feat: extend Tag model for unified ML tagging

Unify object detections, scene labels, and face clusters with user tags
via new columns on the existing Tag model:
- kind (user|object|scene|face_cluster), source, representative_photo_id
- photo_tags gains confidence, bbox (JSONB), source per-association
- Uniqueness moves from (name) to (name, kind) so ML labels coexist
  with user tags without collision

Add Alembic migration 0002 with defensive IF NOT EXISTS guards.

Update tags router: kind filter on GET, merge endpoint for combining
auto-detected clusters/objects, include kind/source in list response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 09:02:32 +02:00
parent 9282a5c734
commit b1c2bdf7f0
3 changed files with 204 additions and 21 deletions

View File

@@ -1,10 +1,15 @@
"""
Tags API router
Tags API router.
Unified across user tags, ML-detected objects, and face clusters via
the `kind` query parameter. Default behaviour (no kind filter) returns
all tags — the frontend's "Hide auto-generated tags" toggle filters
client-side or passes `kind=user`.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, insert, delete
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -19,6 +24,7 @@ router = APIRouter()
class TagCreate(BaseModel):
name: str
color: Optional[str] = None
kind: str = "user"
class TagUpdate(BaseModel):
@@ -26,11 +32,18 @@ class TagUpdate(BaseModel):
color: Optional[str] = None
class TagMerge(BaseModel):
target_id: str # tag to merge INTO
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_tags(db: AsyncSession = Depends(get_db)):
"""List all tags with their photo counts."""
async def list_tags(
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
db: AsyncSession = Depends(get_db),
):
"""List all tags with their photo counts, optionally filtered by kind."""
count_subq = (
select(
photo_tags.c.tag_id,
@@ -42,8 +55,11 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
stmt = (
select(Tag, count_subq.c.photo_count)
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
.order_by(Tag.name.asc())
)
if kind:
stmt = stmt.where(Tag.kind == kind)
stmt = stmt.order_by(Tag.name.asc())
result = await db.execute(stmt)
rows = result.all()
@@ -52,6 +68,9 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
"id": tag.id,
"name": tag.name,
"color": tag.color,
"kind": tag.kind,
"source": tag.source,
"representative_photo_id": tag.representative_photo_id,
"photo_count": int(count or 0),
}
for tag, count in rows
@@ -60,30 +79,37 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
@router.post("", status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
"""Create a new tag. Names are unique — re-creating an existing name
returns the existing row instead of erroring (idempotent for the
autocomplete UI flow)."""
"""Create a new tag. The (name, kind) pair is unique — re-creating an
existing pair returns the existing row (idempotent for autocomplete)."""
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Tag name is required")
existing = await db.execute(select(Tag).where(Tag.name == name))
existing = await db.execute(
select(Tag).where(Tag.name == name, Tag.kind == body.kind)
)
found = existing.scalar_one_or_none()
if found:
return {"id": found.id, "name": found.name, "color": found.color, "photo_count": 0}
return {
"id": found.id, "name": found.name, "color": found.color,
"kind": found.kind, "photo_count": 0,
}
tag = Tag(name=name, color=body.color)
tag = Tag(name=name, color=body.color, kind=body.kind)
db.add(tag)
await db.commit()
await db.refresh(tag)
return {"id": tag.id, "name": tag.name, "color": tag.color, "photo_count": 0}
return {
"id": tag.id, "name": tag.name, "color": tag.color,
"kind": tag.kind, "photo_count": 0,
}
@router.patch("/{tag_id}")
async def update_tag(
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
):
"""Rename or recolor a tag."""
"""Rename or recolor a tag (works for any kind — user, object, face_cluster)."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if not tag:
@@ -99,7 +125,52 @@ async def update_tag(
await db.commit()
await db.refresh(tag)
return {"id": tag.id, "name": tag.name, "color": tag.color}
return {"id": tag.id, "name": tag.name, "color": tag.color, "kind": tag.kind}
@router.post("/{tag_id}/merge")
async def merge_tag(
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db)
):
"""Merge tag_id INTO target_id. All photo associations from the source
tag are moved to the target, then the source tag is deleted.
Useful for merging auto-detected face clusters (e.g. "Person 3""Alice")
or merging duplicate object labels."""
if tag_id == body.target_id:
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
source = (await db.execute(select(Tag).where(Tag.id == tag_id))).scalar_one_or_none()
target = (await db.execute(select(Tag).where(Tag.id == body.target_id))).scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source tag not found")
if not target:
raise HTTPException(status_code=404, detail="Target tag not found")
# Move photo associations: update tag_id from source → target.
# Skip any that would violate the PK (photo already tagged with target).
existing_target_photos = select(photo_tags.c.photo_id).where(
photo_tags.c.tag_id == body.target_id
)
await db.execute(
update(photo_tags)
.where(
photo_tags.c.tag_id == tag_id,
photo_tags.c.photo_id.notin_(existing_target_photos),
)
.values(tag_id=body.target_id)
)
# Delete remaining source associations (duplicates that couldn't move)
from sqlalchemy import delete as sa_delete
await db.execute(
sa_delete(photo_tags).where(photo_tags.c.tag_id == tag_id)
)
# Delete source tag
await db.delete(source)
await db.commit()
return {"merged_into": target.id, "target_name": target.name}
@router.delete("/{tag_id}", status_code=204)