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

This commit is contained in:
2026-10-07 14:50:21 +03:00
parent 4e1f809018
commit dde87e5d6c
15 changed files with 2202 additions and 2138 deletions
+165
View File
@@ -0,0 +1,165 @@
import asyncio
import re
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
from app.dictionary import lookup_term
from app.phrasal import detect_phrasal_verb
from app.ai import translate_sentence_ai, get_ollama_status
from app.tts import (
synthesize_speech,
prewarm_card_audio,
AUDIO_CACHE_DIR,
get_available_voices,
)
router = APIRouter(tags=["Dictionary & TTS"])
class SentenceTranslateRequest(BaseModel):
sentence: str = Field(..., min_length=1)
target_word: Optional[str] = ""
context: Optional[str] = ""
@router.get("/api/lookup")
async def api_lookup(
term: Optional[str] = None,
q: Optional[str] = None,
context: Optional[str] = None,
user: dict = Depends(require_user),
):
query_str = (term or q or "").strip()
if not query_str:
raise HTTPException(status_code=400, detail="Query term cannot be empty")
data = await lookup_term(query_str)
# Check if this term already exists in user's dictionary
with get_db() as conn:
existing = conn.execute(
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
(user["id"], query_str),
).fetchone()
if existing:
data["in_library"] = True
data["library_card"] = dict(existing)
else:
data["in_library"] = False
data["library_card"] = None
# Multi-Word & Phrasal Verb Detection
data["phrasal_verb"] = None
if context:
pv = detect_phrasal_verb(
sentence=context,
target_word=query_str,
user_id=user["id"],
conn=conn,
)
if pv and pv["term"].lower() != query_str.lower():
data["phrasal_verb"] = pv
return data
@router.get("/api/detect-phrasal-verb")
async def api_detect_phrasal_verb(
sentence: str = Query(..., min_length=1),
word: str = Query(..., min_length=1),
user: dict = Depends(require_user),
):
"""
Analyzes sentence context around word to detect phrasal verbs, idioms, and collocations.
"""
with get_db() as conn:
pv = detect_phrasal_verb(sentence=sentence, target_word=word, user_id=user["id"], conn=conn)
return {"detected": bool(pv), "phrasal_verb": pv}
@router.api_route("/api/audio", methods=["GET", "HEAD"])
async def api_audio_proxy():
# Audio feature disabled
return Response(status_code=204)
@router.post("/api/translate-sentence")
async def api_translate_sentence(
req: SentenceTranslateRequest,
user: dict = Depends(require_user),
):
"""
Translates a sentence using local AI with in-context meaning of the target word.
Uses SQLite sentence_translations cache for sub-millisecond retrieval on repeat queries.
"""
clean_s = req.sentence.strip()
if not clean_s:
raise HTTPException(status_code=400, detail="Sentence cannot be empty")
# Pre-warm neural audio for sentence and target word in background
asyncio.create_task(prewarm_card_audio(req.target_word or "", clean_s))
import sys
main_mod = sys.modules.get("app.main")
translator = getattr(main_mod, "translate_sentence_ai", translate_sentence_ai) if main_mod else translate_sentence_ai
return await translator(clean_s, req.target_word or "", req.context or "")
@router.get("/api/ai/status")
async def api_ai_status(user: dict = Depends(require_user)):
"""
Returns local AI status and available models.
"""
return await get_ollama_status()
@router.get("/api/audio/{hash_val}.wav")
async def api_get_cached_audio(hash_val: str):
"""
Streams pre-warmed / cached audio file directly with immutable cache headers.
"""
clean_hash = hash_val.lower().strip()
if not re.match(r"^[a-f0-9]{32}$", clean_hash):
raise HTTPException(status_code=400, detail="Invalid audio hash")
audio_path = AUDIO_CACHE_DIR / f"{clean_hash}.wav"
if not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
path=str(audio_path),
media_type="audio/wav",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
@router.get("/api/tts")
async def api_tts(
text: str = Query(..., min_length=1, max_length=1000),
lang: str = Query("en"),
speed: float = Query(1.0, ge=0.5, le=2.0),
voice: Optional[str] = Query(None),
user: dict = Depends(require_user),
):
"""
Synthesizes speech on-demand using local Piper TTS.
Instantly returns cached audio if previously synthesized for this voice, speed, and text.
"""
clean_text = text.strip()
if not clean_text:
raise HTTPException(status_code=400, detail="Text cannot be empty")
audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed, voice=voice)
if not audio_path or not audio_path.is_file():
raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable")
return FileResponse(
path=str(audio_path),
media_type="audio/wav",
headers={"Cache-Control": "public, max-age=604800, immutable"},
)
@router.get("/api/tts/status")
async def api_tts_status(user: dict = Depends(require_user)):
"""Returns status of local Piper TTS and all available voice models."""
return get_available_voices()