feat: ML tag suggestions, character/fandom integrity, underscores, modal polish

Consolidated merge of feat/tag-suggestions branch. Original 64-commit history
was lost to git-object corruption in a Nextcloud-synced checkout; this single
commit captures the equivalent diff.

Includes:
- pgvector-backed tag suggestion infra (WD14 + SigLIP centroids, ml-worker
  container, Celery tasks, suggestion service, accept/reject endpoints + modal
  UI with green/red chip buttons)
- Character/fandom integrity: title-case normalization on every write path,
  fandom-id backfill, maintenance task + settings button, migrations g26041901
  + h26041901 to canonicalize legacy rows with case-only duplicate merging
- Tag-underscores + modal polish: WD14 name canonicalization at emit + accept
  + add/bulk-add paths, migration i26041901 for legacy-row rename-or-merge
  across character/fandom/NULL kinds, suggestion-accept refresh parity via
  awaited loadTags, persistent chip tint
This commit is contained in:
2026-04-19 19:50:58 -04:00
parent 69b3ddcbd0
commit 0f35a0c484
37 changed files with 8642 additions and 30 deletions
@@ -0,0 +1,121 @@
"""add tag suggestions
Revision ID: 0017871d2221
Revises: f26021101
Create Date: 2026-04-18 20:29:43.837834
"""
from alembic import op
import sqlalchemy as sa
from pgvector.sqlalchemy import Vector
# revision identifiers, used by Alembic.
revision = '0017871d2221'
down_revision = 'f26021101'
branch_labels = None
depends_on = None
def upgrade():
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
'image_tag_prediction',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('image_id', sa.Integer(), nullable=False),
sa.Column('tag_name', sa.Text(), nullable=False),
sa.Column('tag_category', sa.Text(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('model_version', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], ondelete='CASCADE',
name='fk_image_tag_prediction_image_id'),
)
op.create_index('idx_tag_predictions_image', 'image_tag_prediction', ['image_id'])
op.create_index('idx_tag_predictions_tag', 'image_tag_prediction', ['tag_name'])
op.create_index('idx_tag_predictions_model', 'image_tag_prediction', ['model_version'])
op.create_table(
'image_embedding',
sa.Column('image_id', sa.Integer(), nullable=False),
sa.Column('model_version', sa.Text(), nullable=False),
sa.Column('embedding', Vector(1152), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('image_id', 'model_version'),
sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], ondelete='CASCADE',
name='fk_image_embedding_image_id'),
)
op.execute(
"CREATE INDEX idx_embeddings_hnsw ON image_embedding "
"USING hnsw (embedding vector_cosine_ops)"
)
op.create_table(
'character_reference_embedding',
sa.Column('character_tag', sa.Text(), nullable=False),
sa.Column('model_version', sa.Text(), nullable=False),
sa.Column('centroid', Vector(1152), nullable=False),
sa.Column('reference_count', sa.Integer(), nullable=False),
sa.Column('computed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('character_tag', 'model_version'),
)
op.create_table(
'suggestion_feedback',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('image_id', sa.Integer(), nullable=False),
sa.Column('tag_name', sa.Text(), nullable=False),
sa.Column('suggestion_source', sa.Text(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('decision', sa.Text(), nullable=False),
sa.Column('decided_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], ondelete='CASCADE',
name='fk_suggestion_feedback_image_id'),
)
op.create_index('idx_feedback_image', 'suggestion_feedback', ['image_id'])
op.create_index('idx_feedback_tag', 'suggestion_feedback', ['tag_name'])
op.create_table(
'tag_suggestion_config',
sa.Column('key', sa.Text(), primary_key=True),
sa.Column('value', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
)
# Seed default thresholds
op.bulk_insert(
sa.table(
'tag_suggestion_config',
sa.column('key', sa.Text()),
sa.column('value', sa.Text()),
sa.column('description', sa.Text()),
),
[
{'key': 'threshold_general', 'value': '0.35', 'description': 'Min confidence for general tag suggestions'},
{'key': 'threshold_character_wd14', 'value': '0.75', 'description': 'Min confidence for WD14 character tags'},
{'key': 'threshold_copyright', 'value': '0.5', 'description': 'Min confidence for copyright/fandom tags'},
{'key': 'threshold_rating', 'value': '0.5', 'description': 'Min confidence for rating tags'},
{'key': 'threshold_meta', 'value': '0.5', 'description': 'Min confidence for meta tags (omitted from modal)'},
{'key': 'threshold_embedding_character', 'value': '0.85', 'description': 'Min cosine similarity for character via embeddings'},
{'key': 'min_reference_images_per_character', 'value': '5', 'description': 'Characters with fewer confirmed refs are excluded from embedding suggestions'},
{'key': 'centroid_recompute_delta', 'value': '3', 'description': 'Recompute character centroid after N new confirmed references'},
{'key': 'wd14_model_version', 'value': 'wd-eva02-large-tagger-v3', 'description': 'Current WD14 model version'},
{'key': 'siglip_model_version', 'value': 'siglip-so400m-patch14-384', 'description': 'Current SigLIP embedding model version'},
],
)
def downgrade():
op.drop_table('tag_suggestion_config', if_exists=True)
op.drop_index('idx_feedback_tag', table_name='suggestion_feedback', if_exists=True)
op.drop_index('idx_feedback_image', table_name='suggestion_feedback', if_exists=True)
op.drop_table('suggestion_feedback', if_exists=True)
op.drop_table('character_reference_embedding', if_exists=True)
op.execute("DROP INDEX IF EXISTS idx_embeddings_hnsw")
op.drop_table('image_embedding', if_exists=True)
op.drop_index('idx_tag_predictions_model', table_name='image_tag_prediction', if_exists=True)
op.drop_index('idx_tag_predictions_tag', table_name='image_tag_prediction', if_exists=True)
op.drop_index('idx_tag_predictions_image', table_name='image_tag_prediction', if_exists=True)
op.drop_table('image_tag_prediction', if_exists=True)
op.execute("DROP EXTENSION IF EXISTS vector")
@@ -0,0 +1,75 @@
"""rename character_reference_embedding to tag_reference_embedding and add tag_kind
Revision ID: g26041901
Revises: 0017871d2221
Create Date: 2026-04-19
"""
from alembic import op
import sqlalchemy as sa
revision = 'g26041901'
down_revision = '0017871d2221'
branch_labels = None
depends_on = None
def upgrade():
# Rename table
op.rename_table('character_reference_embedding', 'tag_reference_embedding')
# Rename column
op.alter_column('tag_reference_embedding', 'character_tag', new_column_name='tag_name')
# Add tag_kind column (nullable — null = general/topic tag)
op.add_column(
'tag_reference_embedding',
sa.Column('tag_kind', sa.Text(), nullable=True),
)
# Every pre-existing row was written by the character-only path, so tag_kind='character'.
op.execute("UPDATE tag_reference_embedding SET tag_kind = 'character'")
# Rename config keys. Postgres-safe single UPDATE per key.
op.execute(
"UPDATE tag_suggestion_config "
"SET key = 'threshold_embedding' "
"WHERE key = 'threshold_embedding_character'"
)
op.execute(
"UPDATE tag_suggestion_config "
"SET key = 'min_reference_images' "
"WHERE key = 'min_reference_images_per_character'"
)
# Drop the rating threshold — rating is no longer surfaced in the UI.
op.execute("DELETE FROM tag_suggestion_config WHERE key = 'threshold_rating'")
def downgrade():
# Restore threshold_rating seed with original default.
op.execute(
"INSERT INTO tag_suggestion_config (key, value, description) "
"VALUES ('threshold_rating', '0.5', 'Min confidence for rating tags') "
"ON CONFLICT (key) DO NOTHING"
)
# Revert config key renames.
op.execute(
"UPDATE tag_suggestion_config "
"SET key = 'min_reference_images_per_character' "
"WHERE key = 'min_reference_images'"
)
op.execute(
"UPDATE tag_suggestion_config "
"SET key = 'threshold_embedding_character' "
"WHERE key = 'threshold_embedding'"
)
# Drop tag_kind column.
op.drop_column('tag_reference_embedding', 'tag_kind')
# Revert column and table renames.
op.alter_column('tag_reference_embedding', 'tag_name', new_column_name='character_tag')
op.rename_table('tag_reference_embedding', 'character_reference_embedding')
@@ -0,0 +1,163 @@
"""Normalize character and fandom tag display names; merge case-only duplicates.
Revision ID: h26041901
Revises: g26041901
Create Date: 2026-04-19
Upgrade: title-cases the display-name portion of every character:* and fandom:*
tag. When two rows of the same kind collapse to the same name, merges image_tags,
tag.fandom_id references, and tag_reference_embedding rows into the already-
correctly-cased target ("winner") and deletes the "loser" tag row.
Downgrade: raises. Merges are destructive; reverting would require restoring
from a pre-migration backup.
"""
from alembic import op
from sqlalchemy import text
revision = 'h26041901'
down_revision = 'g26041901'
branch_labels = None
depends_on = None
def _normalize_display(s: str) -> str:
"""Mirror of app.utils.tag_names.normalize_display_name. Inlined here so
the migration doesn't import app code (Alembic runs without the Flask app)."""
if not s:
return s
out = []
for p in s.split(' '):
if p == '':
out.append(p)
else:
out.append(p[:1].upper() + p[1:])
return ' '.join(out)
def _compute_new_name(old_name: str, kind: str) -> str:
"""Given a full 'prefix:display' name, return the normalized full name."""
if ':' not in old_name:
return old_name
prefix, rest = old_name.split(':', 1)
if kind == 'character':
# Split off "(Fandom)" suffix if present, normalize each part.
if '(' in rest and rest.rstrip().endswith(')'):
paren_idx = rest.rfind('(')
base = rest[:paren_idx].rstrip()
fandom_part = rest[paren_idx+1:-1].strip() # strip outer parens + any interior whitespace
base = _normalize_display(base)
fandom_part = _normalize_display(fandom_part)
return f"{prefix}:{base} ({fandom_part})"
return f"{prefix}:{_normalize_display(rest)}"
elif kind == 'fandom':
return f"{prefix}:{_normalize_display(rest)}"
return old_name
def upgrade():
conn = op.get_bind()
# Work on a snapshot; we mutate the tag table as we iterate.
rows = conn.execute(
text("SELECT id, name, kind FROM tag WHERE kind IN ('character', 'fandom') ORDER BY id")
).fetchall()
renames = 0
merges = 0
for row in rows:
tag_id = row[0]
old_name = row[1]
kind = row[2]
new_name = _compute_new_name(old_name, kind)
if new_name == old_name:
continue
# Re-check that this tag still exists (may have been deleted as a loser in
# an earlier merge within this loop).
still_exists = conn.execute(
text("SELECT id FROM tag WHERE id = :id"),
{'id': tag_id},
).fetchone()
if not still_exists:
continue
# Look for a collision: same target name + same kind + different id.
target = conn.execute(
text("SELECT id, name FROM tag WHERE name = :new_name AND kind = :kind AND id != :tag_id"),
{'new_name': new_name, 'kind': kind, 'tag_id': tag_id},
).fetchone()
if target is None:
# No collision — plain rename.
conn.execute(
text("UPDATE tag SET name = :new_name WHERE id = :tag_id"),
{'new_name': new_name, 'tag_id': tag_id},
)
renames += 1
else:
# Merge: winner = target (already correctly cased), loser = current row.
winner_id = target[0]
winner_name = target[1] # already == new_name
loser_id = tag_id
loser_name = old_name
# 1. Reassign image_tags, dedup into winner.
conn.execute(
text("""
INSERT INTO image_tags (image_id, tag_id)
SELECT DISTINCT image_id, :winner_id
FROM image_tags
WHERE tag_id = :loser_id
ON CONFLICT DO NOTHING
"""),
{'winner_id': winner_id, 'loser_id': loser_id},
)
conn.execute(
text("DELETE FROM image_tags WHERE tag_id = :loser_id"),
{'loser_id': loser_id},
)
# 2. Reassign any fandom_id FKs pointing at the loser.
conn.execute(
text("UPDATE tag SET fandom_id = :winner_id WHERE fandom_id = :loser_id"),
{'winner_id': winner_id, 'loser_id': loser_id},
)
# 3. tag_reference_embedding: the table is keyed by (tag_name, model_version).
# For each model_version where only the loser has a row, rename; where
# both have rows, keep the winner's and delete the loser's.
conn.execute(
text("""
UPDATE tag_reference_embedding
SET tag_name = :winner_name
WHERE tag_name = :loser_name
AND NOT EXISTS (
SELECT 1 FROM tag_reference_embedding w
WHERE w.tag_name = :winner_name
AND w.model_version = tag_reference_embedding.model_version
)
"""),
{'winner_name': winner_name, 'loser_name': loser_name},
)
conn.execute(
text("DELETE FROM tag_reference_embedding WHERE tag_name = :loser_name"),
{'loser_name': loser_name},
)
# 4. Delete the loser tag row.
conn.execute(
text("DELETE FROM tag WHERE id = :loser_id"),
{'loser_id': loser_id},
)
merges += 1
print(f"normalize_character_fandom_tag_names: {renames} renames, {merges} merges")
def downgrade():
raise NotImplementedError(
"h26041901 normalizes tag names and merges case-only duplicates. "
"Merges are destructive and cannot be reversed automatically. "
"Restore from a pre-migration backup if you need to roll back."
)
@@ -0,0 +1,189 @@
"""Strip underscores from character/fandom/null-kind tag display names and merge
case-only duplicates that collapse onto the same canonical name.
Revision ID: i26041901
Revises: h26041901
Create Date: 2026-04-19
Upgrade: for every row in `tag` with kind in ('character', 'fandom', NULL),
compute the canonical name (character/fandom: title-case + underscore→space;
null: underscore→space only). Rename survivors. On collision, merge image_tags,
fandom_id references, and tag_reference_embedding rows into the already-
correctly-named winner and delete the loser.
Downgrade: raises. Merges are destructive; reverting requires a pre-migration
backup.
"""
from alembic import op
from sqlalchemy import text
revision = 'i26041901'
down_revision = 'h26041901'
branch_labels = None
depends_on = None
def _underscores_to_spaces(s: str) -> str:
"""Mirror of app.utils.tag_names.underscores_to_spaces. Inlined so the
migration doesn't import app code (Alembic runs without the Flask app)."""
return s.replace('_', ' ') if s else s
def _normalize_display(s: str) -> str:
"""Mirror of app.utils.tag_names.normalize_display_name."""
if not s:
return s
s = _underscores_to_spaces(s)
out = []
for p in s.split(' '):
if p == '':
out.append(p)
else:
out.append(p[:1].upper() + p[1:])
return ' '.join(out)
def _compute_new_name(old_name: str, kind) -> str:
"""Return the canonical full name for a given (name, kind) pair.
kind == 'character': title-case base + (optional fandom suffix).
kind == 'fandom': title-case the display portion after the prefix.
kind IS NULL: underscore→space only; case preserved.
"""
if kind == 'character':
if ':' not in old_name:
return old_name
prefix, rest = old_name.split(':', 1)
if '(' in rest and rest.rstrip().endswith(')'):
paren_idx = rest.rfind('(')
base = rest[:paren_idx].rstrip()
fandom_part = rest[paren_idx+1:-1].strip()
return f"{prefix}:{_normalize_display(base)} ({_normalize_display(fandom_part)})"
return f"{prefix}:{_normalize_display(rest)}"
elif kind == 'fandom':
if ':' not in old_name:
return old_name
prefix, rest = old_name.split(':', 1)
return f"{prefix}:{_normalize_display(rest)}"
elif kind is None:
# Null-kind (general): keep case, just strip underscores. Split off any
# prefix defensively — historical rows may or may not carry one.
if ':' in old_name:
prefix, rest = old_name.split(':', 1)
return f"{prefix}:{_underscores_to_spaces(rest)}"
return _underscores_to_spaces(old_name)
return old_name
def upgrade():
conn = op.get_bind()
rows = conn.execute(
text("""
SELECT id, name, kind FROM tag
WHERE kind IN ('character', 'fandom') OR kind IS NULL
ORDER BY id
""")
).fetchall()
renames = 0
merges = 0
for row in rows:
tag_id = row[0]
old_name = row[1]
kind = row[2]
new_name = _compute_new_name(old_name, kind)
if new_name == old_name:
continue
# Re-check that this tag still exists (may have been deleted as a loser
# in an earlier merge within this loop).
still_exists = conn.execute(
text("SELECT id FROM tag WHERE id = :id"),
{'id': tag_id},
).fetchone()
if not still_exists:
continue
# Collision check: same target name + same kind (NULL-safe) + different id.
if kind is None:
target = conn.execute(
text("SELECT id, name FROM tag WHERE name = :new_name AND kind IS NULL AND id != :tag_id"),
{'new_name': new_name, 'tag_id': tag_id},
).fetchone()
else:
target = conn.execute(
text("SELECT id, name FROM tag WHERE name = :new_name AND kind = :kind AND id != :tag_id"),
{'new_name': new_name, 'kind': kind, 'tag_id': tag_id},
).fetchone()
if target is None:
conn.execute(
text("UPDATE tag SET name = :new_name WHERE id = :tag_id"),
{'new_name': new_name, 'tag_id': tag_id},
)
renames += 1
else:
winner_id = target[0]
winner_name = target[1]
loser_id = tag_id
loser_name = old_name
# 1. Reassign image_tags into winner.
conn.execute(
text("""
INSERT INTO image_tags (image_id, tag_id)
SELECT DISTINCT image_id, :winner_id
FROM image_tags
WHERE tag_id = :loser_id
ON CONFLICT DO NOTHING
"""),
{'winner_id': winner_id, 'loser_id': loser_id},
)
conn.execute(
text("DELETE FROM image_tags WHERE tag_id = :loser_id"),
{'loser_id': loser_id},
)
# 2. Reassign fandom_id FKs pointing at the loser.
conn.execute(
text("UPDATE tag SET fandom_id = :winner_id WHERE fandom_id = :loser_id"),
{'winner_id': winner_id, 'loser_id': loser_id},
)
# 3. tag_reference_embedding: keep winner's row per model_version;
# otherwise rename loser's row to the winner.
conn.execute(
text("""
UPDATE tag_reference_embedding
SET tag_name = :winner_name
WHERE tag_name = :loser_name
AND NOT EXISTS (
SELECT 1 FROM tag_reference_embedding w
WHERE w.tag_name = :winner_name
AND w.model_version = tag_reference_embedding.model_version
)
"""),
{'winner_name': winner_name, 'loser_name': loser_name},
)
conn.execute(
text("DELETE FROM tag_reference_embedding WHERE tag_name = :loser_name"),
{'loser_name': loser_name},
)
# 4. Delete the loser tag row.
conn.execute(
text("DELETE FROM tag WHERE id = :loser_id"),
{'loser_id': loser_id},
)
merges += 1
print(f"normalize_underscores_and_general_tags: {renames} renames, {merges} merges")
def downgrade():
raise NotImplementedError(
"i26041901 canonicalizes tag names and merges duplicates that collapse. "
"Merges are destructive and cannot be reversed automatically. "
"Restore from a pre-migration backup if you need to roll back."
)