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

@@ -0,0 +1,85 @@
"""extend tags for vision pipeline
Revision ID: 0002_extend_tags
Revises: 0001_baseline
Create Date: 2026-04-10
Add kind, source, representative_photo_id to tags table.
Add confidence, bbox, source to photo_tags association.
Switch uniqueness from (name) to (name, kind).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "0002_extend_tags"
down_revision: Union[str, None] = "0001_baseline"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── tags table ────────────────────────────────────────────────────
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS kind VARCHAR NOT NULL DEFAULT 'user'")
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS source VARCHAR")
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS representative_photo_id VARCHAR REFERENCES photos(id) ON DELETE SET NULL")
# Create index on kind for filtering
op.execute("CREATE INDEX IF NOT EXISTS ix_tags_kind ON tags(kind)")
# Drop old unique constraint on name (if it exists) and add (name, kind).
# SQLAlchemy create_all may have created either — handle both cases.
op.execute("""
DO $$
BEGIN
-- Drop the old single-column unique index/constraint if present.
IF EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'tags' AND indexname = 'ix_tags_name'
) THEN
DROP INDEX ix_tags_name;
END IF;
-- Some SQLAlchemy versions create a unique constraint directly.
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'tags' AND constraint_name = 'tags_name_key'
) THEN
ALTER TABLE tags DROP CONSTRAINT tags_name_key;
END IF;
END $$;
""")
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_tags_name_kind'
) THEN
ALTER TABLE tags ADD CONSTRAINT uq_tags_name_kind UNIQUE (name, kind);
END IF;
END $$;
""")
# ── photo_tags table ──────────────────────────────────────────────
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS confidence FLOAT")
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS bbox JSONB")
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS source VARCHAR")
def downgrade() -> None:
# photo_tags columns
op.drop_column("photo_tags", "source")
op.drop_column("photo_tags", "bbox")
op.drop_column("photo_tags", "confidence")
# tags: restore old unique constraint
op.execute("ALTER TABLE tags DROP CONSTRAINT IF EXISTS uq_tags_name_kind")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tags_name ON tags(name)")
# tags columns
op.drop_column("tags", "representative_photo_id")
op.drop_column("tags", "source")
op.drop_column("tags", "kind")

View File

@@ -1,8 +1,14 @@
"""
Tag model definitions
Tag model definitions.
Tags are unified across user-created tags, ML-detected objects, scene
labels, and face clusters via the `kind` column. The `photo_tags`
association carries per-photo ML metadata (confidence, bounding box,
source model).
"""
from sqlalchemy import Column, String, ForeignKey, Table, Index
from sqlalchemy import Column, String, Float, ForeignKey, Table, Index, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB
import uuid
from app.database import Base
@@ -13,20 +19,41 @@ photo_tags = Table(
Base.metadata,
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
# ML metadata — null for user-applied tags
Column('confidence', Float, nullable=True),
Column('bbox', JSONB, nullable=True), # [x1, y1, x2, y2] normalized 0-1
Column('source', String, nullable=True), # e.g. "vision:yolov8n", "vision:sface"
Index('ix_photo_tags_photo_id', 'photo_id'),
Index('ix_photo_tags_tag_id', 'tag_id'),
)
class Tag(Base):
__tablename__ = 'tags'
__table_args__ = (
UniqueConstraint('name', 'kind', name='uq_tags_name_kind'),
)
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, nullable=False, index=True)
name = Column(String, nullable=False, index=True)
color = Column(String) # Hex color code for UI display
# Tag classification
kind = Column(String, nullable=False, default='user', index=True)
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'
# Which model produced this tag (null for user-created)
source = Column(String, nullable=True)
# e.g. "vision:yolov8n", "vision:sface", null
# For face clusters: the photo used as the cluster representative thumbnail
representative_photo_id = Column(
String, ForeignKey('photos.id', ondelete='SET NULL'), nullable=True
)
# Relationships
photos = relationship("Photo", secondary=photo_tags, backref="tags")
class PhotoTag:
"""Helper class for photo-tag associations (not a table model)"""
pass
pass

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)