From f77e75147dfee1f72e7725fafd2b98e336d879b6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 3 Jul 2026 08:34:16 -0400 Subject: [PATCH] feat(tags): system-tag UI markers + full protection sweep (step 4 of #128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI: shield marker + tooltip on TagChip and TagCard; system tags hide rename/merge/delete affordances (chip kebab entirely — set-fandom never applies to their general kind; remove stays, un-tagging is normal use). Aliases stay available: mapping model outputs ONTO a system tag is useful. Directory cards carry is_system. Every destructive path that could take out a system row is now guarded, found by sweeping run 1891s off-by-three failures — each one was a surface that would have eaten the seeded tags: - prune-unused: predicate exempts is_system (they ship with zero applications and matched every unused condition) - reset-content: predicate exempts is_system AND keeps their applications — hygiene flags describe the file, not content tagging - admin tag DELETE: refused with system_tag error - normalize_existing_tags: scan excludes is_system — canonicalization would recase wip -> Wip behind TagService.rename's guard, breaking the name-keyed presentation lookup Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- backend/app/api/admin.py | 4 + backend/app/services/cleanup_service.py | 16 +++- backend/app/services/tag_directory_service.py | 1 + backend/app/services/tag_service.py | 4 + frontend/src/components/discovery/TagCard.vue | 13 ++++ frontend/src/components/modal/TagChip.vue | 16 +++- tests/test_system_tags.py | 75 ++++++++++++++++++- 7 files changed, 121 insertions(+), 8 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index d94615b..e1ef468 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -156,6 +156,10 @@ async def tag_delete(tag_id: int): ) except LookupError: return _bad("not_found", status=404) + except ValueError as exc: + # System tags (#128) — the training-hygiene machinery keys on + # these rows. + return _bad("system_tag", detail=str(exc)) return jsonify(result) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 8708e28..cd7ef99 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -17,7 +17,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any -from sqlalchemy import delete, func, or_, select, update +from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.orm import Session, aliased from ..models import ( @@ -203,6 +203,9 @@ def _unused_tag_conditions() -> list: Tag.id.not_in(used_via_series), Tag.id.not_in(used_via_chapter), Tag.id.not_in(used_via_fandom), + # System tags (#128) ship with zero applications and must survive a + # prune — the training-hygiene machinery keys on the rows. + Tag.is_system.is_(False), ] @@ -402,6 +405,8 @@ def delete_tag(session: Session, *, tag_id: int) -> dict: tag = session.get(Tag, tag_id) if tag is None: raise LookupError(f"tag id not found: {tag_id}") + if tag.is_system: + raise ValueError(f"'{tag.name}' is a system tag and cannot be deleted") associations_count = count_tag_associations(session, tag_id=tag_id) info = {"id": tag.id, "name": tag.name, "kind": tag.kind.value} session.delete(tag) @@ -731,7 +736,10 @@ def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict: heads retrain from whatever the operator re-tags. (The API route gates the live run behind a preview-derived confirm token for exactly this reason.) - PRESERVED: fandom + series tags and their series_page ordering. CASCADE on + PRESERVED: fandom + series tags and their series_page ordering, AND the + system hygiene tags (#128) WITH their applications — the reset re-tags + CONTENT concepts, while wip/banner flags describe the file itself and + re-flagging hundreds of banners by hand would be pure loss. CASCADE on image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting character tags never touches the fandom rows. Irreversible except via DB backup @@ -744,7 +752,9 @@ def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict: "sample_names": [first 50], and on live runs "deleted": total} """ - predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS) + predicate = and_( + Tag.kind.in_(RESETTABLE_TAG_KINDS), Tag.is_system.is_(False) + ) rows = session.execute( select(Tag.id, Tag.name, Tag.kind).where(predicate) ).all() diff --git a/backend/app/services/tag_directory_service.py b/backend/app/services/tag_directory_service.py index 49c55cc..a6b3756 100644 --- a/backend/app/services/tag_directory_service.py +++ b/backend/app/services/tag_directory_service.py @@ -107,6 +107,7 @@ class TagDirectoryService: "kind": tag.kind.value if hasattr(tag.kind, "value") else tag.kind, "fandom_id": tag.fandom_id, "fandom_name": fandom_name, + "is_system": tag.is_system, "image_count": int(image_count), "preview_thumbnails": previews.get(tag.id, []), } diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 3bda769..98e1d9c 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -806,6 +806,10 @@ async def normalize_existing_tags( rows = ( await session.execute( select(Tag.id, Tag.name, Tag.kind, Tag.fandom_id) + # System tags (#128) are exempt: their names are the keys the + # hygiene machinery matches on, and canonicalization would recase + # 'wip' → 'Wip' (this path bypasses TagService.rename's guard). + .where(Tag.is_system.is_(False)) ) ).all() groups = _group_existing_tags(rows) diff --git a/frontend/src/components/discovery/TagCard.vue b/frontend/src/components/discovery/TagCard.vue index 1d1a0b1..9aa7582 100644 --- a/frontend/src/components/discovery/TagCard.vue +++ b/frontend/src/components/discovery/TagCard.vue @@ -13,9 +13,15 @@