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:
85
backend/alembic/versions/0002_extend_tags_for_vision.py
Normal file
85
backend/alembic/versions/0002_extend_tags_for_vision.py
Normal 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")
|
||||||
@@ -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.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -13,20 +19,41 @@ photo_tags = Table(
|
|||||||
Base.metadata,
|
Base.metadata,
|
||||||
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
|
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
|
||||||
Column('tag_id', String, ForeignKey('tags.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_photo_id', 'photo_id'),
|
||||||
Index('ix_photo_tags_tag_id', 'tag_id'),
|
Index('ix_photo_tags_tag_id', 'tag_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Tag(Base):
|
class Tag(Base):
|
||||||
__tablename__ = 'tags'
|
__tablename__ = 'tags'
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('name', 'kind', name='uq_tags_name_kind'),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
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
|
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
|
# Relationships
|
||||||
photos = relationship("Photo", secondary=photo_tags, backref="tags")
|
photos = relationship("Photo", secondary=photo_tags, backref="tags")
|
||||||
|
|
||||||
|
|
||||||
class PhotoTag:
|
class PhotoTag:
|
||||||
"""Helper class for photo-tag associations (not a table model)"""
|
"""Helper class for photo-tag associations (not a table model)"""
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -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 typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, func, insert, delete
|
from sqlalchemy import select, func, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -19,6 +24,7 @@ router = APIRouter()
|
|||||||
class TagCreate(BaseModel):
|
class TagCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
|
kind: str = "user"
|
||||||
|
|
||||||
|
|
||||||
class TagUpdate(BaseModel):
|
class TagUpdate(BaseModel):
|
||||||
@@ -26,11 +32,18 @@ class TagUpdate(BaseModel):
|
|||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TagMerge(BaseModel):
|
||||||
|
target_id: str # tag to merge INTO
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
async def list_tags(
|
||||||
"""List all tags with their photo counts."""
|
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 = (
|
count_subq = (
|
||||||
select(
|
select(
|
||||||
photo_tags.c.tag_id,
|
photo_tags.c.tag_id,
|
||||||
@@ -42,8 +55,11 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(Tag, count_subq.c.photo_count)
|
select(Tag, count_subq.c.photo_count)
|
||||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
.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)
|
result = await db.execute(stmt)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
@@ -52,6 +68,9 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
|
|||||||
"id": tag.id,
|
"id": tag.id,
|
||||||
"name": tag.name,
|
"name": tag.name,
|
||||||
"color": tag.color,
|
"color": tag.color,
|
||||||
|
"kind": tag.kind,
|
||||||
|
"source": tag.source,
|
||||||
|
"representative_photo_id": tag.representative_photo_id,
|
||||||
"photo_count": int(count or 0),
|
"photo_count": int(count or 0),
|
||||||
}
|
}
|
||||||
for tag, count in rows
|
for tag, count in rows
|
||||||
@@ -60,30 +79,37 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||||
"""Create a new tag. Names are unique — re-creating an existing name
|
"""Create a new tag. The (name, kind) pair is unique — re-creating an
|
||||||
returns the existing row instead of erroring (idempotent for the
|
existing pair returns the existing row (idempotent for autocomplete)."""
|
||||||
autocomplete UI flow)."""
|
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="Tag name is required")
|
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()
|
found = existing.scalar_one_or_none()
|
||||||
if found:
|
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)
|
db.add(tag)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tag)
|
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}")
|
@router.patch("/{tag_id}")
|
||||||
async def update_tag(
|
async def update_tag(
|
||||||
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
|
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))
|
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||||
tag = result.scalar_one_or_none()
|
tag = result.scalar_one_or_none()
|
||||||
if not tag:
|
if not tag:
|
||||||
@@ -99,7 +125,52 @@ async def update_tag(
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tag)
|
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)
|
@router.delete("/{tag_id}", status_code=204)
|
||||||
|
|||||||
Reference in New Issue
Block a user