- Step 14: Brand enforcement service (font/color/logo replacement, WCAG contrast check, LLM prompt context) - Step 15: Enhanced outline & slide content generation with brand context, content summary, "no hallucination" instructions - Step 15b: LLM auto-fallback retry logic across providers (FALLBACK_LLM_PROVIDERS env) - Step 16: Redis/ARQ job queue — worker entry point, presentation & master deck workers, job status/SSE endpoints, graceful fallback to BackgroundTasks when Redis unavailable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""Job status polling and SSE streaming endpoints."""
|
|
import asyncio
|
|
import json
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from models.sse_response import SSEResponse, SSEErrorResponse
|
|
from models.sql.job import JobModel
|
|
from models.sql.user import UserModel
|
|
from services.database import get_async_session
|
|
from services.redis_service import get_arq_pool
|
|
from utils.auth_dependencies import get_current_user
|
|
|
|
JOBS_ROUTER = APIRouter(prefix="/api/v1/ppt", tags=["Jobs"])
|
|
|
|
|
|
@JOBS_ROUTER.get("/jobs/{job_id}")
|
|
async def get_job_status(
|
|
job_id: uuid.UUID,
|
|
_current_user: UserModel = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Poll job status."""
|
|
job = await session.get(JobModel, job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
return {
|
|
"id": str(job.id),
|
|
"job_type": job.job_type,
|
|
"status": job.status,
|
|
"progress": job.progress,
|
|
"progress_message": job.progress_message,
|
|
"error_message": job.error_message,
|
|
"presentation_id": str(job.presentation_id) if job.presentation_id else None,
|
|
"created_at": job.created_at.isoformat() if job.created_at else None,
|
|
"started_at": job.started_at.isoformat() if job.started_at else None,
|
|
"completed_at": job.completed_at.isoformat() if job.completed_at else None,
|
|
}
|
|
|
|
|
|
@JOBS_ROUTER.get("/jobs/{job_id}/stream")
|
|
async def stream_job_progress(
|
|
job_id: uuid.UUID,
|
|
_current_user: UserModel = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""SSE stream of job progress events via Redis pub/sub."""
|
|
job = await session.get(JobModel, job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
# If already completed/failed, return final event immediately
|
|
if job.status in ("completed", "failed"):
|
|
async def done_stream():
|
|
data = {
|
|
"type": "progress",
|
|
"job_id": str(job.id),
|
|
"progress": job.progress,
|
|
"message": job.progress_message or job.status,
|
|
"status": job.status,
|
|
}
|
|
yield SSEResponse(
|
|
event="response", data=json.dumps(data)
|
|
).to_string()
|
|
|
|
return StreamingResponse(done_stream(), media_type="text/event-stream")
|
|
|
|
async def progress_stream():
|
|
try:
|
|
pool = await get_arq_pool()
|
|
pubsub = pool.pubsub()
|
|
channel = f"job:{job_id}:progress"
|
|
await pubsub.subscribe(channel)
|
|
|
|
# Send initial status
|
|
yield SSEResponse(
|
|
event="response",
|
|
data=json.dumps({
|
|
"type": "progress",
|
|
"job_id": str(job_id),
|
|
"progress": job.progress,
|
|
"message": job.progress_message or "Waiting",
|
|
"status": job.status,
|
|
}),
|
|
).to_string()
|
|
|
|
# Listen for updates with timeout
|
|
timeout = 600 # 10 minutes max
|
|
elapsed = 0
|
|
while elapsed < timeout:
|
|
message = await pubsub.get_message(
|
|
ignore_subscribe_messages=True, timeout=2.0
|
|
)
|
|
if message and message["type"] == "message":
|
|
raw = message["data"]
|
|
if isinstance(raw, bytes):
|
|
raw = raw.decode()
|
|
payload = json.loads(raw)
|
|
yield SSEResponse(
|
|
event="response",
|
|
data=json.dumps({"type": "progress", **payload}),
|
|
).to_string()
|
|
|
|
# Stop streaming on terminal status
|
|
if payload.get("status") in ("completed", "failed"):
|
|
break
|
|
else:
|
|
elapsed += 2
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
await pubsub.unsubscribe(channel)
|
|
await pubsub.aclose()
|
|
|
|
except Exception as e:
|
|
yield SSEErrorResponse(detail=str(e)[:200]).to_string()
|
|
|
|
return StreamingResponse(progress_stream(), media_type="text/event-stream")
|
|
|
|
|
|
@JOBS_ROUTER.delete("/jobs/{job_id}")
|
|
async def cancel_job(
|
|
job_id: uuid.UUID,
|
|
_current_user: UserModel = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Cancel a queued or processing job."""
|
|
job = await session.get(JobModel, job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
if job.status in ("completed", "failed"):
|
|
raise HTTPException(status_code=409, detail="Job already finished")
|
|
|
|
# Try to abort via ARQ
|
|
try:
|
|
pool = await get_arq_pool()
|
|
await pool.abort_job(str(job_id))
|
|
except Exception:
|
|
pass # Best effort
|
|
|
|
job.status = "failed"
|
|
job.error_message = "Cancelled by user"
|
|
job.progress_message = "Cancelled"
|
|
await session.commit()
|
|
|
|
return {"ok": True, "status": "cancelled"}
|