Files
2026-07-13 13:08:41 +08:00

112 lines
3.1 KiB
Python

"""In-process jobs for PPTX→template slide layout generation.
Long-running LLM calls can exceed reverse-proxy idle/read timeouts (often 60s).
The browser starts a job (fast POST) and polls status (short GETs) until completion.
Note: state lives in this process only (single uvicorn worker in the shipped stack).
"""
from __future__ import annotations
import asyncio
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Awaitable, Callable, Optional
from fastapi import HTTPException
from pydantic import BaseModel, Field
class SlideLayoutJobStartResponse(BaseModel):
job_id: str
class SlideLayoutJobStatusResponse(BaseModel):
status: str = Field(..., description="pending | complete | failed")
react_component: Optional[str] = None
error: Optional[str] = None
@dataclass
class _JobRecord:
status: str = "pending"
react_component: Optional[str] = None
error: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
_jobs: dict[str, _JobRecord] = {}
_lock = asyncio.Lock()
_MAX_JOBS = 256
def _http_exception_message(exc: HTTPException) -> str:
detail = exc.detail
if isinstance(detail, str):
return detail
return str(detail)
async def _prune_if_needed() -> None:
async with _lock:
if len(_jobs) <= _MAX_JOBS:
return
terminal = [
(jid, rec.created_at)
for jid, rec in _jobs.items()
if rec.status in ("complete", "failed")
]
terminal.sort(key=lambda x: x[1])
for jid, _ in terminal[: max(1, len(_jobs) - _MAX_JOBS + 32)]:
_jobs.pop(jid, None)
async def start_slide_layout_job(
work: Callable[[], Awaitable[str]],
) -> str:
await _prune_if_needed()
job_id = str(uuid.uuid4())
async with _lock:
_jobs[job_id] = _JobRecord()
async def _runner() -> None:
try:
react = await work()
async with _lock:
rec = _jobs.get(job_id)
if rec:
rec.status = "complete"
rec.react_component = react
except HTTPException as exc:
async with _lock:
rec = _jobs.get(job_id)
if rec:
rec.status = "failed"
rec.error = _http_exception_message(exc)[:4000]
except asyncio.CancelledError:
raise
except Exception as exc:
async with _lock:
rec = _jobs.get(job_id)
if rec:
rec.status = "failed"
msg = str(exc).strip() or exc.__class__.__name__
rec.error = msg[:4000]
asyncio.create_task(_runner())
return job_id
async def get_slide_layout_job(job_id: str) -> Optional[_JobRecord]:
async with _lock:
rec = _jobs.get(job_id)
if rec is None:
return None
return _JobRecord(
status=rec.status,
react_component=rec.react_component,
error=rec.error,
created_at=rec.created_at,
)