refactor(stage-4): partition monolithic main.py into modular APIRouters
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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"'},
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 "",
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user