89 lines
3.5 KiB
Python
89 lines
3.5 KiB
Python
"""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
|