From 33efba8bf52cf33303a1cec5aecaa1778aad7aeb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 16 May 2026 13:38:27 -0400 Subject: [PATCH] feat(tags): merge repoints allowlist, embedding, aliases, fandom children Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/tag_service.py | 36 +++++++++-- tests/test_tag_merge.py | 98 ++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index e79df6c..e2d1310 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -368,18 +368,46 @@ class TagService: ) async def _repoint_allowlist(self, src: int, tgt: int) -> None: - pass + tgt_has = await self.session.scalar( + select(exists().where(TagAllowlist.tag_id == tgt)) + ) + if tgt_has: + await self.session.execute( + text("DELETE FROM tag_allowlist WHERE tag_id = :src"), + {"src": src}, + ) + else: + await self.session.execute( + update(TagAllowlist) + .where(TagAllowlist.tag_id == src) + .values(tag_id=tgt) + ) async def _repoint_embedding(self, src: int) -> None: - pass + await self.session.execute( + text( + "DELETE FROM tag_reference_embedding WHERE tag_id = :src" + ), + {"src": src}, + ) async def _repoint_aliases(self, src: int, tgt: int) -> None: - pass + from ..models.tag_alias import TagAlias + + await self.session.execute( + update(TagAlias) + .where(TagAlias.canonical_tag_id == src) + .values(canonical_tag_id=tgt) + ) async def _repoint_fandom_children( self, src: int, tgt: int, src_kind: TagKind ) -> None: - pass + if src_kind != TagKind.fandom: + return + await self.session.execute( + update(Tag).where(Tag.fandom_id == src).values(fandom_id=tgt) + ) async def _create_protective_aliases( self, src_name: str, src_kind: TagKind, tgt: int diff --git a/tests/test_tag_merge.py b/tests/test_tag_merge.py index b9d0c2f..c5e70df 100644 --- a/tests/test_tag_merge.py +++ b/tests/test_tag_merge.py @@ -1,5 +1,5 @@ import pytest -from sqlalchemy import select +from sqlalchemy import func, select from backend.app.models import Tag, TagKind, image_tag from backend.app.models.tag_allowlist import TagAllowlist @@ -206,3 +206,99 @@ async def test_merge_dedups_suggestion_rejections(db): ) ) ).first() is None + + +@pytest.mark.asyncio +async def test_merge_allowlist_target_has_keeps_target_threshold(db): + svc = TagService(db) + a = await svc.find_or_create("SrcAL", TagKind.general) + b = await svc.find_or_create("TgtAL", TagKind.general) + db.add(TagAllowlist(tag_id=a.id, min_confidence=0.5)) + db.add(TagAllowlist(tag_id=b.id, min_confidence=0.9)) + await db.flush() + await svc.merge(a.id, b.id) + rows = (await db.execute(select(TagAllowlist))).scalars().all() + assert len(rows) == 1 + assert rows[0].tag_id == b.id + assert rows[0].min_confidence == 0.9 + + +@pytest.mark.asyncio +async def test_merge_allowlist_source_only_moves_to_target(db): + svc = TagService(db) + a = await svc.find_or_create("SrcAL2", TagKind.general) + b = await svc.find_or_create("TgtAL2", TagKind.general) + db.add(TagAllowlist(tag_id=a.id, min_confidence=0.42)) + await db.flush() + await svc.merge(a.id, b.id) + rows = (await db.execute(select(TagAllowlist))).scalars().all() + assert len(rows) == 1 + assert rows[0].tag_id == b.id + assert rows[0].min_confidence == 0.42 + + +@pytest.mark.asyncio +async def test_merge_deletes_source_embedding(db): + from backend.app.models.tag_reference_embedding import ( + TagReferenceEmbedding, + ) + + svc = TagService(db) + a = await svc.find_or_create("SrcEmb", TagKind.general) + b = await svc.find_or_create("TgtEmb", TagKind.general) + db.add( + TagReferenceEmbedding( + tag_id=a.id, + embedding=[0.0] * 1152, + reference_count=1, + model_version="v", + ) + ) + await db.flush() + await svc.merge(a.id, b.id) + db.expire_all() # merge() uses Core DML; drop stale identity-map state + remaining = await db.scalar( + select(func.count()) + .select_from(TagReferenceEmbedding) + .where(TagReferenceEmbedding.tag_id == a.id) + ) + assert remaining == 0 + + +@pytest.mark.asyncio +async def test_merge_repoints_existing_aliases(db): + from backend.app.models.tag_alias import TagAlias + + svc = TagService(db) + a = await svc.find_or_create("SrcAlias", TagKind.general) + b = await svc.find_or_create("TgtAlias", TagKind.general) + db.add( + TagAlias( + alias_string="oldpred", + alias_category="general", + canonical_tag_id=a.id, + ) + ) + await db.flush() + await svc.merge(a.id, b.id) + db.expire_all() # merge() uses Core DML; drop stale identity-map state + al = ( + await db.execute( + select(TagAlias).where(TagAlias.alias_string == "oldpred") + ) + ).scalar_one() + assert al.canonical_tag_id == b.id + + +@pytest.mark.asyncio +async def test_merge_fandom_reparents_children(db): + svc = TagService(db) + f_src = await svc.find_or_create("FandomSrc", TagKind.fandom) + f_tgt = await svc.find_or_create("FandomTgt", TagKind.fandom) + child = await svc.find_or_create( + "ChildChar", TagKind.character, fandom_id=f_src.id + ) + await svc.merge(f_src.id, f_tgt.id) + db.expire_all() # merge() uses Core DML; drop stale identity-map state + refreshed = await db.get(Tag, child.id) + assert refreshed.fandom_id == f_tgt.id