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)
|
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"])
|
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
|
||||||
async def db_stats():
|
async def db_stats():
|
||||||
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
"""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
|
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.
|
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -89,6 +89,53 @@
|
|||||||
@click="onKindCommit"
|
@click="onKindCommit"
|
||||||
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
|
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-5" />
|
||||||
|
|
||||||
|
<p class="text-body-2 mb-2">
|
||||||
|
<strong class="text-error">Reset content tagging.</strong>
|
||||||
|
Deletes every <code>general</code> and <code>character</code> tag and
|
||||||
|
removes them from every image, so you can re-tag from scratch with the
|
||||||
|
auto-suggest. <strong>Fandoms and series (with their page order) are
|
||||||
|
kept</strong>, and each image's saved predictions are untouched — open
|
||||||
|
an image and its suggestions reappear.
|
||||||
|
</p>
|
||||||
|
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||||
|
Irreversible — there's no undo except restoring a DB backup.
|
||||||
|
Back one up first (Settings → Maintenance → Backup).
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="accent" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
:loading="loadingResetPreview"
|
||||||
|
class="mb-3"
|
||||||
|
@click="onResetPreview"
|
||||||
|
>Preview content-tag reset</v-btn>
|
||||||
|
|
||||||
|
<div v-if="resetPreview">
|
||||||
|
<p class="text-body-2 mb-2">
|
||||||
|
<strong>{{ resetPreview.count }}</strong> content tag(s)
|
||||||
|
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
|
||||||
|
({{ k }}: {{ n }})
|
||||||
|
</span>
|
||||||
|
across <strong>{{ resetPreview.applications }}</strong> image
|
||||||
|
application(s).
|
||||||
|
</p>
|
||||||
|
<div v-if="resetPreview.sample_names?.length" class="fc-name-grid mb-3">
|
||||||
|
<span v-for="n in resetPreview.sample_names" :key="n" class="fc-name">
|
||||||
|
{{ n }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
color="error" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-delete-alert"
|
||||||
|
:disabled="!resetPreview.count"
|
||||||
|
:loading="resetCommitting"
|
||||||
|
@click="onResetCommit"
|
||||||
|
>Delete {{ resetPreview.count }} content tag(s) +
|
||||||
|
{{ resetPreview.applications }} application(s)</v-btn>
|
||||||
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -105,6 +152,9 @@ const committing = ref(false)
|
|||||||
const kindPreview = ref(null)
|
const kindPreview = ref(null)
|
||||||
const loadingKindPreview = ref(false)
|
const loadingKindPreview = ref(false)
|
||||||
const kindCommitting = ref(false)
|
const kindCommitting = ref(false)
|
||||||
|
const resetPreview = ref(null)
|
||||||
|
const loadingResetPreview = ref(false)
|
||||||
|
const resetCommitting = ref(false)
|
||||||
|
|
||||||
async function onPreview() {
|
async function onPreview() {
|
||||||
loadingPreview.value = true
|
loadingPreview.value = true
|
||||||
@@ -143,6 +193,25 @@ async function onKindCommit() {
|
|||||||
kindCommitting.value = false
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -127,6 +127,21 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Destructive: deletes ALL general + character tags so the operator can
|
||||||
|
// re-tag from scratch via auto-suggest. fandom + series preserved.
|
||||||
|
async function resetContentTagging({ dryRun = true } = {}) {
|
||||||
|
lastError.value = null
|
||||||
|
try {
|
||||||
|
return await api.post(
|
||||||
|
'/api/admin/tags/reset-content',
|
||||||
|
{ body: { dry_run: dryRun } },
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
lastError.value = e.message
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Task progress polling (taps FC-3i activity dashboard) --------
|
// --- Task progress polling (taps FC-3i activity dashboard) --------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,6 +177,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
tagUsageCount,
|
tagUsageCount,
|
||||||
pruneUnusedTags,
|
pruneUnusedTags,
|
||||||
purgeLegacyTags,
|
purgeLegacyTags,
|
||||||
|
resetContentTagging,
|
||||||
pollTaskUntilDone,
|
pollTaskUntilDone,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -435,3 +435,26 @@ async def test_trigger_vacuum_queues_the_task(client, monkeypatch):
|
|||||||
resp = await client.post("/api/admin/maintenance/vacuum")
|
resp = await client.post("/api/admin/maintenance/vacuum")
|
||||||
assert resp.status_code == 202
|
assert resp.status_code == 202
|
||||||
assert calls == [1]
|
assert calls == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tier-A: POST /tags/reset-content -------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_content_tagging_dry_run_returns_counts(client, db):
|
||||||
|
db.add_all([
|
||||||
|
Tag(name="solo", kind=TagKind.general),
|
||||||
|
Tag(name="naruto", kind=TagKind.character),
|
||||||
|
Tag(name="Naruto", kind=TagKind.fandom),
|
||||||
|
Tag(name="my-series", kind=TagKind.series),
|
||||||
|
])
|
||||||
|
await db.commit()
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/admin/tags/reset-content", json={"dry_run": True}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["count"] == 2
|
||||||
|
assert body["by_kind"] == {"general": 1, "character": 1}
|
||||||
|
# dry-run leaves the rows in place — fandom + series untouched too.
|
||||||
|
assert "deleted" not in body
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from backend.app.models import Artist, ImageRecord, Tag, TagKind
|
from backend.app.models import Artist, ImageRecord, Tag, TagKind
|
||||||
|
from backend.app.models.series_page import SeriesPage
|
||||||
from backend.app.models.tag import image_tag
|
from backend.app.models.tag import image_tag
|
||||||
from backend.app.services import cleanup_service
|
from backend.app.services import cleanup_service
|
||||||
|
|
||||||
@@ -333,3 +334,62 @@ def test_prune_unused_tags_commit_deletes_them(db_sync, tmp_path):
|
|||||||
surviving_names = db_sync.execute(select(Tag.name)).scalars().all()
|
surviving_names = db_sync.execute(select(Tag.name)).scalars().all()
|
||||||
assert "kept" in surviving_names
|
assert "kept" in surviving_names
|
||||||
assert "bye" not in surviving_names
|
assert "bye" not in surviving_names
|
||||||
|
|
||||||
|
|
||||||
|
# --- reset_content_tagging ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_content_tagging_dry_run_counts_without_deleting(db_sync, tmp_path):
|
||||||
|
a = _make_artist(db_sync, slug="rc")
|
||||||
|
img = _make_image(
|
||||||
|
db_sync, artist=a, path=str(tmp_path / "r.jpg"), sha256="1" * 64,
|
||||||
|
)
|
||||||
|
g = _make_tag(db_sync, name="solo", kind=TagKind.general)
|
||||||
|
c = _make_tag(db_sync, name="naruto", kind=TagKind.character)
|
||||||
|
_make_tag(db_sync, name="Naruto", kind=TagKind.fandom)
|
||||||
|
_make_tag(db_sync, name="my-series", kind=TagKind.series)
|
||||||
|
db_sync.execute(image_tag.insert().values([
|
||||||
|
{"image_record_id": img.id, "tag_id": g.id},
|
||||||
|
{"image_record_id": img.id, "tag_id": c.id},
|
||||||
|
]))
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
result = cleanup_service.reset_content_tagging(db_sync, dry_run=True)
|
||||||
|
assert result["count"] == 2
|
||||||
|
assert result["by_kind"] == {"general": 1, "character": 1}
|
||||||
|
assert result["applications"] == 2
|
||||||
|
# Nothing deleted — all 4 tags still present.
|
||||||
|
assert db_sync.execute(select(func.count(Tag.id))).scalar_one() == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_content_tagging_deletes_content_keeps_fandom_series(db_sync, tmp_path):
|
||||||
|
a = _make_artist(db_sync, slug="rc2")
|
||||||
|
img = _make_image(
|
||||||
|
db_sync, artist=a, path=str(tmp_path / "r2.jpg"), sha256="2" * 64,
|
||||||
|
)
|
||||||
|
g = _make_tag(db_sync, name="solo", kind=TagKind.general)
|
||||||
|
c = _make_tag(db_sync, name="naruto", kind=TagKind.character)
|
||||||
|
_make_tag(db_sync, name="Naruto", kind=TagKind.fandom)
|
||||||
|
s = _make_tag(db_sync, name="my-series", kind=TagKind.series)
|
||||||
|
db_sync.execute(image_tag.insert().values([
|
||||||
|
{"image_record_id": img.id, "tag_id": g.id},
|
||||||
|
{"image_record_id": img.id, "tag_id": c.id},
|
||||||
|
{"image_record_id": img.id, "tag_id": s.id}, # series membership
|
||||||
|
]))
|
||||||
|
db_sync.add(SeriesPage(series_tag_id=s.id, image_id=img.id, page_number=1))
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
result = cleanup_service.reset_content_tagging(db_sync, dry_run=False)
|
||||||
|
assert result["deleted"] == 2
|
||||||
|
|
||||||
|
# general + character gone; fandom + series kept.
|
||||||
|
kinds = db_sync.execute(select(Tag.kind)).scalars().all()
|
||||||
|
kind_vals = {k.value if hasattr(k, "value") else str(k) for k in kinds}
|
||||||
|
assert kind_vals == {"fandom", "series"}
|
||||||
|
# series_page ordering survived.
|
||||||
|
assert db_sync.execute(
|
||||||
|
select(func.count()).select_from(SeriesPage)
|
||||||
|
).scalar_one() == 1
|
||||||
|
# Only the series image_tag association survived (content ones cascaded).
|
||||||
|
remaining = db_sync.execute(select(image_tag.c.tag_id)).scalars().all()
|
||||||
|
assert remaining == [s.id]
|
||||||
|
|||||||
Reference in New Issue
Block a user