"""Duplicate cluster review API: list, detail, and decision. Decisions carry an ``expected_version``; a stale version returns 409 without mutating anything, so a browser acting on out-of-date state cannot overwrite a newer decision. """ from __future__ import annotations from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse from photo_pipeline.schemas import DecisionRequest from photo_pipeline.services.duplicates import ( MAX_MEMBER_PAGE, MEMBER_PAGE, ConflictError, DuplicateError, DuplicateService, ) router = APIRouter(tags=["duplicates"]) def _service(request: Request) -> DuplicateService: return DuplicateService(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.post("/duplicates/detect") def detect(request: Request) -> dict: """Run duplicate detection over the current assets (synchronous in Phase A).""" report = _service(request).detect() return {"clusters": len(report.clusters), "counts": report.counts} @router.get("/duplicates/clusters") def list_clusters( request: Request, state: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> dict: return _service(request).list_clusters(state=state, limit=limit, offset=offset) @router.get("/duplicates/clusters/{cluster_id}") def get_cluster( cluster_id: str, request: Request, limit: int = Query(MEMBER_PAGE, ge=1, le=MAX_MEMBER_PAGE), offset: int = Query(0, ge=0), ): detail = _service(request).get_cluster(cluster_id, limit=limit, offset=offset) if detail is None: return _error(404, "not_found", f"unknown cluster {cluster_id}") return detail @router.post("/duplicates/clusters/{cluster_id}/decision") def decide(cluster_id: str, body: DecisionRequest, request: Request): service = _service(request) if service.get_cluster(cluster_id) is None: return _error(404, "not_found", f"unknown cluster {cluster_id}") try: return service.decide( cluster_id, body.decision, canonical_asset_id=body.canonical_asset_id, expected_version=body.expected_version, reason=body.reason, ) except ConflictError as error: return _error(409, "version_conflict", str(error)) except DuplicateError as error: return _error(422, "invalid_decision", str(error))