feat(tags): system-tag UI markers + full protection sweep (step 4 of #128)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Failing after 3m33s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-03 08:34:16 -04:00
parent 723f023e6a
commit f77e75147d
7 changed files with 121 additions and 8 deletions
+4
View File
@@ -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)
+13 -3
View File
@@ -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()
@@ -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, []),
}
+4
View File
@@ -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)
@@ -13,9 +13,15 @@
<div class="fc-tagcard__name">
<template v-if="!editing">
{{ card.name }}
<v-icon
v-if="card.is_system" size="14" icon="mdi-shield-outline"
class="fc-tagcard__system"
title="System tag — tagged items are excluded from training other concepts"
/>
<span v-if="card.kind === 'character' && card.fandom_name"
class="fc-tagcard__fandom"> {{ card.fandom_name }}</span>
<v-icon
v-if="!card.is_system"
class="fc-tagcard__edit" size="14" icon="mdi-pencil"
@click.stop="startEdit"
/>
@@ -58,12 +64,18 @@
prepend-icon="mdi-tag-multiple"
@click="$emit('aliases', card)"
/>
<!-- System tags (#128): merge-away and delete are refused
server-side the hygiene machinery keys on the row so
don't offer them. Aliases stay (mapping model outputs ONTO
a system tag is useful). -->
<v-list-item
v-if="!card.is_system"
title="Merge with…"
prepend-icon="mdi-call-merge"
@click="$emit('merge-with', card)"
/>
<v-list-item
v-if="!card.is_system"
title="Delete tag"
prepend-icon="mdi-delete"
base-color="error"
@@ -133,6 +145,7 @@ function submit() {
.fc-tagcard__body { padding: 8px 12px; }
.fc-tagcard__name { font-weight: 600; }
.fc-tagcard__fandom { font-weight: 400; opacity: 0.7; }
.fc-tagcard__system { opacity: 0.6; margin-left: 2px; }
.fc-tagcard__meta {
display: flex; align-items: center; justify-content: space-between;
margin-top: 4px;
+13 -3
View File
@@ -10,14 +10,23 @@
@click:close="$emit('remove', tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span
{{ tag.name }}<v-icon
v-if="tag.is_system" end size="x-small" class="fc-tag-chip__system"
title="System tag — tagged items are excluded from training other concepts"
>mdi-shield-outline</v-icon><span
v-if="tag.fandom_id" class="fc-tag-chip__fandom"
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
</v-chip>
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
teleported image modal #711). -->
<KebabMenu class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`">
teleported image modal #711). System tags hide it entirely: rename
is refused server-side (the hygiene machinery keys on the row) and
set-fandom never applies to their general kind. Remove () stays
un-tagging an image is normal use. -->
<KebabMenu
v-if="!tag.is_system"
class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`"
>
<v-list-item @click="$emit('rename', tag)">
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
@@ -64,6 +73,7 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 1px;
}
.fc-tag-chip__system { opacity: 0.6; margin-left: 2px; }
.fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
+73 -2
View File
@@ -7,9 +7,14 @@ mechanism keys on these exact rows. Merge-INTO stays allowed: adopting an
operator's old hygiene tag into the system row is the intended move.
"""
import pytest
from sqlalchemy import select
from sqlalchemy import insert, select
from backend.app.models import Tag, TagKind
from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.cleanup_service import (
prune_unused_tags,
reset_content_tagging,
)
pytestmark = pytest.mark.integration
@@ -65,6 +70,72 @@ async def test_merge_away_system_tag_refused(client, db):
assert "system tag" in body["error"]
@pytest.mark.asyncio
async def test_admin_delete_system_tag_refused(client, db):
wip = await _system_tag(db, "wip")
resp = await client.delete(f"/api/admin/tags/{wip.id}")
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "system_tag"
still = (await db.execute(
select(Tag.id).where(Tag.id == wip.id)
)).scalar_one_or_none()
assert still == wip.id
@pytest.mark.asyncio
async def test_prune_unused_skips_system_tags(db):
"""The seeded system tags have zero applications and would match every
'unused' condition — the shared predicate must exempt them (preview AND
live delete, same conditions)."""
preview = await db.run_sync(
lambda s: prune_unused_tags(s, dry_run=True)
)
assert preview["count"] == 0
assert preview["sample_names"] == []
await db.run_sync(lambda s: prune_unused_tags(s, dry_run=False))
remaining = (await db.execute(
select(Tag.name).where(Tag.is_system.is_(True))
)).scalars().all()
assert set(remaining) == SYSTEM_NAMES
@pytest.mark.asyncio
async def test_reset_content_preserves_system_tags_and_applications(db):
"""Reset re-tags CONTENT concepts; hygiene flags describe the file itself
and survive with their applications."""
wip = await _system_tag(db, "wip")
plain = Tag(name="doomed concept", kind=TagKind.general)
db.add(plain)
img = ImageRecord(
path="/images/r.jpg", sha256="f" * 64, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
await db.flush()
for tid in (wip.id, plain.id):
await db.execute(insert(image_tag).values(
image_record_id=img.id, tag_id=tid, source="manual",
))
await db.commit()
result = await db.run_sync(
lambda s: reset_content_tagging(s, dry_run=False)
)
assert result["deleted"] == 1 # only the plain general tag
names_left = (await db.execute(
select(Tag.name).where(Tag.kind == TagKind.general)
)).scalars().all()
assert "doomed concept" not in names_left
assert "wip" in names_left
wip_apps = (await db.execute(
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == wip.id)
)).scalars().all()
assert wip_apps == [img.id]
@pytest.mark.asyncio
async def test_merge_into_system_tag_allowed(client, db):
wip = await _system_tag(db, "wip")