feat(tags): 'Reset content tagging' admin action
Wipe every general + character tag so the operator can re-tag from scratch via
the Camie auto-suggest, while PRESERVING fandoms, series (+ series_page order),
and each image's stored tagger_predictions (so suggestions repopulate
immediately). One set-based DELETE FROM tag WHERE kind IN ('general','character')
— the five tag-referencing tables all cascade, so applications + aliases +
allowlist + rejections + centroids clear automatically; series tags aren't
deleted so series survive; Tag.fandom_id is SET NULL so fandoms are untouched.
Reuses the established dry-run-preview -> confirm pattern: cleanup_service.
reset_content_tagging() + POST /api/admin/tags/reset-content +
TagMaintenanceCard section with a backup-first warning and a red confirm
showing exact counts (tags by kind + image applications). Irreversible except
via DB backup restore; the wipe only fires when the operator confirms.
Tests: service dry-run counts + live delete preserves fandom/series/series_page
while content tags + their image_tag cascade away; API dry-run wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -224,6 +224,27 @@ async def tags_purge_legacy():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/reset-content", methods=["POST"])
|
||||
async def tags_reset_content():
|
||||
"""Tier-A: delete ALL general + character tags (the Camie-suggestable
|
||||
content vocabulary) so the operator can re-tag from scratch via
|
||||
auto-suggest. fandom + series tags + series_page ordering are preserved,
|
||||
and image tagger_predictions are untouched so suggestions repopulate.
|
||||
dry-run preview returns per-kind counts + applications + a sample so the
|
||||
UI shows exactly what'll go before the operator confirms (dry_run=false).
|
||||
Irreversible except via DB backup restore."""
|
||||
from ..services.cleanup_service import reset_content_tagging
|
||||
|
||||
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: reset_content_tagging(sync_sess, dry_run=dry_run)
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
|
||||
async def db_stats():
|
||||
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
||||
|
||||
@@ -455,6 +455,62 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes
|
||||
# these so the operator can re-tag from scratch via auto-suggest. fandom +
|
||||
# series (and series_page ordering) are deliberately NOT here — they're kept.
|
||||
RESETTABLE_TAG_KINDS = ("general", "character")
|
||||
|
||||
|
||||
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
||||
"""Count (dry_run) or DELETE every general + character tag so the operator
|
||||
can re-tag from scratch via the Camie auto-suggest.
|
||||
|
||||
PRESERVED: fandom + series tags and their series_page ordering, plus every
|
||||
image's image_record.tagger_predictions (untouched) so suggestions
|
||||
repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
|
||||
tag_reference_embedding / 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 restore.
|
||||
|
||||
Returns:
|
||||
{"by_kind": {"general": N, "character": M},
|
||||
"count": total tags,
|
||||
"applications": image_tag rows that will be / were removed,
|
||||
"sample_names": [first 50],
|
||||
and on live runs "deleted": total}
|
||||
"""
|
||||
predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS)
|
||||
rows = session.execute(
|
||||
select(Tag.id, Tag.name, Tag.kind).where(predicate)
|
||||
).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
|
||||
# Headline impact: applications (image_tag rows) that vanish via cascade.
|
||||
applications = session.execute(
|
||||
select(func.count())
|
||||
.select_from(image_tag)
|
||||
.where(image_tag.c.tag_id.in_(select(Tag.id).where(predicate)))
|
||||
).scalar_one()
|
||||
sample = [name for _id, name, _kind in rows[:50]]
|
||||
total = len(rows)
|
||||
result = {
|
||||
"by_kind": by_kind,
|
||||
"count": total,
|
||||
"applications": applications,
|
||||
"sample_names": sample,
|
||||
}
|
||||
if dry_run:
|
||||
return result
|
||||
if total:
|
||||
session.execute(Tag.__table__.delete().where(predicate))
|
||||
session.commit()
|
||||
result["deleted"] = total
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user