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
+2202 -2138

No files matched your search

+9 -1
View File
@@ -102,6 +102,10 @@ def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str:
return "" return ""
hints = [] hints = []
special_guidance = {
"owl": "owl — это сова/неясыть (ни в коем случае НЕ орёл!)",
"tawny owl": "tawny owl — это сова/неясыть (птица семейства совиных, ни в коем случае НЕ орёл!)",
}
try: try:
with get_db() as conn: with get_db() as conn:
@@ -122,7 +126,11 @@ def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str:
continue continue
seen_terms.add(cand) seen_terms.add(cand)
trans = matched_dict[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: if len(hints) >= 8:
break break
+22
View File
@@ -4,6 +4,7 @@ import secrets
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
from fastapi import Request, HTTPException, status
from app import config from app import config
from app.database import get_db from app.database import get_db
@@ -136,3 +137,24 @@ def ensure_admin_user():
if count == 0 and config.ADMIN_PASSWORD: if count == 0 and config.ADMIN_PASSWORD:
create_user(config.ADMIN_USERNAME, 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}