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:
@@ -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."
|
||||
)
|
||||
Reference in New Issue
Block a user