feat(provenance+tags): collapse provenance descriptions; purge retired-kind tags

Operator-flagged 2026-05-28, two asks.

**1. Provenance posts ate the panel.** Each post card rendered its full
description (180px scroll box) inline, so a few posts pushed everything
else off-screen. ProvenancePanel now collapses the description by
default behind a per-post "Show description ▾ / Hide ▴" toggle (state
keyed by provenance_id, reset when the viewed image changes). Cards stay
compact — platform/date/title/meta/actions — and the operator expands
only the descriptions they want.

**2. Purge tags of retired/system kinds.** The IR migration left
`archive`/`post`/`artist`-kind tags (e.g. `BlenderKnight:Hannah_BJ_Loops`)
that FC no longer creates — the tag input only makes
character/fandom/series/general, and provenance + artists are their own
systems now. (meta/rating were already hard-deleted by alembic 0023.)

- cleanup_service.purge_tags_by_kind(kinds=PURGEABLE_TAG_KINDS) — counts
  (dry_run) or deletes tags whose kind ∈ (archive, post, artist).
  CASCADE clears the image_tag / alias / allowlist / etc. rows.
- POST /api/admin/tags/purge-retired-kinds (Tier-A, dry-run preview
  returns per-kind counts + sample names — the preview IS the
  verification of exactly what'll be deleted before committing).
- Tag Maintenance card gets a second section: "Preview retired-kind
  tags" → per-kind breakdown + sample → "Delete N retired-kind tag(s)".

Tests: dry-run counts by kind (general survives), commit deletes only
the retired kinds (general + character survive, retired count → 0).

NOTE: a dry-run preview will show exactly which kinds/counts are
present. If the operator's noisy tags turn out to be `general` (e.g. an
IR `source:patreon` that fell back to general during migration), they
won't be caught by the kind purge — the preview makes that visible so
we can decide on a name-based pass separately.
This commit is contained in:
2026-05-28 01:12:20 -04:00
parent 104cac5dca
commit e1fc65bd1b
6 changed files with 203 additions and 2 deletions
+19
View File
@@ -206,3 +206,22 @@ async def tags_prune_unused():
)
)
return jsonify(result)
@admin_bp.route("/tags/purge-retired-kinds", methods=["POST"])
async def tags_purge_retired_kinds():
"""Tier-A: delete tags whose kind is a system/legacy kind FC no
longer creates (archive / post / artist — the IR-migration leftovers
like `BlenderKnight:Hannah_BJ_Loops`). dry-run preview returns
per-kind counts + a sample so the UI can show exactly what'll go
before the operator confirms with dry_run=false."""
from ..services.cleanup_service import purge_tags_by_kind
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
async with get_session() as session:
result = await session.run_sync(
lambda sync_sess: purge_tags_by_kind(sync_sess, dry_run=dry_run)
)
return jsonify(result)
+42
View File
@@ -369,6 +369,48 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
return {"deleted": len(ids), "sample_names": sample}
# System/legacy tag kinds that FC no longer creates: the tag input only
# makes character/fandom/series/general; provenance (post grouping) and
# archive membership are their own systems now, and artists are
# first-class Artist/Source rows. These linger only on IR-migrated rows.
# meta/rating were hard-deleted by alembic 0023, so they're not listed.
PURGEABLE_TAG_KINDS = ("archive", "post", "artist")
def purge_tags_by_kind(
session: Session, *, kinds: tuple[str, ...] = PURGEABLE_TAG_KINDS,
dry_run: bool = False,
) -> dict:
"""Count (dry_run) or delete all tags whose kind is in `kinds`.
CASCADE on image_tag / tag_alias / tag_allowlist /
tag_reference_embedding / tag_suggestion_rejection / series_page
clears the related rows on the parent DELETE.
Returns:
{"by_kind": {kind: count, ...}, "count": total,
"sample_names": [first 50], and on live runs "deleted": total}
"""
rows = session.execute(
select(Tag.id, Tag.name, Tag.kind).where(Tag.kind.in_(kinds))
).all()
by_kind: dict[str, int] = {}
for _id, _name, kind in rows:
key = kind.value if hasattr(kind, "value") else str(kind)
by_kind[key] = by_kind.get(key, 0) + 1
sample = [name for _id, name, _kind in rows[:50]]
total = len(rows)
if dry_run:
return {"by_kind": by_kind, "count": total, "sample_names": sample}
if total:
session.execute(Tag.__table__.delete().where(Tag.kind.in_(kinds)))
session.commit()
return {
"by_kind": by_kind, "count": total, "deleted": total,
"sample_names": sample,
}
# ---------------------------------------------------------------------------
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
# ---------------------------------------------------------------------------