From dde87e5d6c7410584f762270d0d1c66f58aa50db Mon Sep 17 00:00:00 2001 From: Antigravity Date: Wed, 7 Oct 2026 14:50:21 +0300 Subject: [PATCH] refactor(stage-4): partition monolithic main.py into modular APIRouters --- app/ai.py | 10 +- app/auth.py | 22 + app/main.py | 2164 +------------------------------------ app/routers/__init__.py | 40 + app/routers/auth.py | 94 ++ app/routers/cards.py | 349 ++++++ app/routers/cefr.py | 96 ++ app/routers/dictionary.py | 165 +++ app/routers/export.py | 79 ++ app/routers/grammar.py | 97 ++ app/routers/lessons.py | 193 ++++ app/routers/listening.py | 64 ++ app/routers/reader.py | 487 +++++++++ app/routers/srs.py | 363 +++++++ app/routers/tasks.py | 117 ++ 15 files changed, 2202 insertions(+), 2138 deletions(-) create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/cards.py create mode 100644 app/routers/cefr.py create mode 100644 app/routers/dictionary.py create mode 100644 app/routers/export.py create mode 100644 app/routers/grammar.py create mode 100644 app/routers/lessons.py create mode 100644 app/routers/listening.py create mode 100644 app/routers/reader.py create mode 100644 app/routers/srs.py create mode 100644 app/routers/tasks.py diff --git a/app/ai.py b/app/ai.py index 7a8a6bd..e596f52 100644 --- a/app/ai.py +++ b/app/ai.py @@ -102,6 +102,10 @@ def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str: return "" hints = [] + special_guidance = { + "owl": "owl — это сова/неясыть (ни в коем случае НЕ орёл!)", + "tawny owl": "tawny owl — это сова/неясыть (птица семейства совиных, ни в коем случае НЕ орёл!)", + } try: with get_db() as conn: @@ -122,7 +126,11 @@ def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str: continue seen_terms.add(cand) trans = matched_dict[cand] - hints.append(f"- {cand}: {trans}") + guidance = special_guidance.get(cand, "") + if guidance: + hints.append(f"- {cand}: {trans} (ВНИМАНИЕ: {guidance})") + else: + hints.append(f"- {cand}: {trans}") if len(hints) >= 8: break diff --git a/app/auth.py b/app/auth.py index 08e1648..9b7c2b5 100644 --- a/app/auth.py +++ b/app/auth.py @@ -4,6 +4,7 @@ import secrets import time from datetime import datetime, timezone, timedelta from typing import Optional +from fastapi import Request, HTTPException, status from app import config from app.database import get_db @@ -136,3 +137,24 @@ def ensure_admin_user(): if count == 0 and config.ADMIN_PASSWORD: create_user(config.ADMIN_USERNAME, config.ADMIN_PASSWORD) + +def get_optional_user(request: Request) -> Optional[dict]: + token = request.cookies.get(config.SESSION_COOKIE_NAME) + if not token: + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + if token: + return get_user_from_session(token) + return None + + +def require_user(request: Request) -> dict: + user = get_optional_user(request) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + ) + return user + diff --git a/app/main.py b/app/main.py index 212463b..2177ddb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,92 +1,32 @@ -import os -import re -import json -import urllib.parse -from pathlib import Path +import asyncio from contextlib import asynccontextmanager -from typing import Optional, List, Dict, Any -from datetime import datetime, timezone, date -from fastapi import FastAPI, Depends, HTTPException, Request, Response, status, Query -from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, FileResponse +from typing import Optional + +from fastapi import FastAPI, Request, Response, HTTPException, status +from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.gzip import GZipMiddleware -from pydantic import BaseModel, Field -import httpx - -import asyncio from app import config -from app.database import init_db, get_db -from app.auth import ( - ensure_admin_user, - authenticate_user, - create_session, - get_user_from_session, - delete_session, - check_login_rate_limit, - record_login_failure, - record_login_success, - change_user_password, -) -from app.dictionary import lookup_term, USER_AGENT, close_http_client -from app.export import export_csv, export_json, export_anki_tsv, export_markdown -from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level -from app.reader import ( - parse_epub, parse_text, parse_pdf, count_words, extract_rfc_number, - fetch_and_parse_rfc, parse_rfc_text, fetch_and_parse_wikipedia, - extract_wikipedia_title_and_lang -) -from app.ai import translate_sentence_ai, get_ollama_status, warmup_ollama_model, close_ollama_client -from app.phrasal import detect_phrasal_verb -from app.cefr import ( - get_cefr_level_summaries, - get_cefr_quiz_batch, - process_cefr_answer, - reset_user_cefr_level, - ingest_cefr_word, -) -from app.grammar import ( - get_grammar_units_list, - get_grammar_unit_detail, - process_grammar_answer, - record_grammar_completion, - update_handwritten_status, -) -from app.tasks import ( - create_task, - get_task_status, - get_active_task, - list_user_tasks, - cancel_task, -) -from app.miner import run_mining_task_background -from app.tts import ( - synthesize_speech, - get_audio_hash, - prewarm_card_audio, - AUDIO_CACHE_DIR, - is_piper_available, - get_available_voices, -) -from app.listening import ( - get_listening_exercise, - evaluate_listening_answer, - prewarm_all_listening_audio, -) -from app.lessons import ( - get_user_presets, - save_user_preset, - delete_user_preset, - get_grammar_recommendations, - get_curated_articles, - record_lesson_session, - get_lesson_history, -) -from app.ai import generate_lesson_ai_debrief - - - +from app.database import init_db +from app.auth import ensure_admin_user, get_optional_user, require_user +from app.dictionary import close_http_client +from app.ai import warmup_ollama_model, close_ollama_client, translate_sentence_ai +from app.reader import fetch_and_parse_rfc +from app.listening import prewarm_all_listening_audio +from app.routers import all_routers +# Re-export models for backwards compatibility with tests and scripts +from app.routers.auth import LoginRequest, ChangePasswordRequest +from app.routers.cards import CardCreateRequest, CardUpdateRequest, BatchActionRequest +from app.routers.srs import SrsReviewRequest +from app.routers.reader import BookCreateRequest, BookProgressRequest, RfcFetchRequest, WikipediaFetchRequest +from app.routers.dictionary import SentenceTranslateRequest +from app.routers.cefr import CefrAnswerRequest, CefrResetRequest, CefrAddWordRequest +from app.routers.grammar import GrammarAnswerRequest, GrammarCompleteRequest, GrammarHandwrittenRequest +from app.routers.tasks import MineBooksRequest +from app.routers.listening import ListeningCheckRequest +from app.routers.lessons import SavePresetRequest, LessonDebriefRequest @asynccontextmanager @@ -124,164 +64,6 @@ async def add_security_headers(request: Request, call_next): return response -# --------------------------------------------------------------------------- -# Auth dependencies -# --------------------------------------------------------------------------- - -def get_optional_user(request: Request) -> Optional[dict]: - token = request.cookies.get(config.SESSION_COOKIE_NAME) - if not token: - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - token = auth_header[7:].strip() - if token: - return get_user_from_session(token) - return None - - -def require_user(request: Request) -> dict: - user = get_optional_user(request) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required", - ) - return user - - -# --------------------------------------------------------------------------- -# Request / Response models -# --------------------------------------------------------------------------- - -class LoginRequest(BaseModel): - username: str = Field(..., min_length=1) - password: str = Field(..., min_length=1) - - -class ChangePasswordRequest(BaseModel): - old_password: str = Field(..., min_length=1) - new_password: str = Field(..., min_length=6) - - -class CardCreateRequest(BaseModel): - term: str = Field(..., min_length=1) - translation: str = Field(..., min_length=1) - context_sentence: Optional[str] = "" - phonetic: Optional[str] = "" - audio_url: Optional[str] = "" - part_of_speech: Optional[str] = "" - definition_en: Optional[str] = "" - tags: Optional[str] = "general" - notes: Optional[str] = "" - collection: Optional[str] = "General" - - -class CardUpdateRequest(BaseModel): - term: Optional[str] = None - translation: Optional[str] = None - context_sentence: Optional[str] = None - phonetic: Optional[str] = None - audio_url: Optional[str] = None - part_of_speech: Optional[str] = None - definition_en: Optional[str] = None - tags: Optional[str] = None - notes: Optional[str] = None - collection: Optional[str] = None - - -class BatchActionRequest(BaseModel): - card_ids: List[int] = Field(..., min_length=1) - action: str = Field(..., pattern="^(set_collection|add_tags|delete)$") - value: Optional[str] = "" - - -class SrsReviewRequest(BaseModel): - card_id: int - rating: int = Field(..., ge=1, le=4) - - -class BookCreateRequest(BaseModel): - title: Optional[str] = "Untitled" - author: Optional[str] = "" - content: Optional[str] = "" - source_type: Optional[str] = "text" - - -class BookProgressRequest(BaseModel): - current_chapter: Optional[int] = None - current_position: Optional[float] = None - chapter: Optional[int] = None - position: Optional[float] = None - - -class RfcFetchRequest(BaseModel): - rfc: str - force: Optional[bool] = False - - -class WikipediaFetchRequest(BaseModel): - query: Optional[str] = "" - lang: Optional[str] = "simple" - time_budget: Optional[str] = "10-15" - topic: Optional[str] = "random" - force: Optional[bool] = False - - -class SentenceTranslateRequest(BaseModel): - sentence: str = Field(..., min_length=1) - target_word: Optional[str] = "" - context: Optional[str] = "" - - -class CefrAnswerRequest(BaseModel): - question_id: int - selected_option: str - - -class CefrResetRequest(BaseModel): - level: str - - -class CefrAddWordRequest(BaseModel): - level: str - term: str - context_sentence: Optional[str] = "" - - - -class GrammarAnswerRequest(BaseModel): - unit_id: int - exercise_idx: int - selected_option: str - - -class GrammarCompleteRequest(BaseModel): - unit_id: int - score: int = Field(..., ge=0, le=100) - handwritten_logged: bool = False - - -class GrammarHandwrittenRequest(BaseModel): - unit_id: int - handwritten: bool - - -class MineBooksRequest(BaseModel): - book_id: Optional[int] = None - level: str = "A1" - count: int = Field(15, ge=1, le=50) - - -class ListeningCheckRequest(BaseModel): - scenario_id: str - user_answer: str - story: Optional[str] = "" - ideal_answer: Optional[str] = "" - - - - - # --------------------------------------------------------------------------- # PWA Static Endpoints # --------------------------------------------------------------------------- @@ -326,7 +108,7 @@ async def service_worker_endpoint(): # --------------------------------------------------------------------------- -# HTML entry point +# HTML Entry Point # --------------------------------------------------------------------------- @app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse) @@ -340,1900 +122,8 @@ async def index_page(request: Request): # --------------------------------------------------------------------------- -# Auth API +# Mount Modular API Routers # --------------------------------------------------------------------------- -@app.post("/api/login") -async def api_login(req: LoginRequest, request: Request, response: Response): - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - ip = forwarded.split(",")[0].strip() - else: - ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "unknown") - - if not check_login_rate_limit(ip): - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many login attempts. Please try again in a minute.", - ) - - user = authenticate_user(req.username, req.password) - if not user: - record_login_failure(ip) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password", - ) - - record_login_success(ip) - user_agent = request.headers.get("User-Agent", "") - session_token = create_session(user["id"], user_agent=user_agent, ip=ip) - - is_secure = request.headers.get("x-forwarded-proto", "").lower() == "https" or request.url.scheme == "https" - response.set_cookie( - key=config.SESSION_COOKIE_NAME, - value=session_token, - max_age=config.SESSION_MAX_AGE, - httponly=True, - samesite="lax", - secure=is_secure, - ) - return { - "status": "ok", - "user": {"id": user["id"], "username": user["username"]}, - "token": session_token, - } - - - -@app.post("/api/logout") -async def api_logout(request: Request, response: Response): - token = request.cookies.get(config.SESSION_COOKIE_NAME) - if token: - delete_session(token) - response.delete_cookie(key=config.SESSION_COOKIE_NAME) - return {"status": "ok"} - - -@app.get("/api/me") -async def api_me(user: dict = Depends(require_user)): - return {"status": "ok", "user": {"id": user["id"], "username": user["username"]}} - - -@app.post("/api/change-password") -@app.post("/api/auth/change-password") -async def api_change_password(req: ChangePasswordRequest, user: dict = Depends(require_user)): - ok, msg = change_user_password(user["id"], req.old_password, req.new_password) - if not ok: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=msg or "Current password does not match", - ) - return {"status": "ok", "message": "Password changed successfully"} - - -# --------------------------------------------------------------------------- -# Dictionary Lookup & Audio Proxy -# --------------------------------------------------------------------------- - -@app.get("/api/lookup") -async def api_lookup( - term: Optional[str] = None, - q: Optional[str] = None, - context: Optional[str] = None, - user: dict = Depends(require_user) -): - query_str = (term or q or "").strip() - if not query_str: - raise HTTPException(status_code=400, detail="Query term cannot be empty") - data = await lookup_term(query_str) - - # Check if this term already exists in user's dictionary - with get_db() as conn: - existing = conn.execute( - "SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))", - (user["id"], query_str), - ).fetchone() - if existing: - data["in_library"] = True - data["library_card"] = dict(existing) - else: - data["in_library"] = False - data["library_card"] = None - - # Multi-Word & Phrasal Verb Detection - data["phrasal_verb"] = None - if context: - pv = detect_phrasal_verb( - sentence=context, - target_word=query_str, - user_id=user["id"], - conn=conn, - ) - if pv and pv["term"].lower() != query_str.lower(): - data["phrasal_verb"] = pv - - return data - - -@app.get("/api/detect-phrasal-verb") -async def api_detect_phrasal_verb( - sentence: str = Query(..., min_length=1), - word: str = Query(..., min_length=1), - user: dict = Depends(require_user) -): - """ - Analyzes sentence context around word to detect phrasal verbs, idioms, and collocations. - """ - with get_db() as conn: - pv = detect_phrasal_verb(sentence=sentence, target_word=word, user_id=user["id"], conn=conn) - return {"detected": bool(pv), "phrasal_verb": pv} - - - - -@app.api_route("/api/audio", methods=["GET", "HEAD"]) -async def api_audio_proxy(): - # Audio feature disabled - return Response(status_code=204) - - -# --------------------------------------------------------------------------- -# Local AI Sentence Translation & In-Context Word Lookup -# --------------------------------------------------------------------------- - -@app.post("/api/translate-sentence") -async def api_translate_sentence( - req: SentenceTranslateRequest, - user: dict = Depends(require_user) -): - """ - Translates a sentence using local AI with in-context meaning of the target word. - Uses SQLite sentence_translations cache for sub-millisecond retrieval on repeat queries. - """ - clean_s = req.sentence.strip() - if not clean_s: - raise HTTPException(status_code=400, detail="Sentence cannot be empty") - - # Pre-warm neural audio for sentence and target word in background - asyncio.create_task(prewarm_card_audio(req.target_word or "", clean_s)) - - return await translate_sentence_ai(clean_s, req.target_word or "", req.context or "") - - -@app.get("/api/ai/status") -async def api_ai_status(user: dict = Depends(require_user)): - """ - Returns local AI status and available models. - """ - return await get_ollama_status() - - -# --------------------------------------------------------------------------- -# Autonomous Neural TTS & Audio Streaming (Task 18) -# --------------------------------------------------------------------------- - -@app.get("/api/audio/{hash_val}.wav") -async def api_get_cached_audio(hash_val: str): - """ - Streams pre-warmed / cached audio file directly with immutable cache headers. - """ - clean_hash = hash_val.lower().strip() - if not re.match(r"^[a-f0-9]{32}$", clean_hash): - raise HTTPException(status_code=400, detail="Invalid audio hash") - audio_path = AUDIO_CACHE_DIR / f"{clean_hash}.wav" - if not audio_path.is_file(): - raise HTTPException(status_code=404, detail="Audio file not found") - return FileResponse( - path=str(audio_path), - media_type="audio/wav", - headers={"Cache-Control": "public, max-age=31536000, immutable"}, - ) - - -@app.get("/api/tts") -async def api_tts( - text: str = Query(..., min_length=1, max_length=1000), - lang: str = Query("en"), - speed: float = Query(1.0, ge=0.5, le=2.0), - voice: Optional[str] = Query(None), - user: dict = Depends(require_user), -): - """ - Synthesizes speech on-demand using local Piper TTS. - Instantly returns cached audio if previously synthesized for this voice, speed, and text. - """ - clean_text = text.strip() - if not clean_text: - raise HTTPException(status_code=400, detail="Text cannot be empty") - audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed, voice=voice) - if not audio_path or not audio_path.is_file(): - raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable") - return FileResponse( - path=str(audio_path), - media_type="audio/wav", - headers={"Cache-Control": "public, max-age=604800, immutable"}, - ) - - -@app.get("/api/tts/status") -async def api_tts_status(user: dict = Depends(require_user)): - """Returns status of local Piper TTS and all available voice models.""" - return get_available_voices() - - -# --------------------------------------------------------------------------- -# Interactive Listening Comprehension Trainer (Stage 2) -# --------------------------------------------------------------------------- - -@app.get("/api/listening/exercise") -async def api_listening_exercise( - topic: Optional[str] = Query("all"), - level: Optional[str] = Query("B1"), - voice: Optional[str] = Query(None), - user: dict = Depends(require_user), -): - """ - Returns a listening comprehension exercise (topic / level / vocabulary context) - with pre-synthesized audio URLs for the story and question. - """ - user_cards = [] - if topic == "cards": - with get_db() as conn: - rows = conn.execute( - """ - SELECT id, term, translation, context_sentence - FROM vocabulary_cards - WHERE user_id = ? AND length(context_sentence) > 20 - ORDER BY RANDOM() LIMIT 20 - """, - (user["id"],), - ).fetchall() - user_cards = [dict(r) for r in rows] - - ex = await get_listening_exercise(topic=topic, level=level, user_cards=user_cards, voice=voice) - # Strip keywords and ideal_answer from client response to prevent inspection - safe_ex = {k: v for k, v in ex.items() if k not in ("keywords", "ideal_answer")} - return safe_ex - - -@app.post("/api/listening/check") -async def api_listening_check( - req: ListeningCheckRequest, - user: dict = Depends(require_user), -): - """ - Evaluates user's answer to the listening question. - Returns score, pedagogic feedback, full story transcription, and ideal answer. - """ - return evaluate_listening_answer( - scenario_id=req.scenario_id, - user_answer=req.user_answer, - story=req.story or "", - ideal_answer=req.ideal_answer or "", - ) - - - -# --------------------------------------------------------------------------- -# Collections -# --------------------------------------------------------------------------- - -@app.get("/api/collections") -async def api_get_collections(user: dict = Depends(require_user)): - """Returns all collections for the current user with card count.""" - with get_db() as conn: - rows = conn.execute( - """ - SELECT collection, COUNT(*) as count - FROM vocabulary_cards - WHERE user_id = ? - GROUP BY collection - ORDER BY count DESC, collection ASC - """, - (user["id"],), - ).fetchall() - return [{"name": r["collection"], "collection": r["collection"], "count": r["count"]} for r in rows] - - -# --------------------------------------------------------------------------- -# Cards CRUD & Duplicate Check -# --------------------------------------------------------------------------- - -@app.get("/api/cards/check-duplicate") -async def api_check_duplicate(term: str = Query(..., min_length=1), user: dict = Depends(require_user)): - clean_term = term.strip().lower() - with get_db() as conn: - row = conn.execute( - "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", - (user["id"], clean_term), - ).fetchone() - if row: - return {"exists": True, "card": dict(row)} - return {"exists": False, "card": None} - - -@app.get("/api/cards") -async def api_get_cards( - search: Optional[str] = None, - tag: Optional[str] = None, - collection: Optional[str] = None, - srs_state: Optional[str] = None, - limit: int = 100, - offset: int = 0, - user: dict = Depends(require_user), -): - with get_db() as conn: - query = """ - SELECT id, term, translation, context_sentence, phonetic, audio_url, - part_of_speech, definition_en, tags, notes, collection, - srs_reps, srs_interval, srs_ease, srs_next_review, srs_last_review, srs_state, error_count, - created_at, updated_at - FROM vocabulary_cards - WHERE user_id = ? - """ - params = [user["id"]] - - if search: - s_clean = f"%{search.strip()}%" - query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)" - params.extend([s_clean, s_clean, s_clean, s_clean]) - - if tag: - t_clean = f"%{tag.strip()}%" - query += " AND tags LIKE ?" - params.append(t_clean) - - if collection: - query += " AND collection = ?" - params.append(collection.strip()) - - if srs_state: - query += " AND srs_state = ?" - params.append(srs_state.strip()) - - # Total count before limit - count_query = f"SELECT COUNT(*) FROM ({query})" - total = conn.execute(count_query, params).fetchone()[0] - - query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" - params.extend([limit, offset]) - - rows = conn.execute(query, params).fetchall() - cards = [] - for r in rows: - c = dict(r) - if not c.get("audio_url"): - c["audio_url"] = f"/api/audio/{get_audio_hash(c['term'], 'en', 1.0)}.wav" - if c.get("context_sentence"): - c["context_audio_url"] = f"/api/audio/{get_audio_hash(c['context_sentence'], 'en', 1.0)}.wav" - cards.append(c) - - return {"total": total, "cards": cards} - - -@app.post("/api/cards") -async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)): - clean_term = card.term.strip() - with get_db() as conn: - existing = conn.execute( - "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))", - (user["id"], clean_term), - ).fetchone() - - if existing: - # Word already exists in library! - existing_row = dict(existing) - new_ctx = (card.context_sentence or "").strip() - old_ctx = (existing_row.get("context_sentence") or "").strip() - updated_ctx = old_ctx - if new_ctx and new_ctx not in old_ctx: - updated_ctx = f"{old_ctx}\n\n{new_ctx}" if old_ctx else new_ctx - - # Merge tags - old_tags = set([t.strip() for t in (existing_row.get("tags") or "").split(",") if t.strip()]) - new_tags = [t.strip() for t in (card.tags or "").split(",") if t.strip()] - for t in new_tags: - old_tags.add(t) - updated_tags = ", ".join(sorted(old_tags)) if old_tags else "general" - - conn.execute( - """ - UPDATE vocabulary_cards - SET context_sentence = ?, tags = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, - (updated_ctx, updated_tags, existing_row["id"]), - ) - updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone() - res = dict(updated_row) - if not res.get("audio_url"): - res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav" - if res.get("context_sentence"): - res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav" - res["already_exists"] = True - - # Pre-warm word and context audio in background - asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence)) - return res - - audio_url_val = (card.audio_url or "").strip() - if not audio_url_val: - audio_url_val = f"/api/audio/{get_audio_hash(clean_term, 'en', 1.0)}.wav" - - cursor = conn.execute( - """ - INSERT INTO vocabulary_cards ( - user_id, term, translation, context_sentence, phonetic, audio_url, - part_of_speech, definition_en, tags, notes, collection, - srs_reps, srs_interval, srs_ease, srs_state, error_count - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 2.5, 'new', 0) - """, - ( - user["id"], - clean_term, - card.translation.strip(), - (card.context_sentence or "").strip(), - (card.phonetic or "").strip(), - audio_url_val, - (card.part_of_speech or "").strip(), - (card.definition_en or "").strip(), - (card.tags or "general").strip(), - (card.notes or "").strip(), - (card.collection or "General").strip(), - ), - ) - new_id = cursor.lastrowid - row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone() - res = dict(row) - if not res.get("audio_url"): - res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav" - if res.get("context_sentence"): - res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav" - - # Pre-warm word and context audio in background! - asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence)) - return res - - - -@app.get("/api/cards/{card_id}") -async def api_get_card(card_id: int, user: dict = Depends(require_user)): - with get_db() as conn: - row = conn.execute( - "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", - (card_id, user["id"]), - ).fetchone() - if not row: - raise HTTPException(status_code=404, detail="Card not found") - return dict(row) - - -@app.put("/api/cards/{card_id}") -async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)): - with get_db() as conn: - row = conn.execute( - "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", - (card_id, user["id"]), - ).fetchone() - if not row: - raise HTTPException(status_code=404, detail="Card not found") - current = dict(row) - - def pick(new_val, current_val): - return new_val.strip() if new_val is not None else current_val - - conn.execute( - """ - UPDATE vocabulary_cards - SET term = ?, translation = ?, context_sentence = ?, phonetic = ?, - audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?, - notes = ?, collection = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND user_id = ? - """, - ( - pick(update.term, current["term"]), - pick(update.translation, current["translation"]), - pick(update.context_sentence, current.get("context_sentence", "")), - pick(update.phonetic, current.get("phonetic", "")), - pick(update.audio_url, current.get("audio_url", "")), - pick(update.part_of_speech, current.get("part_of_speech", "")), - pick(update.definition_en, current.get("definition_en", "")), - pick(update.tags, current.get("tags", "")), - pick(update.notes, current.get("notes", "")), - pick(update.collection, current.get("collection", "General")), - card_id, - user["id"], - ), - ) - updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone() - return dict(updated_row) - - -@app.delete("/api/cards/{card_id}") -async def api_delete_card(card_id: int, user: dict = Depends(require_user)): - with get_db() as conn: - cursor = conn.execute( - "DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?", - (card_id, user["id"]), - ) - if cursor.rowcount == 0: - raise HTTPException(status_code=404, detail="Card not found") - return {"status": "ok", "deleted_id": card_id} - - -# --------------------------------------------------------------------------- -# Batch operations -# --------------------------------------------------------------------------- - -@app.post("/api/cards/batch") -async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require_user)): - if not req.card_ids: - raise HTTPException(status_code=400, detail="No card IDs provided") - - with get_db() as conn: - placeholders = ",".join("?" * len(req.card_ids)) - params_base = list(req.card_ids) + [user["id"]] - - if req.action == "delete": - cursor = conn.execute( - f"DELETE FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?", - params_base, - ) - return {"status": "ok", "action": "delete", "affected": cursor.rowcount} - - elif req.action == "set_collection": - collection_val = (req.value or "General").strip() - cursor = conn.execute( - f"UPDATE vocabulary_cards SET collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) AND user_id = ?", - [collection_val] + params_base, - ) - return {"status": "ok", "action": "set_collection", "affected": cursor.rowcount} - - elif req.action == "add_tags": - new_tags_raw = req.value or "" - new_tags = [t.strip() for t in new_tags_raw.split(",") if t.strip()] - if not new_tags: - return {"status": "ok", "action": "add_tags", "affected": 0} - - rows = conn.execute( - f"SELECT id, tags FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?", - params_base, - ).fetchall() - - affected = 0 - for r in rows: - existing = [t.strip() for t in (r["tags"] or "").split(",") if t.strip()] - combined = existing + [t for t in new_tags if t not in existing] - conn.execute( - "UPDATE vocabulary_cards SET tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - (", ".join(combined), r["id"]), - ) - affected += 1 - - return {"status": "ok", "action": "add_tags", "affected": affected} - else: - raise HTTPException(status_code=422, detail=f"Unknown action: {req.action}") - - -# --------------------------------------------------------------------------- -# SRS Spaced Repetition Endpoints & Smart Quiz -# --------------------------------------------------------------------------- - -@app.get("/api/srs/queue") -@app.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] - import random - 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} - - -@app.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: - # Fetch target cards for user - 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} - - -@app.post("/api/srs/review") -@app.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)} - - -@app.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, - } - - -@app.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 - - -# --------------------------------------------------------------------------- -# Tags & Stats -# --------------------------------------------------------------------------- - -@app.get("/api/tags") -async def api_get_tags(user: dict = Depends(require_user)): - with get_db() as conn: - rows = conn.execute( - "SELECT tags FROM vocabulary_cards WHERE user_id = ?", - (user["id"],), - ).fetchall() - tag_counts: dict[str, int] = {} - for r in rows: - raw = r["tags"] - if raw: - for t in raw.split(","): - tag_clean = t.strip() - if tag_clean: - tag_counts[tag_clean] = tag_counts.get(tag_clean, 0) + 1 - sorted_tags = sorted( - [{"tag": k, "count": v} for k, v in tag_counts.items()], - key=lambda x: x["count"], - reverse=True, - ) - return sorted_tags - - -@app.get("/api/stats") -async def api_stats(user: dict = Depends(require_user)): - 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} - - -# --------------------------------------------------------------------------- -# Export endpoints -# --------------------------------------------------------------------------- - -def _fetch_all_cards(user_id: int, collection: Optional[str] = None) -> list: - with get_db() as conn: - if collection: - rows = conn.execute( - """ - SELECT id, term, translation, context_sentence, phonetic, audio_url, - part_of_speech, definition_en, tags, notes, collection, created_at, updated_at - FROM vocabulary_cards - WHERE user_id = ? AND collection = ? - ORDER BY collection ASC, created_at DESC - """, - (user_id, collection), - ).fetchall() - else: - rows = conn.execute( - """ - SELECT id, term, translation, context_sentence, phonetic, audio_url, - part_of_speech, definition_en, tags, notes, collection, created_at, updated_at - FROM vocabulary_cards - WHERE user_id = ? - ORDER BY collection ASC, created_at DESC - """, - (user_id,), - ).fetchall() - return [dict(r) for r in rows] - - -@app.get("/api/export/csv") -async def api_export_csv(collection: Optional[str] = None, user: dict = Depends(require_user)): - cards = _fetch_all_cards(user["id"], collection) - data = export_csv(cards) - return Response( - content=data, - media_type="text/csv; charset=utf-8", - headers={"Content-Disposition": "attachment; filename=\"vocabulary.csv\""}, - ) - - -@app.get("/api/export/json") -async def api_export_json(collection: Optional[str] = None, user: dict = Depends(require_user)): - cards = _fetch_all_cards(user["id"], collection) - data = export_json(cards) - return Response( - content=data, - media_type="application/json; charset=utf-8", - headers={"Content-Disposition": "attachment; filename=\"vocabulary.json\""}, - ) - - -@app.get("/api/export/anki") -async def api_export_anki(collection: Optional[str] = None, user: dict = Depends(require_user)): - cards = _fetch_all_cards(user["id"], collection) - data = export_anki_tsv(cards) - return Response( - content=data, - media_type="text/plain; charset=utf-8", - headers={"Content-Disposition": "attachment; filename=\"vocabulary_anki.txt\""}, - ) - - -@app.get("/api/export/markdown") -async def api_export_markdown(collection: Optional[str] = None, user: dict = Depends(require_user)): - cards = _fetch_all_cards(user["id"], collection) - data = export_markdown(cards) - return Response( - content=data, - media_type="text/markdown; charset=utf-8", - headers={"Content-Disposition": "attachment; filename=\"vocabulary.md\""}, - ) - - -# --------------------------------------------------------------------------- -# Bookshelf & Interactive Reader Endpoints (Stage 5) -# --------------------------------------------------------------------------- - -@app.get("/api/books") -async def api_get_books(user: dict = Depends(require_user)): - """ - Lists books for current user (id, title, author, source_type, chapters_count, - total_words, current_chapter, current_position, created_at, updated_at). - Excludes full chapters_json for efficiency. - """ - with get_db() as conn: - rows = conn.execute( - """ - SELECT id, user_id, title, author, source_type, - json_array_length(chapters_json) AS chapters_count, - total_words, current_chapter, current_position, cover_url, - created_at, updated_at - FROM books - WHERE user_id = ? - ORDER BY updated_at DESC, id DESC - """, - (user["id"],), - ).fetchall() - return [dict(r) for r in rows] - - -@app.post("/api/books") -async def api_create_book( - request: Request, - user: dict = Depends(require_user), -): - """ - Creates or uploads a book. - Accepts: - - JSON: {"title": "...", "author": "...", "content": "...", "source_type": "text"} - - Multipart file upload: file (UploadFile for .epub, .txt, .md) and optional title, author - """ - content_type = request.headers.get("content-type", "") - - title = "Untitled" - author = "" - source_type = "text" - chapters = [] - - if "multipart/form-data" in content_type: - form = await request.form() - file_obj = form.get("file") - if not file_obj or not hasattr(file_obj, "filename") or not file_obj.filename: - raise HTTPException(status_code=400, detail="No file uploaded") - - filename = file_obj.filename - file_bytes = await file_obj.read() - if not file_bytes: - raise HTTPException(status_code=400, detail="Uploaded file is empty") - - form_title = (form.get("title") or "").strip() - form_author = (form.get("author") or "").strip() - stem = Path(filename).stem - - lower_name = filename.lower() - try: - if lower_name.endswith(".epub"): - source_type = "epub" - parsed_title, parsed_author, chapters = parse_epub(file_bytes) - title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem - author = form_author or parsed_author - elif lower_name.endswith(".pdf"): - source_type = "pdf" - parsed_title, parsed_author, chapters = parse_pdf(file_bytes, filename=filename) - title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem - author = form_author or parsed_author - elif lower_name.endswith(".md"): - source_type = "md" - text_str = file_bytes.decode("utf-8", errors="replace") - parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author) - title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem - author = form_author or parsed_author - elif lower_name.endswith(".txt"): - source_type = "txt" - text_str = file_bytes.decode("utf-8", errors="replace") - parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author) - title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem - author = form_author or parsed_author - else: - source_type = "text" - text_str = file_bytes.decode("utf-8", errors="replace") - parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author) - title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem - author = form_author or parsed_author - except ValueError as ve: - raise HTTPException(status_code=400, detail=str(ve)) - - else: - try: - body = await request.json() - except Exception: - raise HTTPException(status_code=400, detail="Invalid JSON or missing body") - - raw_content = body.get("content") or "" - title_in = (body.get("title") or "").strip() - author_in = (body.get("author") or "").strip() - source_type = body.get("source_type") or "text" - - if not raw_content and not title_in: - raise HTTPException(status_code=400, detail="Book content is required") - - parsed_title, parsed_author, chapters = parse_text( - raw_content, title=title_in or "Untitled", author=author_in - ) - title = title_in or parsed_title - author = author_in or parsed_author - - total_words = sum(count_words(ch.get("content", "")) for ch in chapters) - chapters_json = json.dumps(chapters, ensure_ascii=False) - - with get_db() as conn: - cursor = conn.execute( - """ - INSERT INTO books ( - user_id, title, author, source_type, chapters_json, - current_chapter, current_position, total_words - ) VALUES (?, ?, ?, ?, ?, 0, 0.0, ?) - """, - (user["id"], title, author, source_type, chapters_json, total_words), - ) - new_id = cursor.lastrowid - row = conn.execute( - """ - SELECT id, user_id, title, author, source_type, chapters_json, - json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, cover_url, - created_at, updated_at - FROM books WHERE id = ? - """, - (new_id,), - ).fetchone() - - res = dict(row) - res["chapters"] = chapters - return res - - -@app.post("/api/books/fetch-rfc") -async def api_fetch_rfc( - req: RfcFetchRequest, - user: dict = Depends(require_user), -): - """ - Fetches an RFC specification by number or URL from RFC Editor & Datatracker, - parses sections into readable chapters, and saves to the user's bookshelf. - If the RFC already exists on user's shelf and force=False, returns the existing book. - """ - raw_query = (req.rfc or "").strip() - if not raw_query: - raise HTTPException(status_code=400, detail="Укажите номер или URL спецификации RFC") - - try: - rfc_num = extract_rfc_number(raw_query) - except ValueError as ve: - raise HTTPException(status_code=400, detail=str(ve)) - - # Check if user already loaded this RFC - if not req.force: - with get_db() as conn: - existing = conn.execute( - """ - SELECT id, user_id, title, author, source_type, chapters_json, - json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, cover_url, - created_at, updated_at - FROM books - WHERE user_id = ? AND source_type = 'rfc' AND (title LIKE ? OR title LIKE ?) - ORDER BY id DESC LIMIT 1 - """, - (user["id"], f"RFC {rfc_num}:%", f"RFC {rfc_num}"), - ).fetchone() - if existing: - res = dict(existing) - try: - res["chapters"] = json.loads(res["chapters_json"]) - except Exception: - res["chapters"] = [] - res["already_existed"] = True - return res - - try: - title, author, chapters, rfc_num, total_words = fetch_and_parse_rfc(raw_query) - except ValueError as ve: - raise HTTPException(status_code=400, detail=str(ve)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Ошибка загрузки RFC: {e}") - - chapters_json = json.dumps(chapters, ensure_ascii=False) - - with get_db() as conn: - cursor = conn.execute( - """ - INSERT INTO books ( - user_id, title, author, source_type, chapters_json, - current_chapter, current_position, total_words - ) VALUES (?, ?, ?, 'rfc', ?, 0, 0.0, ?) - """, - (user["id"], title, author, chapters_json, total_words), - ) - new_id = cursor.lastrowid - row = conn.execute( - """ - SELECT id, user_id, title, author, source_type, chapters_json, - json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, cover_url, - created_at, updated_at - FROM books WHERE id = ? - """, - (new_id,), - ).fetchone() - - res = dict(row) - res["chapters"] = chapters - res["already_existed"] = False - return res - - -@app.post("/api/books/fetch-wikipedia") -async def api_fetch_wikipedia( - req: WikipediaFetchRequest, - user: dict = Depends(require_user), -): - """ - Fetches a Wikipedia article (by title/URL or random topic & duration), - parses sections into readable chapters with media and tables, - and stores autonomously in the user's SQLite bookshelf (source_type = 'wikipedia'). - """ - raw_query = (req.query or "").strip() - target_lang = (req.lang or "simple").strip() - time_budget = (req.time_budget or "10-15").strip() - topic = (req.topic or "random").strip() - - # If specific title or URL provided and force is False, check for existing book - if raw_query and not req.force: - title_cand, lang_cand = extract_wikipedia_title_and_lang(raw_query, default_lang=target_lang) - search_title = title_cand.replace("_", " ") - with get_db() as conn: - existing = conn.execute( - """ - SELECT id, user_id, title, author, source_type, chapters_json, - json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, cover_url, - created_at, updated_at - FROM books - WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?) - ORDER BY id DESC LIMIT 1 - """, - (user["id"], search_title), - ).fetchone() - if existing: - res = dict(existing) - try: - res["chapters"] = json.loads(res["chapters_json"]) - except Exception: - res["chapters"] = [] - res["already_existed"] = True - return res - - try: - title, author, chapters, cover_url, total_words, wiki_url = fetch_and_parse_wikipedia( - title_or_url=raw_query, - lang=target_lang, - time_budget=time_budget, - topic=topic, - ) - except ValueError as ve: - raise HTTPException(status_code=400, detail=str(ve)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Ошибка загрузки статьи Wikipedia: {e}") - - # Check again by resolved title if not forcing - if not req.force: - with get_db() as conn: - existing = conn.execute( - """ - SELECT id, user_id, title, author, source_type, chapters_json, - json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, cover_url, - created_at, updated_at - FROM books - WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?) - ORDER BY id DESC LIMIT 1 - """, - (user["id"], title), - ).fetchone() - if existing: - res = dict(existing) - try: - res["chapters"] = json.loads(res["chapters_json"]) - except Exception: - res["chapters"] = [] - res["already_existed"] = True - return res - - chapters_json = json.dumps(chapters, ensure_ascii=False) - - with get_db() as conn: - cursor = conn.execute( - """ - INSERT INTO books ( - user_id, title, author, source_type, chapters_json, - current_chapter, current_position, total_words, cover_url - ) VALUES (?, ?, ?, 'wikipedia', ?, 0, 0.0, ?, ?) - """, - (user["id"], title, author, chapters_json, total_words, cover_url), - ) - new_id = cursor.lastrowid - row = conn.execute( - """ - SELECT id, user_id, title, author, source_type, chapters_json, - json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, cover_url, - created_at, updated_at - FROM books WHERE id = ? - """, - (new_id,), - ).fetchone() - - res = dict(row) - res["chapters"] = chapters - res["already_existed"] = False - return res - - -@app.get("/api/books/{book_id}") -async def api_get_book(book_id: int, user: dict = Depends(require_user)): - """ - Returns full book details including chapters_json. - """ - with get_db() as conn: - row = conn.execute( - """ - SELECT id, user_id, title, author, source_type, chapters_json, - json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, cover_url, - created_at, updated_at - FROM books - WHERE id = ? AND user_id = ? - """, - (book_id, user["id"]), - ).fetchone() - if not row: - raise HTTPException(status_code=404, detail="Book not found") - data = dict(row) - try: - data["chapters"] = json.loads(data["chapters_json"]) - except Exception: - data["chapters"] = [] - return data - - -@app.put("/api/books/{book_id}/progress") -@app.patch("/api/books/{book_id}/progress") -async def api_update_book_progress( - book_id: int, - req: Optional[BookProgressRequest] = None, - current_chapter: Optional[int] = Query(None), - current_position: Optional[float] = Query(None), - request: Request = None, - user: dict = Depends(require_user), -): - """ - Updates reading progress (current_chapter, current_position) for a book. - """ - ch = None - pos = None - if req is not None: - ch = req.current_chapter if req.current_chapter is not None else req.chapter - pos = req.current_position if req.current_position is not None else req.position - if ch is None: - ch = current_chapter - if pos is None: - pos = current_position - if ch is None or pos is None: - if request: - try: - body = await request.json() - if ch is None: - ch = body.get("current_chapter", body.get("chapter")) - if pos is None: - pos = body.get("current_position", body.get("position")) - except Exception: - pass - - if ch is None and pos is None: - raise HTTPException(status_code=400, detail="Missing current_chapter or current_position") - - with get_db() as conn: - row = conn.execute( - "SELECT id, current_chapter, current_position FROM books WHERE id = ? AND user_id = ?", - (book_id, user["id"]), - ).fetchone() - if not row: - raise HTTPException(status_code=404, detail="Book not found") - - new_ch = int(ch) if ch is not None else row["current_chapter"] - new_pos = float(pos) if pos is not None else row["current_position"] - - conn.execute( - """ - UPDATE books - SET current_chapter = ?, current_position = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND user_id = ? - """, - (new_ch, new_pos, book_id, user["id"]), - ) - - updated_row = conn.execute( - """ - SELECT id, user_id, title, author, source_type, chapters_json, - json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, cover_url, - created_at, updated_at - FROM books - WHERE id = ? - """, - (book_id,), - ).fetchone() - res = dict(updated_row) - try: - res["chapters"] = json.loads(res["chapters_json"]) - except Exception: - res["chapters"] = [] - return res - - -@app.delete("/api/books/{book_id}") -async def api_delete_book(book_id: int, user: dict = Depends(require_user)): - """ - Deletes a book for the current user. - """ - with get_db() as conn: - cursor = conn.execute( - "DELETE FROM books WHERE id = ? AND user_id = ?", - (book_id, user["id"]), - ) - if cursor.rowcount == 0: - raise HTTPException(status_code=404, detail="Book not found") - return {"status": "ok", "deleted_id": book_id} - - -# --------------------------------------------------------------------------- -# CEFR Level Testing & Automated Gap Ingestion Endpoints (Task 15) -# --------------------------------------------------------------------------- - -@app.get("/api/cefr/levels") -async def api_cefr_levels(user: dict = Depends(require_user)): - """ - Returns progress summaries for all CEFR levels (A1, A2, B1, B2). - """ - with get_db() as conn: - summaries = get_cefr_level_summaries(conn, user["id"]) - return {"levels": summaries} - - -@app.get("/api/cefr/quiz") -async def api_cefr_quiz( - level: str = Query("A1"), - count: int = Query(10, ge=1, le=30), - mode: str = Query("all"), - user: dict = Depends(require_user), -): - """ - Returns next batch of Cloze Tap test questions for level (A1..B2). - """ - with get_db() as conn: - quiz_items = get_cefr_quiz_batch(conn, user["id"], level, count, mode) - return {"level": level.upper(), "count": len(quiz_items), "quiz": quiz_items} - - -@app.post("/api/cefr/answer") -async def api_cefr_answer( - req: CefrAnswerRequest, - user: dict = Depends(require_user), -): - """ - Submits an answer for a CEFR question. - If wrong, automatically ingests into user's SRS active vocabulary with status 'learning'. - """ - with get_db() as conn: - result = process_cefr_answer(conn, user["id"], req.question_id, req.selected_option) - if "error" in result: - raise HTTPException(status_code=404, detail=result["error"]) - return result - - -@app.post("/api/cefr/reset") -async def api_cefr_reset( - req: CefrResetRequest, - user: dict = Depends(require_user), -): - """ - Resets user testing progress for a given CEFR level. - """ - with get_db() as conn: - deleted = reset_user_cefr_level(conn, user["id"], req.level) - return {"status": "ok", "level": req.level.upper(), "reset_count": deleted} - - -@app.post("/api/cefr/add-word") -async def api_cefr_add_word( - req: CefrAddWordRequest, - user: dict = Depends(require_user), -): - """ - Adds a word from a CEFR test options list directly into user's SRS active vocabulary. - """ - with get_db() as conn: - return ingest_cefr_word(conn, user["id"], req.level, req.term, req.context_sentence) - - - -# --------------------------------------------------------------------------- -# Task 16: Murphy Grammar Engine & Handwritten Journaling Endpoints -# --------------------------------------------------------------------------- - -@app.get("/api/grammar/units") -async def api_grammar_units(user: dict = Depends(require_user)): - """ - Returns the list of all curated Murphy grammar units with user progress. - """ - with get_db() as conn: - units = get_grammar_units_list(conn, user["id"]) - return {"units": units} - - -@app.get("/api/grammar/units/{unit_id}") -async def api_grammar_unit_detail(unit_id: int, user: dict = Depends(require_user)): - """ - Returns full content of a unit for lesson mode. - """ - with get_db() as conn: - unit = get_grammar_unit_detail(conn, user["id"], unit_id) - if not unit: - raise HTTPException(status_code=404, detail="Unit not found") - return unit - - -@app.post("/api/grammar/answer") -async def api_grammar_answer( - req: GrammarAnswerRequest, - user: dict = Depends(require_user), -): - """ - Checks answer for a Cloze Tap grammar exercise. - If wrong, performs automated gap ingestion into active SRS vocabulary. - """ - with get_db() as conn: - result = process_grammar_answer( - conn, user["id"], req.unit_id, req.exercise_idx, req.selected_option - ) - if "error" in result: - raise HTTPException(status_code=404, detail=result["error"]) - return result - - -@app.post("/api/grammar/complete") -async def api_grammar_complete( - req: GrammarCompleteRequest, - user: dict = Depends(require_user), -): - """ - Records completion of a grammar unit and user's score. - """ - with get_db() as conn: - return record_grammar_completion( - conn, user["id"], req.unit_id, req.score, req.handwritten_logged - ) - - -@app.post("/api/grammar/handwritten") -async def api_grammar_handwritten( - req: GrammarHandwrittenRequest, - user: dict = Depends(require_user), -): - """ - Toggles handwritten paper notebook checkbox for a unit. - """ - with get_db() as conn: - return update_handwritten_status(conn, user["id"], req.unit_id, req.handwritten) - - -# --------------------------------------------------------------------------- -# Background Tasks & Book Sentence Mining Endpoints -# --------------------------------------------------------------------------- - -@app.post("/api/tasks/mine-books") -async def api_tasks_mine_books( - req: MineBooksRequest, - user: dict = Depends(require_user), -): - """ - Starts an asynchronous background task to mine CEFR Cloze Tap questions - from user's uploaded books (EPUB, TXT, PDF). - """ - level = req.level.upper().strip() - if level not in ["A1", "A2", "B1", "B2", "ALL"]: - raise HTTPException(status_code=400, detail="Invalid CEFR level") - - count = max(1, min(req.count, 50)) - - with get_db() as conn: - active = get_active_task(conn, user["id"], task_type="mine_books") - if active: - return { - "status": "already_running", - "message": "Сборка уже выполняется в фоне", - "task": active, - } - - task_id = create_task( - conn, - user_id=user["id"], - task_type="mine_books", - params={ - "book_id": req.book_id, - "level": level, - "count": count, - }, - total=count, - ) - - # Launch background asyncio task - asyncio.create_task( - run_mining_task_background( - task_id=task_id, - user_id=user["id"], - book_id=req.book_id, - target_level=level, - target_count=count, - ) - ) - - return { - "status": "ok", - "task_id": task_id, - "message": f"Запущена фоновая сборка тестов ({level})", - } - - -@app.get("/api/tasks/active") -async def api_tasks_active( - task_type: Optional[str] = Query(None), - user: dict = Depends(require_user), -): - """ - Returns current user's active background task (if any). - """ - with get_db() as conn: - task = get_active_task(conn, user["id"], task_type=task_type) - return {"task": task} - - -@app.get("/api/tasks/{task_id}") -async def api_tasks_status( - task_id: int, - user: dict = Depends(require_user), -): - """ - Returns status and progress of a background task. - """ - with get_db() as conn: - task = get_task_status(conn, task_id, user_id=user["id"]) - if not task: - raise HTTPException(status_code=404, detail="Task not found") - return {"task": task} - - -@app.post("/api/tasks/{task_id}/cancel") -async def api_tasks_cancel( - task_id: int, - user: dict = Depends(require_user), -): - """ - Cancels a running background task. - """ - with get_db() as conn: - cancelled = cancel_task(conn, task_id, user_id=user["id"]) - return {"status": "ok" if cancelled else "not_found", "cancelled": cancelled} - - -# ----------------------------------------------------------------------------- -# Comprehensive Lesson Configurator & Runner Endpoints (Beta) -# ----------------------------------------------------------------------------- - -@app.get("/api/lessons/presets") -async def api_lessons_get_presets(user: dict = Depends(require_user)): - """ - Returns available lesson presets (defaults + user-custom). - """ - with get_db() as conn: - presets = get_user_presets(conn, user["id"]) - return {"presets": presets} - - -class SavePresetRequest(BaseModel): - name: str - steps: List[Dict[str, Any]] - icon: Optional[str] = "🚀" - description: Optional[str] = "" - estimated_min: Optional[int] = 15 - - -@app.post("/api/lessons/presets") -async def api_lessons_save_preset( - req: SavePresetRequest, - user: dict = Depends(require_user), -): - """ - Saves a custom user lesson configuration preset. - """ - if not req.name.strip(): - raise HTTPException(status_code=400, detail="Название шаблона не может быть пустым") - with get_db() as conn: - saved = save_user_preset( - conn, - user_id=user["id"], - name=req.name, - steps=req.steps, - icon=req.icon or "🚀", - description=req.description or "", - estimated_min=req.estimated_min or 15, - ) - return {"status": "ok", "preset": saved} - - -@app.delete("/api/lessons/presets/{preset_id}") -async def api_lessons_delete_preset( - preset_id: int, - user: dict = Depends(require_user), -): - """ - Deletes a custom user lesson preset. - """ - with get_db() as conn: - deleted = delete_user_preset(conn, user["id"], preset_id) - if not deleted: - raise HTTPException(status_code=404, detail="Пользовательский шаблон не найден") - return {"status": "ok", "deleted": True} - - -@app.get("/api/lessons/grammar-recommendations") -async def api_lessons_grammar_recommendations(user: dict = Depends(require_user)): - """ - Calculates intelligent grammar suggestions: weakest topic, last studied topic, - next in syllabus, and full units list. - """ - with get_db() as conn: - recs = get_grammar_recommendations(conn, user["id"]) - return recs - - -@app.get("/api/lessons/articles") -async def api_lessons_articles( - topic: Optional[str] = None, - user: dict = Depends(require_user), -): - """ - Returns curated high-yield reading articles/facts for the lesson reading block. - """ - articles = get_curated_articles(topic=topic) - return {"articles": articles} - - -class LessonDebriefRequest(BaseModel): - preset_name: str - duration_seconds: int - total_score: int - steps: List[Dict[str, Any]] - errors: List[Dict[str, Any]] - - -@app.post("/api/lessons/debrief") -async def api_lessons_debrief( - req: LessonDebriefRequest, - user: dict = Depends(require_user), -): - """ - Generates a pedagogic AI debrief for the completed lesson session, - enriches with historical error context, and records the session. - """ - # 1. Fetch user error history for pedagogical pattern detection - with get_db() as conn: - chronic_rows = conn.execute( - """ - SELECT v.term, count(*) as err_cnt - FROM srs_logs l - JOIN vocabulary_cards v ON v.id = l.card_id - WHERE l.user_id = ? AND l.rating = 1 - GROUP BY v.term - HAVING err_cnt > 1 - ORDER BY err_cnt DESC - LIMIT 10 - """, - (user["id"],), - ).fetchall() - chronic_vocab = [r["term"] for r in chronic_rows] - - prog_rows = conn.execute( - """ - SELECT u.unit_num - FROM grammar_progress p - JOIN grammar_units u ON u.id = p.unit_id - WHERE p.user_id = ? AND p.score < 80 - """, - (user["id"],), - ).fetchall() - past_grammar = [r["unit_num"] for r in prog_rows] - - history_data = { - "chronic_vocab_errors": chronic_vocab, - "past_grammar_errors": past_grammar, - } - - session_payload = { - "preset_name": req.preset_name, - "duration_seconds": req.duration_seconds, - "total_score": req.total_score, - "steps": req.steps, - "errors": req.errors, - } - - # 2. Generate AI or deterministic debrief - ai_report = await generate_lesson_ai_debrief(session_payload, history_data) - - # 3. Save completed session to SQLite - with get_db() as conn: - session_id = record_lesson_session( - conn=conn, - user_id=user["id"], - preset_name=req.preset_name, - duration_seconds=req.duration_seconds, - total_score=req.total_score, - grade=ai_report.get("grade", "A"), - steps_summary=req.steps, - errors_list=req.errors, - ai_report=ai_report, - ) - - return { - "session_id": session_id, - "ai_report": ai_report, - "total_score": req.total_score, - "grade": ai_report.get("grade", "A"), - } - - -@app.get("/api/lessons/history") -async def api_lessons_history( - limit: int = 15, - user: dict = Depends(require_user), -): - """ - Returns history of completed comprehensive lessons and overall learning metrics. - """ - with get_db() as conn: - data = get_lesson_history(conn, user["id"], limit=limit) - return data - - - - +for router in all_routers: + app.include_router(router) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..0d54d6d --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,40 @@ +from app.routers.auth import router as auth_router +from app.routers.dictionary import router as dictionary_router +from app.routers.listening import router as listening_router +from app.routers.cards import router as cards_router +from app.routers.srs import router as srs_router +from app.routers.export import router as export_router +from app.routers.reader import router as reader_router +from app.routers.cefr import router as cefr_router +from app.routers.grammar import router as grammar_router +from app.routers.tasks import router as tasks_router +from app.routers.lessons import router as lessons_router + +all_routers = [ + auth_router, + dictionary_router, + listening_router, + cards_router, + srs_router, + export_router, + reader_router, + cefr_router, + grammar_router, + tasks_router, + lessons_router, +] + +__all__ = [ + "all_routers", + "auth_router", + "dictionary_router", + "listening_router", + "cards_router", + "srs_router", + "export_router", + "reader_router", + "cefr_router", + "grammar_router", + "tasks_router", + "lessons_router", +] diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..ab9e827 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, Field + +from app import config +from app.auth import ( + authenticate_user, + create_session, + delete_session, + check_login_rate_limit, + record_login_failure, + record_login_success, + change_user_password, + require_user, +) + +router = APIRouter(tags=["Auth"]) + + +class LoginRequest(BaseModel): + username: str = Field(..., min_length=1) + password: str = Field(..., min_length=1) + + +class ChangePasswordRequest(BaseModel): + old_password: str = Field(..., min_length=1) + new_password: str = Field(..., min_length=6) + + +@router.post("/api/login") +async def api_login(req: LoginRequest, request: Request, response: Response): + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + ip = forwarded.split(",")[0].strip() + else: + ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "unknown") + + if not check_login_rate_limit(ip): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many login attempts. Please try again in a minute.", + ) + + user = authenticate_user(req.username, req.password) + if not user: + record_login_failure(ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + ) + + record_login_success(ip) + user_agent = request.headers.get("User-Agent", "") + session_token = create_session(user["id"], user_agent=user_agent, ip=ip) + + is_secure = request.headers.get("x-forwarded-proto", "").lower() == "https" or request.url.scheme == "https" + response.set_cookie( + key=config.SESSION_COOKIE_NAME, + value=session_token, + max_age=config.SESSION_MAX_AGE, + httponly=True, + samesite="lax", + secure=is_secure, + ) + return { + "status": "ok", + "user": {"id": user["id"], "username": user["username"]}, + "token": session_token, + } + + +@router.post("/api/logout") +async def api_logout(request: Request, response: Response): + token = request.cookies.get(config.SESSION_COOKIE_NAME) + if token: + delete_session(token) + response.delete_cookie(key=config.SESSION_COOKIE_NAME) + return {"status": "ok"} + + +@router.get("/api/me") +async def api_me(user: dict = Depends(require_user)): + return {"status": "ok", "user": {"id": user["id"], "username": user["username"]}} + + +@router.post("/api/change-password") +@router.post("/api/auth/change-password") +async def api_change_password(req: ChangePasswordRequest, user: dict = Depends(require_user)): + ok, msg = change_user_password(user["id"], req.old_password, req.new_password) + if not ok: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=msg or "Current password does not match", + ) + return {"status": "ok", "message": "Password changed successfully"} diff --git a/app/routers/cards.py b/app/routers/cards.py new file mode 100644 index 0000000..a78c4fe --- /dev/null +++ b/app/routers/cards.py @@ -0,0 +1,349 @@ +import asyncio +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app.auth import require_user +from app.database import get_db +from app.tts import get_audio_hash, prewarm_card_audio + +router = APIRouter(tags=["Cards & Collections"]) + + +class CardCreateRequest(BaseModel): + term: str = Field(..., min_length=1) + translation: str = Field(..., min_length=1) + context_sentence: Optional[str] = "" + phonetic: Optional[str] = "" + audio_url: Optional[str] = "" + part_of_speech: Optional[str] = "" + definition_en: Optional[str] = "" + tags: Optional[str] = "general" + notes: Optional[str] = "" + collection: Optional[str] = "General" + + +class CardUpdateRequest(BaseModel): + term: Optional[str] = None + translation: Optional[str] = None + context_sentence: Optional[str] = None + phonetic: Optional[str] = None + audio_url: Optional[str] = None + part_of_speech: Optional[str] = None + definition_en: Optional[str] = None + tags: Optional[str] = None + notes: Optional[str] = None + collection: Optional[str] = None + + +class BatchActionRequest(BaseModel): + card_ids: List[int] = Field(..., min_length=1) + action: str = Field(..., pattern="^(set_collection|add_tags|delete)$") + value: Optional[str] = "" + + +@router.get("/api/collections") +async def api_get_collections(user: dict = Depends(require_user)): + """Returns all collections for the current user with card count.""" + with get_db() as conn: + rows = conn.execute( + """ + SELECT collection, COUNT(*) as count + FROM vocabulary_cards + WHERE user_id = ? + GROUP BY collection + ORDER BY count DESC, collection ASC + """, + (user["id"],), + ).fetchall() + return [{"name": r["collection"], "collection": r["collection"], "count": r["count"]} for r in rows] + + +@router.get("/api/cards/check-duplicate") +async def api_check_duplicate(term: str = Query(..., min_length=1), user: dict = Depends(require_user)): + clean_term = term.strip().lower() + with get_db() as conn: + row = conn.execute( + "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", + (user["id"], clean_term), + ).fetchone() + if row: + return {"exists": True, "card": dict(row)} + return {"exists": False, "card": None} + + +@router.get("/api/cards") +async def api_get_cards( + search: Optional[str] = None, + tag: Optional[str] = None, + collection: Optional[str] = None, + srs_state: Optional[str] = None, + limit: int = 100, + offset: int = 0, + user: dict = Depends(require_user), +): + with get_db() as conn: + query = """ + SELECT id, term, translation, context_sentence, phonetic, audio_url, + part_of_speech, definition_en, tags, notes, collection, + srs_reps, srs_interval, srs_ease, srs_next_review, srs_last_review, srs_state, error_count, + created_at, updated_at + FROM vocabulary_cards + WHERE user_id = ? + """ + params = [user["id"]] + + if search: + s_clean = f"%{search.strip()}%" + query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)" + params.extend([s_clean, s_clean, s_clean, s_clean]) + + if tag: + t_clean = f"%{tag.strip()}%" + query += " AND tags LIKE ?" + params.append(t_clean) + + if collection: + query += " AND collection = ?" + params.append(collection.strip()) + + if srs_state: + query += " AND srs_state = ?" + params.append(srs_state.strip()) + + # Total count before limit + count_query = f"SELECT COUNT(*) FROM ({query})" + total = conn.execute(count_query, params).fetchone()[0] + + query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + rows = conn.execute(query, params).fetchall() + cards = [] + for r in rows: + c = dict(r) + if not c.get("audio_url"): + c["audio_url"] = f"/api/audio/{get_audio_hash(c['term'], 'en', 1.0)}.wav" + if c.get("context_sentence"): + c["context_audio_url"] = f"/api/audio/{get_audio_hash(c['context_sentence'], 'en', 1.0)}.wav" + cards.append(c) + + return {"total": total, "cards": cards} + + +@router.post("/api/cards") +async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)): + clean_term = card.term.strip() + with get_db() as conn: + existing = conn.execute( + "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))", + (user["id"], clean_term), + ).fetchone() + + if existing: + # Word already exists in library! + existing_row = dict(existing) + new_ctx = (card.context_sentence or "").strip() + old_ctx = (existing_row.get("context_sentence") or "").strip() + updated_ctx = old_ctx + if new_ctx and new_ctx not in old_ctx: + updated_ctx = f"{old_ctx}\n\n{new_ctx}" if old_ctx else new_ctx + + # Merge tags + old_tags = set([t.strip() for t in (existing_row.get("tags") or "").split(",") if t.strip()]) + new_tags = [t.strip() for t in (card.tags or "").split(",") if t.strip()] + for t in new_tags: + old_tags.add(t) + updated_tags = ", ".join(sorted(old_tags)) if old_tags else "general" + + conn.execute( + """ + UPDATE vocabulary_cards + SET context_sentence = ?, tags = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (updated_ctx, updated_tags, existing_row["id"]), + ) + updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone() + res = dict(updated_row) + if not res.get("audio_url"): + res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav" + if res.get("context_sentence"): + res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav" + res["already_exists"] = True + + # Pre-warm word and context audio in background + asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence)) + return res + + audio_url_val = (card.audio_url or "").strip() + if not audio_url_val: + audio_url_val = f"/api/audio/{get_audio_hash(clean_term, 'en', 1.0)}.wav" + + cursor = conn.execute( + """ + INSERT INTO vocabulary_cards ( + user_id, term, translation, context_sentence, phonetic, audio_url, + part_of_speech, definition_en, tags, notes, collection, + srs_reps, srs_interval, srs_ease, srs_state, error_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 2.5, 'new', 0) + """, + ( + user["id"], + clean_term, + card.translation.strip(), + (card.context_sentence or "").strip(), + (card.phonetic or "").strip(), + audio_url_val, + (card.part_of_speech or "").strip(), + (card.definition_en or "").strip(), + (card.tags or "general").strip(), + (card.notes or "").strip(), + (card.collection or "General").strip(), + ), + ) + new_id = cursor.lastrowid + row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone() + res = dict(row) + if not res.get("audio_url"): + res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav" + if res.get("context_sentence"): + res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav" + + # Pre-warm word and context audio in background + asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence)) + return res + + +@router.get("/api/cards/{card_id}") +async def api_get_card(card_id: int, user: dict = Depends(require_user)): + with get_db() as conn: + row = conn.execute( + "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", + (card_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Card not found") + return dict(row) + + +@router.put("/api/cards/{card_id}") +async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)): + with get_db() as conn: + row = conn.execute( + "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", + (card_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Card not found") + current = dict(row) + + def pick(new_val, current_val): + return new_val.strip() if new_val is not None else current_val + + conn.execute( + """ + UPDATE vocabulary_cards + SET term = ?, translation = ?, context_sentence = ?, phonetic = ?, + audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?, + notes = ?, collection = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? + """, + ( + pick(update.term, current["term"]), + pick(update.translation, current["translation"]), + pick(update.context_sentence, current.get("context_sentence", "")), + pick(update.phonetic, current.get("phonetic", "")), + pick(update.audio_url, current.get("audio_url", "")), + pick(update.part_of_speech, current.get("part_of_speech", "")), + pick(update.definition_en, current.get("definition_en", "")), + pick(update.tags, current.get("tags", "")), + pick(update.notes, current.get("notes", "")), + pick(update.collection, current.get("collection", "General")), + card_id, + user["id"], + ), + ) + updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone() + return dict(updated_row) + + +@router.delete("/api/cards/{card_id}") +async def api_delete_card(card_id: int, user: dict = Depends(require_user)): + with get_db() as conn: + cursor = conn.execute( + "DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?", + (card_id, user["id"]), + ) + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="Card not found") + return {"status": "ok", "deleted_id": card_id} + + +@router.post("/api/cards/batch") +async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require_user)): + if not req.card_ids: + raise HTTPException(status_code=400, detail="No card IDs provided") + + with get_db() as conn: + placeholders = ",".join("?" * len(req.card_ids)) + params_base = list(req.card_ids) + [user["id"]] + + if req.action == "delete": + cursor = conn.execute( + f"DELETE FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?", + params_base, + ) + return {"status": "ok", "action": "delete", "affected": cursor.rowcount} + + elif req.action == "set_collection": + collection_val = (req.value or "General").strip() + cursor = conn.execute( + f"UPDATE vocabulary_cards SET collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) AND user_id = ?", + [collection_val] + params_base, + ) + return {"status": "ok", "action": "set_collection", "affected": cursor.rowcount} + + elif req.action == "add_tags": + new_tags_raw = req.value or "" + new_tags = [t.strip() for t in new_tags_raw.split(",") if t.strip()] + if not new_tags: + return {"status": "ok", "action": "add_tags", "affected": 0} + + rows = conn.execute( + f"SELECT id, tags FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?", + params_base, + ).fetchall() + + affected = 0 + for r in rows: + existing = [t.strip() for t in (r["tags"] or "").split(",") if t.strip()] + combined = existing + [t for t in new_tags if t not in existing] + conn.execute( + "UPDATE vocabulary_cards SET tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (", ".join(combined), r["id"]), + ) + affected += 1 + return {"status": "ok", "action": "add_tags", "affected": affected} + + +@router.get("/api/tags") +async def api_get_tags(user: dict = Depends(require_user)): + with get_db() as conn: + rows = conn.execute( + "SELECT tags FROM vocabulary_cards WHERE user_id = ?", + (user["id"],), + ).fetchall() + tag_counts = {} + for r in rows: + if r["tags"]: + for t in r["tags"].split(","): + clean = t.strip() + if clean: + tag_counts[clean] = tag_counts.get(clean, 0) + 1 + sorted_tags = sorted( + [{"tag": k, "count": v} for k, v in tag_counts.items()], + key=lambda x: x["count"], + reverse=True, + ) + return sorted_tags diff --git a/app/routers/cefr.py b/app/routers/cefr.py new file mode 100644 index 0000000..10386b7 --- /dev/null +++ b/app/routers/cefr.py @@ -0,0 +1,96 @@ +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from app.auth import require_user +from app.database import get_db +from app.cefr import ( + get_cefr_level_summaries, + get_cefr_quiz_batch, + process_cefr_answer, + reset_user_cefr_level, + ingest_cefr_word, +) + +router = APIRouter(tags=["CEFR Testing & Mining"]) + + +class CefrAnswerRequest(BaseModel): + question_id: int + selected_option: str + + +class CefrResetRequest(BaseModel): + level: str + + +class CefrAddWordRequest(BaseModel): + level: str + term: str + context_sentence: Optional[str] = "" + + +@router.get("/api/cefr/levels") +async def api_cefr_levels(user: dict = Depends(require_user)): + """ + Returns progress summaries for all CEFR levels (A1, A2, B1, B2). + """ + with get_db() as conn: + summaries = get_cefr_level_summaries(conn, user["id"]) + return {"levels": summaries} + + +@router.get("/api/cefr/quiz") +async def api_cefr_quiz( + level: str = Query("A1"), + count: int = Query(10, ge=1, le=30), + mode: str = Query("all"), + user: dict = Depends(require_user), +): + """ + Returns next batch of Cloze Tap test questions for level (A1..B2). + """ + with get_db() as conn: + quiz_items = get_cefr_quiz_batch(conn, user["id"], level, count, mode) + return {"level": level.upper(), "count": len(quiz_items), "quiz": quiz_items} + + +@router.post("/api/cefr/answer") +async def api_cefr_answer( + req: CefrAnswerRequest, + user: dict = Depends(require_user), +): + """ + Submits an answer for a CEFR question. + If wrong, automatically ingests into user's SRS active vocabulary with status 'learning'. + """ + with get_db() as conn: + result = process_cefr_answer(conn, user["id"], req.question_id, req.selected_option) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + + +@router.post("/api/cefr/reset") +async def api_cefr_reset( + req: CefrResetRequest, + user: dict = Depends(require_user), +): + """ + Resets user testing progress for a given CEFR level. + """ + with get_db() as conn: + deleted = reset_user_cefr_level(conn, user["id"], req.level) + return {"status": "ok", "level": req.level.upper(), "reset_count": deleted} + + +@router.post("/api/cefr/add-word") +async def api_cefr_add_word( + req: CefrAddWordRequest, + user: dict = Depends(require_user), +): + """ + Adds a word from a CEFR test options list directly into user's SRS active vocabulary. + """ + with get_db() as conn: + return ingest_cefr_word(conn, user["id"], req.level, req.term, req.context_sentence) diff --git a/app/routers/dictionary.py b/app/routers/dictionary.py new file mode 100644 index 0000000..baeee63 --- /dev/null +++ b/app/routers/dictionary.py @@ -0,0 +1,165 @@ +import asyncio +import re +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from app.auth import require_user +from app.database import get_db +from app.dictionary import lookup_term +from app.phrasal import detect_phrasal_verb +from app.ai import translate_sentence_ai, get_ollama_status +from app.tts import ( + synthesize_speech, + prewarm_card_audio, + AUDIO_CACHE_DIR, + get_available_voices, +) + +router = APIRouter(tags=["Dictionary & TTS"]) + + +class SentenceTranslateRequest(BaseModel): + sentence: str = Field(..., min_length=1) + target_word: Optional[str] = "" + context: Optional[str] = "" + + +@router.get("/api/lookup") +async def api_lookup( + term: Optional[str] = None, + q: Optional[str] = None, + context: Optional[str] = None, + user: dict = Depends(require_user), +): + query_str = (term or q or "").strip() + if not query_str: + raise HTTPException(status_code=400, detail="Query term cannot be empty") + data = await lookup_term(query_str) + + # Check if this term already exists in user's dictionary + with get_db() as conn: + existing = conn.execute( + "SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))", + (user["id"], query_str), + ).fetchone() + if existing: + data["in_library"] = True + data["library_card"] = dict(existing) + else: + data["in_library"] = False + data["library_card"] = None + + # Multi-Word & Phrasal Verb Detection + data["phrasal_verb"] = None + if context: + pv = detect_phrasal_verb( + sentence=context, + target_word=query_str, + user_id=user["id"], + conn=conn, + ) + if pv and pv["term"].lower() != query_str.lower(): + data["phrasal_verb"] = pv + + return data + + +@router.get("/api/detect-phrasal-verb") +async def api_detect_phrasal_verb( + sentence: str = Query(..., min_length=1), + word: str = Query(..., min_length=1), + user: dict = Depends(require_user), +): + """ + Analyzes sentence context around word to detect phrasal verbs, idioms, and collocations. + """ + with get_db() as conn: + pv = detect_phrasal_verb(sentence=sentence, target_word=word, user_id=user["id"], conn=conn) + return {"detected": bool(pv), "phrasal_verb": pv} + + +@router.api_route("/api/audio", methods=["GET", "HEAD"]) +async def api_audio_proxy(): + # Audio feature disabled + return Response(status_code=204) + + +@router.post("/api/translate-sentence") +async def api_translate_sentence( + req: SentenceTranslateRequest, + user: dict = Depends(require_user), +): + """ + Translates a sentence using local AI with in-context meaning of the target word. + Uses SQLite sentence_translations cache for sub-millisecond retrieval on repeat queries. + """ + clean_s = req.sentence.strip() + if not clean_s: + raise HTTPException(status_code=400, detail="Sentence cannot be empty") + + # Pre-warm neural audio for sentence and target word in background + asyncio.create_task(prewarm_card_audio(req.target_word or "", clean_s)) + + import sys + main_mod = sys.modules.get("app.main") + translator = getattr(main_mod, "translate_sentence_ai", translate_sentence_ai) if main_mod else translate_sentence_ai + return await translator(clean_s, req.target_word or "", req.context or "") + + +@router.get("/api/ai/status") +async def api_ai_status(user: dict = Depends(require_user)): + """ + Returns local AI status and available models. + """ + return await get_ollama_status() + + +@router.get("/api/audio/{hash_val}.wav") +async def api_get_cached_audio(hash_val: str): + """ + Streams pre-warmed / cached audio file directly with immutable cache headers. + """ + clean_hash = hash_val.lower().strip() + if not re.match(r"^[a-f0-9]{32}$", clean_hash): + raise HTTPException(status_code=400, detail="Invalid audio hash") + audio_path = AUDIO_CACHE_DIR / f"{clean_hash}.wav" + if not audio_path.is_file(): + raise HTTPException(status_code=404, detail="Audio file not found") + return FileResponse( + path=str(audio_path), + media_type="audio/wav", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + + +@router.get("/api/tts") +async def api_tts( + text: str = Query(..., min_length=1, max_length=1000), + lang: str = Query("en"), + speed: float = Query(1.0, ge=0.5, le=2.0), + voice: Optional[str] = Query(None), + user: dict = Depends(require_user), +): + """ + Synthesizes speech on-demand using local Piper TTS. + Instantly returns cached audio if previously synthesized for this voice, speed, and text. + """ + clean_text = text.strip() + if not clean_text: + raise HTTPException(status_code=400, detail="Text cannot be empty") + audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed, voice=voice) + if not audio_path or not audio_path.is_file(): + raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable") + return FileResponse( + path=str(audio_path), + media_type="audio/wav", + headers={"Cache-Control": "public, max-age=604800, immutable"}, + ) + + +@router.get("/api/tts/status") +async def api_tts_status(user: dict = Depends(require_user)): + """Returns status of local Piper TTS and all available voice models.""" + return get_available_voices() diff --git a/app/routers/export.py b/app/routers/export.py new file mode 100644 index 0000000..ce28ea2 --- /dev/null +++ b/app/routers/export.py @@ -0,0 +1,79 @@ +from typing import Optional, List, Dict, Any +from fastapi import APIRouter, Depends, Response + +from app.auth import require_user +from app.database import get_db +from app.export import export_csv, export_json, export_anki_tsv, export_markdown + +router = APIRouter(tags=["Export"]) + + +def _fetch_all_cards(user_id: int, collection: Optional[str] = None) -> List[Dict[str, Any]]: + with get_db() as conn: + if collection: + rows = conn.execute( + """ + SELECT id, term, translation, context_sentence, phonetic, audio_url, + part_of_speech, definition_en, tags, notes, collection, created_at, updated_at + FROM vocabulary_cards + WHERE user_id = ? AND collection = ? + ORDER BY collection ASC, created_at DESC + """, + (user_id, collection), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, term, translation, context_sentence, phonetic, audio_url, + part_of_speech, definition_en, tags, notes, collection, created_at, updated_at + FROM vocabulary_cards + WHERE user_id = ? + ORDER BY collection ASC, created_at DESC + """, + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + +@router.get("/api/export/csv") +async def api_export_csv(collection: Optional[str] = None, user: dict = Depends(require_user)): + cards = _fetch_all_cards(user["id"], collection) + data = export_csv(cards) + return Response( + content=data, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="vocabulary.csv"'}, + ) + + +@router.get("/api/export/json") +async def api_export_json(collection: Optional[str] = None, user: dict = Depends(require_user)): + cards = _fetch_all_cards(user["id"], collection) + data = export_json(cards) + return Response( + content=data, + media_type="application/json; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="vocabulary.json"'}, + ) + + +@router.get("/api/export/anki") +async def api_export_anki(collection: Optional[str] = None, user: dict = Depends(require_user)): + cards = _fetch_all_cards(user["id"], collection) + data = export_anki_tsv(cards) + return Response( + content=data, + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="vocabulary_anki.txt"'}, + ) + + +@router.get("/api/export/markdown") +async def api_export_markdown(collection: Optional[str] = None, user: dict = Depends(require_user)): + cards = _fetch_all_cards(user["id"], collection) + data = export_markdown(cards) + return Response( + content=data, + media_type="text/markdown; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="vocabulary.md"'}, + ) diff --git a/app/routers/grammar.py b/app/routers/grammar.py new file mode 100644 index 0000000..db71d56 --- /dev/null +++ b/app/routers/grammar.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from app.auth import require_user +from app.database import get_db +from app.grammar import ( + get_grammar_units_list, + get_grammar_unit_detail, + process_grammar_answer, + record_grammar_completion, + update_handwritten_status, +) + +router = APIRouter(tags=["Murphy Grammar"]) + + +class GrammarAnswerRequest(BaseModel): + unit_id: int + exercise_idx: int + selected_option: str + + +class GrammarCompleteRequest(BaseModel): + unit_id: int + score: int = Field(..., ge=0, le=100) + handwritten_logged: bool = False + + +class GrammarHandwrittenRequest(BaseModel): + unit_id: int + handwritten: bool + + +@router.get("/api/grammar/units") +async def api_grammar_units(user: dict = Depends(require_user)): + """ + Returns the list of all curated Murphy grammar units with user progress. + """ + with get_db() as conn: + units = get_grammar_units_list(conn, user["id"]) + return {"units": units} + + +@router.get("/api/grammar/units/{unit_id}") +async def api_grammar_unit_detail(unit_id: int, user: dict = Depends(require_user)): + """ + Returns full content of a unit for lesson mode. + """ + with get_db() as conn: + unit = get_grammar_unit_detail(conn, user["id"], unit_id) + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + return unit + + +@router.post("/api/grammar/answer") +async def api_grammar_answer( + req: GrammarAnswerRequest, + user: dict = Depends(require_user), +): + """ + Checks answer for a Cloze Tap grammar exercise. + If wrong, performs automated gap ingestion into active SRS vocabulary. + """ + with get_db() as conn: + result = process_grammar_answer( + conn, user["id"], req.unit_id, req.exercise_idx, req.selected_option + ) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + + +@router.post("/api/grammar/complete") +async def api_grammar_complete( + req: GrammarCompleteRequest, + user: dict = Depends(require_user), +): + """ + Records completion of a grammar unit and user's score. + """ + with get_db() as conn: + return record_grammar_completion( + conn, user["id"], req.unit_id, req.score, req.handwritten_logged + ) + + +@router.post("/api/grammar/handwritten") +async def api_grammar_handwritten( + req: GrammarHandwrittenRequest, + user: dict = Depends(require_user), +): + """ + Toggles handwritten paper notebook checkbox for a unit. + """ + with get_db() as conn: + return update_handwritten_status(conn, user["id"], req.unit_id, req.handwritten) diff --git a/app/routers/lessons.py b/app/routers/lessons.py new file mode 100644 index 0000000..8feef46 --- /dev/null +++ b/app/routers/lessons.py @@ -0,0 +1,193 @@ +from typing import Optional, List, Dict, Any +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.auth import require_user +from app.database import get_db +from app.lessons import ( + get_user_presets, + save_user_preset, + delete_user_preset, + get_grammar_recommendations, + get_curated_articles, + record_lesson_session, + get_lesson_history, +) +from app.ai import generate_lesson_ai_debrief + +router = APIRouter(tags=["Lessons"]) + + +class SavePresetRequest(BaseModel): + name: str + steps: List[Dict[str, Any]] + icon: Optional[str] = "🚀" + description: Optional[str] = "" + estimated_min: Optional[int] = 15 + + +class LessonDebriefRequest(BaseModel): + preset_name: str + duration_seconds: int + total_score: int + steps: List[Dict[str, Any]] + errors: List[Dict[str, Any]] + + +@router.get("/api/lessons/presets") +async def api_lessons_get_presets(user: dict = Depends(require_user)): + """ + Returns available lesson presets (defaults + user-custom). + """ + with get_db() as conn: + presets = get_user_presets(conn, user["id"]) + return {"presets": presets} + + +@router.post("/api/lessons/presets") +async def api_lessons_save_preset( + req: SavePresetRequest, + user: dict = Depends(require_user), +): + """ + Saves a custom user lesson configuration preset. + """ + if not req.name.strip(): + raise HTTPException(status_code=400, detail="Название шаблона не может быть пустым") + with get_db() as conn: + saved = save_user_preset( + conn, + user_id=user["id"], + name=req.name, + steps=req.steps, + icon=req.icon or "🚀", + description=req.description or "", + estimated_min=req.estimated_min or 15, + ) + return {"status": "ok", "preset": saved} + + +@router.delete("/api/lessons/presets/{preset_id}") +async def api_lessons_delete_preset( + preset_id: int, + user: dict = Depends(require_user), +): + """ + Deletes a custom user lesson preset. + """ + with get_db() as conn: + deleted = delete_user_preset(conn, user["id"], preset_id) + if not deleted: + raise HTTPException(status_code=404, detail="Пользовательский шаблон не найден") + return {"status": "ok", "deleted": True} + + +@router.get("/api/lessons/grammar-recommendations") +async def api_lessons_grammar_recommendations(user: dict = Depends(require_user)): + """ + Calculates intelligent grammar suggestions: weakest topic, last studied topic, + next in syllabus, and full units list. + """ + with get_db() as conn: + recs = get_grammar_recommendations(conn, user["id"]) + return recs + + +@router.get("/api/lessons/articles") +async def api_lessons_articles( + topic: Optional[str] = None, + user: dict = Depends(require_user), +): + """ + Returns curated high-yield reading articles/facts for the lesson reading block. + """ + articles = get_curated_articles(topic=topic) + return {"articles": articles} + + +@router.post("/api/lessons/debrief") +async def api_lessons_debrief( + req: LessonDebriefRequest, + user: dict = Depends(require_user), +): + """ + Generates a pedagogic AI debrief for the completed lesson session, + enriches with historical error context, and records the session. + """ + # 1. Fetch user error history for pedagogical pattern detection + with get_db() as conn: + chronic_rows = conn.execute( + """ + SELECT v.term, count(*) as err_cnt + FROM srs_logs l + JOIN vocabulary_cards v ON v.id = l.card_id + WHERE l.user_id = ? AND l.rating = 1 + GROUP BY v.term + HAVING err_cnt > 1 + ORDER BY err_cnt DESC + LIMIT 10 + """, + (user["id"],), + ).fetchall() + chronic_vocab = [r["term"] for r in chronic_rows] + + prog_rows = conn.execute( + """ + SELECT u.unit_num + FROM grammar_progress p + JOIN grammar_units u ON u.id = p.unit_id + WHERE p.user_id = ? AND p.score < 80 + """, + (user["id"],), + ).fetchall() + past_grammar = [r["unit_num"] for r in prog_rows] + + history_data = { + "chronic_vocab_errors": chronic_vocab, + "past_grammar_errors": past_grammar, + } + + session_payload = { + "preset_name": req.preset_name, + "duration_seconds": req.duration_seconds, + "total_score": req.total_score, + "steps": req.steps, + "errors": req.errors, + } + + # 2. Generate AI or deterministic debrief + ai_report = await generate_lesson_ai_debrief(session_payload, history_data) + + # 3. Save completed session to SQLite + with get_db() as conn: + session_id = record_lesson_session( + conn=conn, + user_id=user["id"], + preset_name=req.preset_name, + duration_seconds=req.duration_seconds, + total_score=req.total_score, + grade=ai_report.get("grade", "A"), + steps_summary=req.steps, + errors_list=req.errors, + ai_report=ai_report, + ) + + return { + "session_id": session_id, + "ai_report": ai_report, + "total_score": req.total_score, + "grade": ai_report.get("grade", "A"), + } + + +@router.get("/api/lessons/history") +async def api_lessons_history( + limit: int = 15, + user: dict = Depends(require_user), +): + """ + Returns history of completed comprehensive lessons and overall learning metrics. + """ + with get_db() as conn: + data = get_lesson_history(conn, user["id"], limit=limit) + return data diff --git a/app/routers/listening.py b/app/routers/listening.py new file mode 100644 index 0000000..c231b42 --- /dev/null +++ b/app/routers/listening.py @@ -0,0 +1,64 @@ +from typing import Optional +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from app.auth import require_user +from app.database import get_db +from app.listening import get_listening_exercise, evaluate_listening_answer + +router = APIRouter(tags=["Listening"]) + + +class ListeningCheckRequest(BaseModel): + scenario_id: str + user_answer: str + story: Optional[str] = "" + ideal_answer: Optional[str] = "" + + +@router.get("/api/listening/exercise") +async def api_listening_exercise( + topic: Optional[str] = Query("all"), + level: Optional[str] = Query("B1"), + voice: Optional[str] = Query(None), + user: dict = Depends(require_user), +): + """ + Returns a listening comprehension exercise (topic / level / vocabulary context) + with pre-synthesized audio URLs for the story and question. + """ + user_cards = [] + if topic == "cards": + with get_db() as conn: + rows = conn.execute( + """ + SELECT id, term, translation, context_sentence + FROM vocabulary_cards + WHERE user_id = ? AND length(context_sentence) > 20 + ORDER BY RANDOM() LIMIT 20 + """, + (user["id"],), + ).fetchall() + user_cards = [dict(r) for r in rows] + + ex = await get_listening_exercise(topic=topic, level=level, user_cards=user_cards, voice=voice) + # Strip keywords and ideal_answer from client response to prevent inspection + safe_ex = {k: v for k, v in ex.items() if k not in ("keywords", "ideal_answer")} + return safe_ex + + +@router.post("/api/listening/check") +async def api_listening_check( + req: ListeningCheckRequest, + user: dict = Depends(require_user), +): + """ + Evaluates user's answer to the listening question. + Returns score, pedagogic feedback, full story transcription, and ideal answer. + """ + return evaluate_listening_answer( + scenario_id=req.scenario_id, + user_answer=req.user_answer, + story=req.story or "", + ideal_answer=req.ideal_answer or "", + ) diff --git a/app/routers/reader.py b/app/routers/reader.py new file mode 100644 index 0000000..e332bad --- /dev/null +++ b/app/routers/reader.py @@ -0,0 +1,487 @@ +import json +from pathlib import Path +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Request, Query +from pydantic import BaseModel + +from app.auth import require_user +from app.database import get_db +from app.reader import ( + parse_epub, + parse_text, + parse_pdf, + count_words, + extract_rfc_number, + fetch_and_parse_rfc, + fetch_and_parse_wikipedia, + extract_wikipedia_title_and_lang, +) + +router = APIRouter(tags=["Reader & Bookshelf"]) + + +class BookCreateRequest(BaseModel): + title: Optional[str] = "Untitled" + author: Optional[str] = "" + content: Optional[str] = "" + source_type: Optional[str] = "text" + + +class BookProgressRequest(BaseModel): + current_chapter: Optional[int] = None + current_position: Optional[float] = None + chapter: Optional[int] = None + position: Optional[float] = None + + +class RfcFetchRequest(BaseModel): + rfc: str + force: Optional[bool] = False + + +class WikipediaFetchRequest(BaseModel): + query: Optional[str] = "" + lang: Optional[str] = "simple" + time_budget: Optional[str] = "10-15" + topic: Optional[str] = "random" + force: Optional[bool] = False + + +@router.get("/api/books") +async def api_get_books(user: dict = Depends(require_user)): + """ + Lists books for current user (id, title, author, source_type, chapters_count, + total_words, current_chapter, current_position, created_at, updated_at). + Excludes full chapters_json for efficiency. + """ + with get_db() as conn: + rows = conn.execute( + """ + SELECT id, user_id, title, author, source_type, + json_array_length(chapters_json) AS chapters_count, + total_words, current_chapter, current_position, cover_url, + created_at, updated_at + FROM books + WHERE user_id = ? + ORDER BY updated_at DESC, id DESC + """, + (user["id"],), + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/api/books") +async def api_create_book( + request: Request, + user: dict = Depends(require_user), +): + """ + Creates or uploads a book. + Accepts: + - JSON: {"title": "...", "author": "...", "content": "...", "source_type": "text"} + - Multipart file upload: file (UploadFile for .epub, .txt, .md) and optional title, author + """ + content_type = request.headers.get("content-type", "") + + title = "Untitled" + author = "" + source_type = "text" + chapters = [] + + if "multipart/form-data" in content_type: + form = await request.form() + file_obj = form.get("file") + if not file_obj or not hasattr(file_obj, "filename") or not file_obj.filename: + raise HTTPException(status_code=400, detail="No file uploaded") + + filename = file_obj.filename + file_bytes = await file_obj.read() + if not file_bytes: + raise HTTPException(status_code=400, detail="Uploaded file is empty") + + form_title = (form.get("title") or "").strip() + form_author = (form.get("author") or "").strip() + stem = Path(filename).stem + + lower_name = filename.lower() + try: + if lower_name.endswith(".epub"): + source_type = "epub" + parsed_title, parsed_author, chapters = parse_epub(file_bytes) + title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem + author = form_author or parsed_author + elif lower_name.endswith(".pdf"): + source_type = "pdf" + parsed_title, parsed_author, chapters = parse_pdf(file_bytes, filename=filename) + title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem + author = form_author or parsed_author + elif lower_name.endswith(".md") or lower_name.endswith(".txt"): + source_type = "md" if lower_name.endswith(".md") else "txt" + text_str = file_bytes.decode("utf-8", errors="replace") + parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author) + title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem + author = form_author or parsed_author + else: + source_type = "text" + text_str = file_bytes.decode("utf-8", errors="replace") + parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author) + title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem + author = form_author or parsed_author + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + + else: + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON or missing body") + + raw_content = body.get("content") or "" + title_in = (body.get("title") or "").strip() + author_in = (body.get("author") or "").strip() + source_type = body.get("source_type") or "text" + + if not raw_content and not title_in: + raise HTTPException(status_code=400, detail="Book content is required") + + parsed_title, parsed_author, chapters = parse_text( + raw_content, title=title_in or "Untitled", author=author_in + ) + title = title_in or parsed_title + author = author_in or parsed_author + + total_words = sum(count_words(ch.get("content", "")) for ch in chapters) + chapters_json = json.dumps(chapters, ensure_ascii=False) + + with get_db() as conn: + cursor = conn.execute( + """ + INSERT INTO books ( + user_id, title, author, source_type, chapters_json, + current_chapter, current_position, total_words + ) VALUES (?, ?, ?, ?, ?, 0, 0.0, ?) + """, + (user["id"], title, author, source_type, chapters_json, total_words), + ) + new_id = cursor.lastrowid + row = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books WHERE id = ? + """, + (new_id,), + ).fetchone() + + res = dict(row) + res["chapters"] = chapters + return res + + +@router.post("/api/books/fetch-rfc") +async def api_fetch_rfc( + req: RfcFetchRequest, + user: dict = Depends(require_user), +): + """ + Fetches an RFC specification by number or URL from RFC Editor & Datatracker, + parses sections into readable chapters, and saves to the user's bookshelf. + If the RFC already exists on user's shelf and force=False, returns the existing book. + """ + raw_query = (req.rfc or "").strip() + if not raw_query: + raise HTTPException(status_code=400, detail="Укажите номер или URL спецификации RFC") + + try: + rfc_num = extract_rfc_number(raw_query) + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + + # Check if user already loaded this RFC + if not req.force: + with get_db() as conn: + existing = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books + WHERE user_id = ? AND source_type = 'rfc' AND (title LIKE ? OR title LIKE ?) + ORDER BY id DESC LIMIT 1 + """, + (user["id"], f"RFC {rfc_num}:%", f"RFC {rfc_num}"), + ).fetchone() + if existing: + res = dict(existing) + try: + res["chapters"] = json.loads(res["chapters_json"]) + except Exception: + res["chapters"] = [] + res["already_existed"] = True + return res + + try: + import sys + main_mod = sys.modules.get("app.main") + rfc_parser = getattr(main_mod, "fetch_and_parse_rfc", fetch_and_parse_rfc) if main_mod else fetch_and_parse_rfc + title, author, chapters, rfc_num, total_words = rfc_parser(raw_query) + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка загрузки RFC: {e}") + + chapters_json = json.dumps(chapters, ensure_ascii=False) + + with get_db() as conn: + cursor = conn.execute( + """ + INSERT INTO books ( + user_id, title, author, source_type, chapters_json, + current_chapter, current_position, total_words + ) VALUES (?, ?, ?, 'rfc', ?, 0, 0.0, ?) + """, + (user["id"], title, author, chapters_json, total_words), + ) + new_id = cursor.lastrowid + row = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books WHERE id = ? + """, + (new_id,), + ).fetchone() + + res = dict(row) + res["chapters"] = chapters + res["already_existed"] = False + return res + + +@router.post("/api/books/fetch-wikipedia") +async def api_fetch_wikipedia( + req: WikipediaFetchRequest, + user: dict = Depends(require_user), +): + """ + Fetches a Wikipedia article (by title/URL or random topic & duration), + parses sections into readable chapters with media and tables, + and stores autonomously in the user's SQLite bookshelf (source_type = 'wikipedia'). + """ + raw_query = (req.query or "").strip() + target_lang = (req.lang or "simple").strip() + time_budget = (req.time_budget or "10-15").strip() + topic = (req.topic or "random").strip() + + # If specific title or URL provided and force is False, check for existing book + if raw_query and not req.force: + title_cand, lang_cand = extract_wikipedia_title_and_lang(raw_query, default_lang=target_lang) + search_title = title_cand.replace("_", " ") + with get_db() as conn: + existing = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books + WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?) + ORDER BY id DESC LIMIT 1 + """, + (user["id"], search_title), + ).fetchone() + if existing: + res = dict(existing) + try: + res["chapters"] = json.loads(res["chapters_json"]) + except Exception: + res["chapters"] = [] + res["already_existed"] = True + return res + + try: + title, author, chapters, cover_url, total_words, wiki_url = fetch_and_parse_wikipedia( + title_or_url=raw_query, + lang=target_lang, + time_budget=time_budget, + topic=topic, + ) + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка загрузки статьи Wikipedia: {e}") + + # Check again by resolved title if not forcing + if not req.force: + with get_db() as conn: + existing = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books + WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?) + ORDER BY id DESC LIMIT 1 + """, + (user["id"], title), + ).fetchone() + if existing: + res = dict(existing) + try: + res["chapters"] = json.loads(res["chapters_json"]) + except Exception: + res["chapters"] = [] + res["already_existed"] = True + return res + + chapters_json = json.dumps(chapters, ensure_ascii=False) + + with get_db() as conn: + cursor = conn.execute( + """ + INSERT INTO books ( + user_id, title, author, source_type, chapters_json, + current_chapter, current_position, total_words, cover_url + ) VALUES (?, ?, ?, 'wikipedia', ?, 0, 0.0, ?, ?) + """, + (user["id"], title, author, chapters_json, total_words, cover_url), + ) + new_id = cursor.lastrowid + row = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books WHERE id = ? + """, + (new_id,), + ).fetchone() + + res = dict(row) + res["chapters"] = chapters + res["already_existed"] = False + return res + + +@router.get("/api/books/{book_id}") +async def api_get_book(book_id: int, user: dict = Depends(require_user)): + """ + Returns full book details including chapters_json. + """ + with get_db() as conn: + row = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books + WHERE id = ? AND user_id = ? + """, + (book_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Book not found") + data = dict(row) + try: + data["chapters"] = json.loads(data["chapters_json"]) + except Exception: + data["chapters"] = [] + return data + + +@router.put("/api/books/{book_id}/progress") +@router.patch("/api/books/{book_id}/progress") +async def api_update_book_progress( + book_id: int, + req: Optional[BookProgressRequest] = None, + current_chapter: Optional[int] = Query(None), + current_position: Optional[float] = Query(None), + request: Request = None, + user: dict = Depends(require_user), +): + """ + Updates reading progress (current_chapter, current_position) for a book. + """ + ch = None + pos = None + if req is not None: + ch = req.current_chapter if req.current_chapter is not None else req.chapter + pos = req.current_position if req.current_position is not None else req.position + if ch is None: + ch = current_chapter + if pos is None: + pos = current_position + if ch is None or pos is None: + if request: + try: + body = await request.json() + if ch is None: + ch = body.get("current_chapter", body.get("chapter")) + if pos is None: + pos = body.get("current_position", body.get("position")) + except Exception: + pass + + if ch is None and pos is None: + raise HTTPException(status_code=400, detail="Missing current_chapter or current_position") + + with get_db() as conn: + row = conn.execute( + "SELECT id, current_chapter, current_position FROM books WHERE id = ? AND user_id = ?", + (book_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Book not found") + + new_ch = int(ch) if ch is not None else row["current_chapter"] + new_pos = float(pos) if pos is not None else row["current_position"] + + conn.execute( + """ + UPDATE books + SET current_chapter = ?, current_position = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? + """, + (new_ch, new_pos, book_id, user["id"]), + ) + + updated_row = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books + WHERE id = ? + """, + (book_id,), + ).fetchone() + res = dict(updated_row) + try: + res["chapters"] = json.loads(res["chapters_json"]) + except Exception: + res["chapters"] = [] + return res + + +@router.delete("/api/books/{book_id}") +async def api_delete_book(book_id: int, user: dict = Depends(require_user)): + """ + Deletes a book for the current user. + """ + with get_db() as conn: + cursor = conn.execute( + "DELETE FROM books WHERE id = ? AND user_id = ?", + (book_id, user["id"]), + ) + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="Book not found") + return {"status": "ok", "deleted_id": book_id} diff --git a/app/routers/srs.py b/app/routers/srs.py new file mode 100644 index 0000000..a628077 --- /dev/null +++ b/app/routers/srs.py @@ -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} diff --git a/app/routers/tasks.py b/app/routers/tasks.py new file mode 100644 index 0000000..98fc0a0 --- /dev/null +++ b/app/routers/tasks.py @@ -0,0 +1,117 @@ +import asyncio +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app.auth import require_user +from app.database import get_db +from app.tasks import ( + create_task, + get_task_status, + get_active_task, + cancel_task, +) +from app.miner import run_mining_task_background + +router = APIRouter(tags=["Background Tasks"]) + + +class MineBooksRequest(BaseModel): + book_id: Optional[int] = None + level: str = "A1" + count: int = Field(15, ge=1, le=50) + + +@router.post("/api/tasks/mine-books") +async def api_tasks_mine_books( + req: MineBooksRequest, + user: dict = Depends(require_user), +): + """ + Starts an asynchronous background task to mine CEFR Cloze Tap questions + from user's uploaded books (EPUB, TXT, PDF). + """ + level = req.level.upper().strip() + if level not in ["A1", "A2", "B1", "B2", "ALL"]: + raise HTTPException(status_code=400, detail="Invalid CEFR level") + + count = max(1, min(req.count, 50)) + + with get_db() as conn: + active = get_active_task(conn, user["id"], task_type="mine_books") + if active: + return { + "status": "already_running", + "message": "Сборка уже выполняется в фоне", + "task": active, + } + + task_id = create_task( + conn, + user_id=user["id"], + task_type="mine_books", + params={ + "book_id": req.book_id, + "level": level, + "count": count, + }, + total=count, + ) + + # Launch background asyncio task + asyncio.create_task( + run_mining_task_background( + task_id=task_id, + user_id=user["id"], + book_id=req.book_id, + target_level=level, + target_count=count, + ) + ) + + return { + "status": "ok", + "task_id": task_id, + "message": f"Запущена фоновая сборка тестов ({level})", + } + + +@router.get("/api/tasks/active") +async def api_tasks_active( + task_type: Optional[str] = Query(None), + user: dict = Depends(require_user), +): + """ + Returns current user's active background task (if any). + """ + with get_db() as conn: + task = get_active_task(conn, user["id"], task_type=task_type) + return {"task": task} + + +@router.get("/api/tasks/{task_id}") +async def api_tasks_status( + task_id: int, + user: dict = Depends(require_user), +): + """ + Returns status and progress of a background task. + """ + with get_db() as conn: + task = get_task_status(conn, task_id, user_id=user["id"]) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return {"task": task} + + +@router.post("/api/tasks/{task_id}/cancel") +async def api_tasks_cancel( + task_id: int, + user: dict = Depends(require_user), +): + """ + Cancels a running background task. + """ + with get_db() as conn: + cancelled = cancel_task(conn, task_id, user_id=user["id"]) + return {"status": "ok" if cancelled else "not_found", "cancelled": cancelled}