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()