diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 9785cd7..9e4b2b2 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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 diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 58aab98..f4ee687 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -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. # --------------------------------------------------------------------------- diff --git a/frontend/src/components/settings/TagMaintenanceCard.vue b/frontend/src/components/settings/TagMaintenanceCard.vue index 6ea69c8..db8fc73 100644 --- a/frontend/src/components/settings/TagMaintenanceCard.vue +++ b/frontend/src/components/settings/TagMaintenanceCard.vue @@ -89,6 +89,53 @@ @click="onKindCommit" >Delete {{ kindPreview.count }} legacy tag(s) + + + +

+ Reset content tagging. + Deletes every general and character tag and + removes them from every image, so you can re-tag from scratch with the + auto-suggest. Fandoms and series (with their page order) are + kept, and each image's saved predictions are untouched — open + an image and its suggestions reappear. +

+ + Irreversible — there's no undo except restoring a DB backup. + Back one up first (Settings → Maintenance → Backup). + + + Preview content-tag reset + +
+

+ {{ resetPreview.count }} content tag(s) + + ({{ k }}: {{ n }})  + + across {{ resetPreview.applications }} image + application(s). +

+
+ + {{ n }} + +
+ Delete {{ resetPreview.count }} content tag(s) + + {{ resetPreview.applications }} application(s) +
@@ -105,6 +152,9 @@ const committing = ref(false) const kindPreview = ref(null) const loadingKindPreview = ref(false) const kindCommitting = ref(false) +const resetPreview = ref(null) +const loadingResetPreview = ref(false) +const resetCommitting = ref(false) async function onPreview() { loadingPreview.value = true @@ -143,6 +193,25 @@ async function onKindCommit() { kindCommitting.value = false } } + +async function onResetPreview() { + loadingResetPreview.value = true + try { + resetPreview.value = await store.resetContentTagging({ dryRun: true }) + } finally { + loadingResetPreview.value = false + } +} + +async function onResetCommit() { + resetCommitting.value = true + try { + await store.resetContentTagging({ dryRun: false }) + resetPreview.value = { count: 0, by_kind: {}, applications: 0, sample_names: [] } + } finally { + resetCommitting.value = false + } +}