"""Safety review, decisions, and scoring-job API. Decisions are synchronous commands (persist + EXIF checkpoint). Scoring is a durable job under the ``library_write`` lock, so only one mutating job runs at a time. """ from __future__ import annotations from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse from photo_pipeline.jobs.domain_handlers import LIBRARY_WRITE_LOCK, SAFETY_SCORE from photo_pipeline.schemas import SafetyDecisionRequest from photo_pipeline.services.jobs import JobBlocked, JobService from photo_pipeline.services.safety import SafetyError, SafetyService router = APIRouter(tags=["safety"]) def _service(request: Request) -> SafetyService: return SafetyService(request.app.state.session_factory) def _error(status: int, code: str, message: str) -> JSONResponse: return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}}) @router.get("/safety/queue") def review_queue( request: Request, state: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), ) -> dict: return _service(request).review_queue(state=state, limit=limit, offset=offset) @router.get("/safety/counts") def counts(request: Request) -> dict: return _service(request).counts() @router.post("/safety/decisions") def decide(body: SafetyDecisionRequest, request: Request): try: return _service(request).decide(body.asset_id, body.decision) except SafetyError as error: return _error(404, "not_found", str(error)) @router.post("/safety/jobs") def enqueue_scoring(request: Request): ids = _service(request).scorable_asset_ids() if not ids: return _error(409, "nothing_to_score", "no eligible assets to score") jobs = JobService(request.app.state.session_factory) try: return jobs.enqueue(SAFETY_SCORE, lock=LIBRARY_WRITE_LOCK, items=ids) except JobBlocked as error: return _error(409, error.code, str(error))