refactor(stage-4): partition monolithic main.py into modular APIRouters

This commit is contained in:
Antigravity committed 2026-10-07 14:50:21 +03:00
1 parent 4e1f809018
commit dde87e5d6c
15 files changed
+2201 -2137

No files matched your search

+8
View File
@@ -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,6 +126,10 @@ def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str:
continue
seen_terms.add(cand)
trans = matched_dict[cand]
guidance = special_guidance.get(cand, "")
if guidance:
hints.append(f"- {cand}: {trans} (ВНИМАНИЕ: {guidance})")
else:
hints.append(f"- {cand}: {trans}")
if len(hints) >= 8:
+22
View File
@@ -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
+27 -2137
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -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",
]
+94
View File
@@ -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"}
+349
View File
@@ -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
+96
View File
@@ -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)
+165
View File
@@ -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()
+79
View File
@@ -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"'},
)
+97
View File
@@ -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)
+193
View File
@@ -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
+64
View File
@@ -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 "",
)
+487
View File
@@ -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}
+363
View File
@@ -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}
+117
View File
@@ -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}