pdf-quiz-generator/backend/app/routers/tts.py
Daniel 12d99d3609 Add switchable embedding model, Polly toggle, job cancellation, and UI fixes
Embedding:
- Embedding model now configurable via Admin UI (More tab) or LITELLM_EMBEDDING_MODEL env
- Calls LiteLLM proxy directly via httpx (bypasses LiteLLM library param validation)
- Passes dimensions=1024 to proxy; Redis setting overrides env var
- Default model: ge-gemini-embedding-001 (Gemini AI Studio, 1024-dim)
- Test button in admin UI to verify model works
- Fixed vector_service to use httpx + Redis model (was broken with non-prefixed model names)

Polly:
- Global enable/disable toggle in Admin → More settings (stored in Redis)
- /tts/voices filters out polly/* when disabled
- /tts/speak rejects polly requests when disabled

Job cancellation:
- POST /quizzes/job/{job_id}/cancel endpoint
- Cancel button on JobsPage for running jobs
- Celery task checks Redis status at each chunk boundary and exits cleanly
- Fixes DB lock on restart caused by cancelled jobs leaving open transactions

Admin UI:
- Settings tab renamed to "More" (heading: More Settings)
- Model row overflow fixed (minWidth: 0 + ellipsis on model_id)
- Embedding model search shows all proxy models (no auto-filter by "embed")
- Navbar correctly excludes cancelled/failed jobs from "extracting" count

README:
- Added Rebuild & Restart section with commands
- Updated embedding model reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:44:11 +02:00

78 lines
2.7 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.services import ai_service
from app.utils.auth import get_current_user
router = APIRouter()
class TTSRequest(BaseModel):
text: str
voice: str | None = None # model_id override
def _polly_enabled() -> bool:
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
val = r.get("settings:polly_enabled")
return val != "false"
except Exception:
return True
@router.get("/voices")
def get_voices(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return available TTS voices from DB, excluding Polly if disabled."""
query = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
)
if not _polly_enabled():
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
@router.post("/speak")
def text_to_speech(
request: TTSRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Convert text to speech using configured or user-selected TTS model."""
if not request.text.strip():
raise HTTPException(status_code=400, detail="Text cannot be empty")
text = request.text[:2000]
if request.voice:
if request.voice.startswith("polly/") and not _polly_enabled():
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
model_id = config.model_id if config else request.voice
api_key = config.api_key if (config and config.api_key) else None
else:
config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
).first()
model_id = config.model_id if config else "tts-1:alloy"
api_key = config.api_key if (config and config.api_key) else None
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
if audio is None:
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
return Response(content=audio, media_type="audio/mpeg")