Three things landed together; the message names all of them, because a commit that mentions one is a commit nobody finds the other two in. **Figures.** Thirty-four JPEG 2000 files — 21 on questions, the rest unattached in the media library — are WebP now, with `questions.image_path`, `questions.explanation_image_path` and `media_assets.path` repointed together. Serving already converted them on the way out, so nothing was broken; this removes the step and makes what is stored the same thing that is served. The originals stay: they are the only copy of what came out of the PDF, they cost a few megabytes between them, and a conversion nobody can undo is not one to run against a live bank. Paths are found by what the columns say rather than by listing a bucket, because three tables record them and updating two would be worse than none. **The openai SDK is gone.** Ten call sites — one more than the map said, the Celery article drafter — every one of them a POST with a JSON body, and not one reading usage, cost, tool calls or logprobs. Every other call to the same proxy was already plain httpx: embeddings, the ChromaDB embedding function, speech both ways, model discovery, the vision probe. So this deletes an abstraction rather than swapping one for another, and leaves one HTTP client instead of two. `chat()` and `achat()` return the message content; a `ProxyError` carries the status and the first 500 characters of the body, which is where the proxy explains itself. Behaviour is preserved deliberately, including a 600-second fallback timeout for the four call sites that were running on the SDK's ten-minute default. Lowering that is a real change and belongs in its own commit. Proved against the live proxy on both services rather than only against mocks: a completion, an async completion, a real 400 the vision probe still classifies as a refusal, 407 models read from the catalogue, and a word read off an image. **Voice.** A chosen voice is honoured whatever serves it. The prefix check only accepted a locally served one, so a site adding a hosted voice would offer it in Settings, save the learner's choice, and then quietly read every question in the default voice. The list has always come from the database — adding a voice is a row in Settings → AI models, never a code change. And the sign-in page stops offering a locked door: `signup-policy` reports whether registration is open at all, and the Sign up link goes when it is not. The switch existed and the only way to discover it was to fill the form in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
137 lines
5 KiB
Python
137 lines
5 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
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, check_rate_limit
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class TTSRequest(BaseModel):
|
|
text: str
|
|
voice: str | None = None # model_id override
|
|
|
|
|
|
MAX_AUDIO_UPLOAD_BYTES = 25 * 1024 * 1024
|
|
|
|
|
|
def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None]:
|
|
config = db.query(AIModelConfig).filter(
|
|
AIModelConfig.task == task,
|
|
AIModelConfig.is_active == True,
|
|
AIModelConfig.is_default == True,
|
|
).first()
|
|
if config:
|
|
return config.model_id, config.api_key or None
|
|
return fallback, None
|
|
|
|
|
|
def _default_tts_model(db: Session) -> tuple[str, str | None]:
|
|
config = db.query(AIModelConfig).filter(
|
|
AIModelConfig.task == "tts",
|
|
AIModelConfig.is_active == True,
|
|
AIModelConfig.is_default == True,
|
|
AIModelConfig.model_id.like("local-%"),
|
|
).first()
|
|
if config:
|
|
return config.model_id, config.api_key or None
|
|
return "local-kokoro-tts:am_adam", None
|
|
|
|
|
|
@router.get("/voices")
|
|
def get_voices(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return LiteLLM-routed TTS models only."""
|
|
query = db.query(AIModelConfig).filter(
|
|
AIModelConfig.task == "tts",
|
|
AIModelConfig.is_active == True,
|
|
AIModelConfig.model_id.like("local-%"),
|
|
)
|
|
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."""
|
|
# Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt)
|
|
check_rate_limit(
|
|
key=f"tts_speak:{current_user.id}",
|
|
max_calls=240,
|
|
window_seconds=3600,
|
|
detail="You've reached the audio limit. The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
|
|
user=current_user,
|
|
)
|
|
if not request.text.strip():
|
|
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
|
|
|
text = request.text[:2000]
|
|
|
|
# The player no longer asks, so the learner's own setting is what decides —
|
|
# falling through to whatever an administrator marked default when they have
|
|
# never chosen one. A caller may still name a voice, which is how a preview
|
|
# in Settings plays the one being considered rather than the one in force.
|
|
wanted = request.voice or current_user.tts_voice
|
|
# Any active voice, not only a locally served one. The prefix check that
|
|
# used to guard this meant a site adding a hosted voice would offer it in
|
|
# Settings, save the learner's choice, and then quietly read every question
|
|
# in the default voice instead.
|
|
config = db.query(AIModelConfig).filter(
|
|
AIModelConfig.task == "tts",
|
|
AIModelConfig.is_active == True,
|
|
AIModelConfig.model_id == wanted,
|
|
).first() if wanted else None
|
|
if config:
|
|
model_id, api_key = config.model_id, config.api_key or None
|
|
else:
|
|
model_id, api_key = _default_tts_model(db)
|
|
|
|
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")
|
|
|
|
|
|
@router.post("/transcribe")
|
|
def speech_to_text(
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Transcribe microphone audio using the configured STT model."""
|
|
check_rate_limit(
|
|
key=f"stt_transcribe:{current_user.id}",
|
|
max_calls=120,
|
|
window_seconds=3600,
|
|
detail="You've reached the speech transcription limit. Try again shortly.",
|
|
user=current_user,
|
|
)
|
|
audio = file.file.read()
|
|
if not audio:
|
|
raise HTTPException(status_code=400, detail="Audio file is empty")
|
|
if len(audio) > MAX_AUDIO_UPLOAD_BYTES:
|
|
raise HTTPException(status_code=413, detail="Audio file is too large")
|
|
|
|
model_id, api_key = _task_model(db, "stt", "local-parakeet-v3")
|
|
text = ai_service.transcribe_audio(
|
|
audio,
|
|
filename=file.filename or "audio.webm",
|
|
content_type=file.content_type or "audio/webm",
|
|
model_id=model_id,
|
|
api_key=api_key,
|
|
)
|
|
if text is None:
|
|
raise HTTPException(status_code=502, detail="Speech transcription failed. Check STT model configuration.")
|
|
return {"text": text, "model": model_id}
|