diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 4616812..11192a4 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -562,50 +562,33 @@ class TagService: async def _create_protective_aliases( self, src_name: str, src_kind: TagKind, tgt: int ) -> bool: - """One alias per category the tagger has actually emitted for - src_name (so future predictions resolve to target); fall back to - the tag's kind when the tagger never predicted this exact name. - Idempotent — never clobbers a pre-existing operator alias.""" + """Alias (src_name, category) -> tgt so future tagger predictions of + src_name resolve to the merge survivor. Idempotent — never clobbers a + pre-existing operator alias. + + The category is the tag's kind. The tagger's tag_to_category map is + one-to-one (a name has exactly ONE category), and a tag's kind is set + from that category when it's created — so kind already IS the tagger's + category for this name. This used to SELECT DISTINCT category by scanning + every image_record's tagger_predictions JSON (an unindexed full scan that, + on a large library, held a series_page lock for minutes inside the merge + and blocked migration 0040 — operator-flagged 2026-06-07). That scan only + ever rediscovered the kind, so it's gone.""" from ..models.tag_alias import TagAlias - rows = ( - await self.session.execute( - text( - "SELECT DISTINCT " - " (tagger_predictions::jsonb -> :n ->> 'category') " - " AS cat " - "FROM image_record " - "WHERE tagger_predictions IS NOT NULL " - " AND (tagger_predictions::jsonb) ? :n" - ), - {"n": src_name}, + category = src_kind.value if hasattr(src_kind, "value") else str(src_kind) + res = await self.session.execute( + pg_insert(TagAlias) + .values( + alias_string=src_name, + alias_category=category, + canonical_tag_id=tgt, ) - ).all() - categories = {r.cat for r in rows if r.cat} - if not categories: - kind_val = ( - src_kind.value - if hasattr(src_kind, "value") - else str(src_kind) + .on_conflict_do_nothing( + index_elements=["alias_string", "alias_category"] ) - categories = {kind_val} - - created = False - for cat in categories: - res = await self.session.execute( - pg_insert(TagAlias) - .values( - alias_string=src_name, - alias_category=cat, - canonical_tag_id=tgt, - ) - .on_conflict_do_nothing( - index_elements=["alias_string", "alias_category"] - ) - ) - if res.rowcount: - created = True - return created + ) + return bool(res.rowcount) # --------------------------------------------------------------------------- diff --git a/tests/test_tag_merge.py b/tests/test_tag_merge.py index ed772c3..69d6b8a 100644 --- a/tests/test_tag_merge.py +++ b/tests/test_tag_merge.py @@ -320,7 +320,12 @@ async def test_no_alias_when_purely_manual(db): @pytest.mark.asyncio -async def test_alias_per_observed_prediction_category(db): +async def test_protective_alias_uses_tag_kind(db): + # The protective alias category is the tag's KIND — the tagger maps each name + # to exactly one category and a tag's kind is set from it, so kind already IS + # the tagger's category. The merge no longer scans image_record's predictions + # to rediscover it. Even with a (contrived) differing prediction category + # present, the merge writes a single (name, kind) alias. from backend.app.models import ImageRecord from backend.app.models.tag_alias import TagAlias @@ -332,11 +337,6 @@ async def test_alias_per_observed_prediction_category(db): await svc.add_to_image(img, a.id, source="ml_auto") r1 = await db.get(ImageRecord, img) r1.tagger_predictions = { - "predname": {"category": "general", "confidence": 0.9} - } - i2 = await _img(db) - r2 = await db.get(ImageRecord, i2) - r2.tagger_predictions = { "predname": {"category": "copyright", "confidence": 0.8} } await db.flush() @@ -353,7 +353,6 @@ async def test_alias_per_observed_prediction_category(db): ).all() assert {(r.alias_string, r.alias_category) for r in rows} == { ("predname", "general"), - ("predname", "copyright"), } assert all(r.canonical_tag_id == b.id for r in rows)