Files

170 lines
5.8 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)
# Ensure term is always present in response
target_term = data.get("term") or data.get("english_term") or query_str
data["term"] = target_term
# 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(?)) OR lower(trim(term)) = lower(trim(?)))",
(user["id"], target_term, 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()