166 lines
5.5 KiB
Python
166 lines
5.5 KiB
Python
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()
|