language-learning-app/api/app/routers/api/jobs.py

142 lines
4.8 KiB
Python
Raw Normal View History

2026-03-18 20:55:02 +00:00
import uuid
from datetime import datetime
from functools import partial
2026-03-18 20:55:02 +00:00
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
2026-03-27 10:36:43 +00:00
from ...auth import require_admin
from ...outbound.postgres.database import get_db, AsyncSessionLocal
from ...outbound.postgres.repositories import summarise_job_repository
from ...outbound.postgres.repositories.translated_article_repository import TranslatedArticleRepository
from ...outbound.postgres.entities.translated_article_entity import TranslatedArticleEntity
from ...outbound.gemini.gemini_client import GeminiClient
2026-03-21 20:47:15 +00:00
from ...storage import upload_audio
from ...config import settings
2026-03-21 20:47:15 +00:00
from ... import worker
2026-03-18 20:55:02 +00:00
2026-03-27 10:36:43 +00:00
router = APIRouter(prefix="/jobs", dependencies=[Depends(require_admin)])
2026-03-18 20:55:02 +00:00
class JobResponse(BaseModel):
id: uuid.UUID
status: str
translated_article_id: uuid.UUID | None = None
2026-03-18 20:55:02 +00:00
created_at: datetime
started_at: datetime | None = None
completed_at: datetime | None = None
error_message: str | None = None
2026-03-21 20:47:15 +00:00
model_config = {"from_attributes": True}
2026-03-18 20:55:02 +00:00
class JobSummary(BaseModel):
id: uuid.UUID
2026-03-18 20:55:02 +00:00
status: str
created_at: datetime
completed_at: datetime | None = None
error_message: str | None = None
model_config = {"from_attributes": True}
2026-03-18 20:55:02 +00:00
2026-03-21 20:47:15 +00:00
2026-03-18 20:55:02 +00:00
class JobListResponse(BaseModel):
jobs: list[JobSummary]
2026-03-21 20:47:15 +00:00
model_config = {"from_attributes": True}
2026-03-18 20:55:02 +00:00
@router.get("/", response_model=JobListResponse)
async def get_jobs(
db: AsyncSession = Depends(get_db),
) -> JobListResponse:
2026-03-18 20:55:02 +00:00
try:
jobs = await summarise_job_repository.list_all(db)
2026-03-21 20:47:15 +00:00
return {"jobs": jobs}
2026-03-18 20:55:02 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{job_id}", response_model=JobResponse)
async def get_job(
job_id: str,
db: AsyncSession = Depends(get_db),
) -> JobResponse:
try:
uid = uuid.UUID(job_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid job ID format")
job = await summarise_job_repository.get_by_id(db, uid)
2026-03-18 20:55:02 +00:00
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return JobResponse(
id=job.id,
2026-03-18 20:55:02 +00:00
status=job.status,
translated_article_id=job.translated_article_id,
2026-03-18 20:55:02 +00:00
created_at=job.created_at,
started_at=job.started_at,
completed_at=job.completed_at,
error_message=job.error_message,
2026-03-18 20:55:02 +00:00
)
async def _run_regenerate_audio(job_id: uuid.UUID) -> None:
gemini_client = GeminiClient(settings.gemini_api_key)
async with AsyncSessionLocal() as db:
job = await summarise_job_repository.get_by_id(db, job_id)
article_repo = TranslatedArticleRepository(db)
article_entity = await db.get(TranslatedArticleEntity, job.translated_article_id)
await summarise_job_repository.mark_processing(db, job)
try:
voice = gemini_client.get_voice_by_language(article_entity.target_language)
wav_bytes = await gemini_client.generate_audio(article_entity.target_body, voice)
audio_key = f"audio/{job_id}.wav"
upload_audio(audio_key, wav_bytes)
await article_repo.update_audio(
article_entity.id,
audio_url=audio_key,
target_body_transcript=article_entity.target_body_transcript,
)
await summarise_job_repository.mark_succeeded(db, job)
except Exception as exc:
await summarise_job_repository.mark_failed(db, job, str(exc))
@router.post("/{job_id}/regenerate-audio", status_code=202)
async def regenerate_audio(
job_id: str,
db: AsyncSession = Depends(get_db),
2026-03-27 10:36:43 +00:00
token_data: dict = Depends(require_admin),
) -> dict:
try:
uid = uuid.UUID(job_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid job ID format")
job = await summarise_job_repository.get_by_id(db, uid)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
if str(job.user_id) != token_data["sub"]:
raise HTTPException(status_code=403, detail="Not authorized to modify this job")
if job.translated_article_id is None:
raise HTTPException(status_code=400, detail="Job has no associated article")
article_entity = await db.get(TranslatedArticleEntity, job.translated_article_id)
if not article_entity or not article_entity.target_body:
raise HTTPException(status_code=400, detail="Job has no generated text to synthesize")
if article_entity.audio_url:
raise HTTPException(status_code=409, detail="Job already has audio")
if job.status == "processing":
raise HTTPException(status_code=409, detail="Job is already processing")
await worker.enqueue(partial(_run_regenerate_audio, uid))
return {"job_id": job_id}