diff --git a/backend/alembic/versions/0002_extend_tags_for_vision.py b/backend/alembic/versions/0002_extend_tags_for_vision.py new file mode 100644 index 0000000..238ac78 --- /dev/null +++ b/backend/alembic/versions/0002_extend_tags_for_vision.py @@ -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") diff --git a/backend/app/models/tags.py b/backend/app/models/tags.py index a2d766a..6cb50c2 100644 --- a/backend/app/models/tags.py +++ b/backend/app/models/tags.py @@ -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 \ No newline at end of file + pass diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py index 643a66d..ee73037 100644 --- a/backend/app/routers/tags.py +++ b/backend/app/routers/tags.py @@ -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)