"""Duplicate detection & merge endpoints (#166). Read endpoints require vault access; the merge endpoint is destructive (backup first in the service layer) and additionally requires the confirmation token pattern used by mutating routes — here enforced by an explicit ``confirm=true`` body flag, mirroring the agent two-step flow. """ from __future__ import annotations from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException, Query from pydantic import BaseModel, ConfigDict, Field from backend.auth.middleware import check_vault_access, require_auth from backend.services import duplicates as _duplicates from backend.services.errors import ServiceError router = APIRouter(prefix="/api/duplicates", tags=["duplicates"]) class DuplicatePair(BaseModel): """One candidate duplicate pair.""" model_config = ConfigDict(extra="allow") file_a: str = Field(description="First file (vault-relative)") file_b: str = Field(description="Second file (vault-relative)") score: float = Field(description="Blended similarity in [0, 1]") class DuplicatesResponse(BaseModel): """Response for GET /api/duplicates.""" model_config = ConfigDict(extra="allow") vault: str = Field(description="Vault name") threshold: float = Field(description="Applied threshold") files_scanned: int = Field(description="Markdown files compared") truncated: bool = Field(description="True when the scan hit the file cap") pairs: list[DuplicatePair] = Field(description="Candidate pairs, best score first") class MergeResponse(BaseModel): """Response for POST /api/duplicates/merge.""" model_config = ConfigDict(extra="allow") strategy: str = Field(description="Applied merge strategy") target: str = Field(description="Surviving note") deleted: str = Field(description="Absorbed note (deleted after merge)") @router.get("", response_model=DuplicatesResponse) async def api_duplicates_list( vault: str = Query(..., description="Vault name"), threshold: float = Query(0.75, ge=0.3, le=1.0, description="Minimum similarity"), limit: int = Query(20, ge=1, le=200, description="Max pairs"), subdir: str = Query("", description="Directory scope"), current_user: dict[str, Any] = Depends(require_auth), ): """List candidate duplicate notes ordered by descending score.""" if not check_vault_access(vault, current_user): raise HTTPException(403, f"No access to vault '{vault}'") try: return _duplicates.find_duplicate_pairs(vault, threshold=threshold, limit=limit, subdir=subdir) except ServiceError as e: raise HTTPException(e.status or 400, e.message) from e @router.post("/merge", response_model=MergeResponse) async def api_duplicates_merge( body: dict[str, Any] = Body(...), current_user: dict[str, Any] = Depends(require_auth), ): """Merge *source_path* into *target_path* (``confirm: true`` required).""" vault = str(body.get("vault") or "") if not check_vault_access(vault, current_user): raise HTTPException(403, f"No access to vault '{vault}'") if body.get("confirm") is not True: raise HTTPException(400, "Fusion destructive : confirmez avec {confirm: true}") try: return _duplicates.merge_duplicates( vault, str(body.get("source_path") or ""), str(body.get("target_path") or ""), strategy=str(body.get("strategy") or "append"), ) except ServiceError as e: raise HTTPException(e.status or 400, e.message) from e