refactor(stage-4): partition monolithic main.py into modular APIRouters
This commit is contained in:
1 parent
4e1f809018
commit
dde87e5d6c
15 files changed
+2202
-2138
No files matched your search
@@ -102,6 +102,10 @@ def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str:
|
||||
return ""
|
||||
|
||||
hints = []
|
||||
special_guidance = {
|
||||
"owl": "owl — это сова/неясыть (ни в коем случае НЕ орёл!)",
|
||||
"tawny owl": "tawny owl — это сова/неясыть (птица семейства совиных, ни в коем случае НЕ орёл!)",
|
||||
}
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
@@ -122,7 +126,11 @@ def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str:
|
||||
continue
|
||||
seen_terms.add(cand)
|
||||
trans = matched_dict[cand]
|
||||
hints.append(f"- {cand}: {trans}")
|
||||
guidance = special_guidance.get(cand, "")
|
||||
if guidance:
|
||||
hints.append(f"- {cand}: {trans} (ВНИМАНИЕ: {guidance})")
|
||||
else:
|
||||
hints.append(f"- {cand}: {trans}")
|
||||
|
||||
if len(hints) >= 8:
|
||||
break
|
||||
|
||||
+22
@@ -4,6 +4,7 @@ import secrets
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import Request, HTTPException, status
|
||||
from app import config
|
||||
from app.database import get_db
|
||||
|
||||
@@ -136,3 +137,24 @@ def ensure_admin_user():
|
||||
if count == 0 and config.ADMIN_PASSWORD:
|
||||
create_user(config.ADMIN_USERNAME, config.ADMIN_PASSWORD)
|
||||
|
||||
|
||||
def get_optional_user(request: Request) -> Optional[dict]:
|
||||
token = request.cookies.get(config.SESSION_COOKIE_NAME)
|
||||
if not token:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:].strip()
|
||||
if token:
|
||||
return get_user_from_session(token)
|
||||
return None
|
||||
|
||||
|
||||
def require_user(request: Request) -> dict:
|
||||
user = get_optional_user(request)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
)
|
||||
return user
|
||||
|
||||
+27
-2137
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