From 3610ba495f2ffe07a4ff22891c50f27409faeef1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 11 Jun 2026 18:52:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(ml):=20drop=20image=5Frecord.tagger=5Fpred?= =?UTF-8?q?ictions=20=E2=80=94=20image=5Fprediction=20is=20sole=20store=20?= =?UTF-8?q?(#768=20step=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read cutover verified in prod (suggestions + allowlist read image_prediction; backfill complete at 908k rows / 51k images). Removes the old JSON column and everything that fed it: - ImageRecord.tagger_predictions column removed; migration 0046 DROPs it. tagger_model_version kept as the "tagged / current?" signal the backfill sweep reads (needs-tagging check switched to tagger_model_version IS NULL). - tag_and_embed no longer dual-writes the JSON — image_prediction is the only write path. - importer re-import reset drops the JSON line (image_prediction rows are already deleted on re-import). - Retired the one-time #768 backfill task + the #764 prune task, their admin endpoints, and their Maintenance cards (Backfill/PrunePredictionsCard). - Tests seed/assert via image_prediction; stale column refs removed. Disk reclaim is NOT automatic: DROP COLUMN is a catalog change. Run `VACUUM FULL image_record` off-hours afterward to return the ~100 GB to the OS so DB backups go small (#739). image_prediction (~90 MB) stays in pg_dump — it's the source of truth now. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../versions/0046_drop_tagger_predictions.py | 43 ++++ backend/app/api/admin.py | 27 +-- backend/app/models/image_record.py | 6 +- backend/app/models/tag_alias.py | 4 +- backend/app/services/cleanup_service.py | 2 +- backend/app/services/importer.py | 1 - backend/app/services/ml/aliases.py | 2 +- backend/app/tasks/admin.py | 209 ------------------ backend/app/tasks/ml.py | 12 +- .../settings/BackfillPredictionsCard.vue | 50 ----- .../components/settings/MaintenancePanel.vue | 4 - .../settings/PrunePredictionsCard.vue | 58 ----- tests/test_migration_0003.py | 4 +- tests/test_phash_dedup.py | 13 +- tests/test_tag_merge.py | 13 +- tests/test_tasks_admin.py | 69 ------ tests/test_tasks_ml.py | 2 +- 17 files changed, 74 insertions(+), 445 deletions(-) create mode 100644 alembic/versions/0046_drop_tagger_predictions.py delete mode 100644 frontend/src/components/settings/BackfillPredictionsCard.vue delete mode 100644 frontend/src/components/settings/PrunePredictionsCard.vue diff --git a/alembic/versions/0046_drop_tagger_predictions.py b/alembic/versions/0046_drop_tagger_predictions.py new file mode 100644 index 0000000..84e543a --- /dev/null +++ b/alembic/versions/0046_drop_tagger_predictions.py @@ -0,0 +1,43 @@ +"""drop image_record.tagger_predictions (predictions normalized to image_prediction) + +Final step of #768. The per-tag predictions now live in the image_prediction +table (backfilled from the JSON, read by suggestions + allowlist, written by +tag_and_embed). The old JSON column is dead weight — and it's the ~100 GB of +sub-0.70 score tail that bloated image_record's TOAST and broke DB backups +(#739). Dropping it is a fast catalog change; it does NOT reclaim the disk on +its own — run `VACUUM FULL image_record` (or pg_repack) afterward, off-hours, +to return the space to the OS so backups go small. + +DROP COLUMN needs a brief ACCESS EXCLUSIVE lock on image_record; env.py's +lock_timeout guards it, so quiesce the ml-worker if a tagging run is in flight +(see the migration-lock reference). tagger_model_version is kept — it's the +"has this been tagged / is it current?" signal the backfill sweep reads. + +Revision ID: 0046 +Revises: 0045 +Create Date: 2026-06-11 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0046" +down_revision: Union[str, None] = "0045" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column("image_record", "tagger_predictions") + + +def downgrade() -> None: + # Re-add the column empty. The JSON data is not restored (it lived only in + # this column); a downgrade would re-tag or backfill from image_prediction + # separately if ever needed. + op.add_column( + "image_record", + sa.Column("tagger_predictions", sa.JSON(), nullable=True), + ) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index fed2c77..0503331 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -251,7 +251,7 @@ 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. + and image_prediction rows 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.""" @@ -348,28 +348,3 @@ async def trigger_reextract_archives(): async_result = reextract_archive_attachments_task.delay() return jsonify({"task_id": async_result.id, "status": "queued"}), 202 - - -@admin_bp.route("/maintenance/prune-predictions", methods=["POST"]) -async def trigger_prune_predictions(): - """Operator-triggered #764 backfill: drop stored tagger predictions below - the current ml_settings.tagger_store_floor and clamp allowlist thresholds - up to it. Shrinks image_record's TOAST (~100 GB of sub-0.70 scores). - Idempotent + self-resuming; runs on the maintenance_long lane.""" - from ..tasks.admin import prune_low_confidence_predictions_task - - async_result = prune_low_confidence_predictions_task.delay() - return jsonify({"task_id": async_result.id, "status": "queued"}), 202 - - -@admin_bp.route("/maintenance/backfill-predictions", methods=["POST"]) -async def trigger_backfill_predictions(): - """Operator-triggered #768 backfill: copy stored tagger predictions from the - image_record.tagger_predictions JSON into the normalized image_prediction - table. Batched + resumable + idempotent; runs on the maintenance_long lane. - Run this once after deploying migration 0045 (which creates the empty table) - to populate predictions for the existing library.""" - from ..tasks.admin import backfill_image_predictions_task - - async_result = backfill_image_predictions_task.delay() - return jsonify({"task_id": async_result.id, "status": "queued"}), 202 diff --git a/backend/app/models/image_record.py b/backend/app/models/image_record.py index 812a9fa..ac57c9a 100644 --- a/backend/app/models/image_record.py +++ b/backend/app/models/image_record.py @@ -60,8 +60,10 @@ class ImageRecord(Base): ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True ) - # ML fields (populated by FC-2's ml-worker) - tagger_predictions: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # ML fields (populated by FC-2's ml-worker). Per-tag predictions live in the + # normalized image_prediction table (#768) — the tagger_predictions JSON + # column was dropped in migration 0046. tagger_model_version stays as the + # "has this been tagged / is it current?" signal the backfill sweep reads. tagger_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True) # 1152 = SigLIP-so400m embedding dim. Swapping models in FC-2 may require # a column-width migration. diff --git a/backend/app/models/tag_alias.py b/backend/app/models/tag_alias.py index 3a21375..93f4755 100644 --- a/backend/app/models/tag_alias.py +++ b/backend/app/models/tag_alias.py @@ -1,6 +1,6 @@ """TagAlias — maps a model's (name, category) prediction to the operator's -canonical tag. Resolved at suggestion-read time so raw predictions stay -unmolested in image_record.tagger_predictions. +canonical tag. Resolved at suggestion-read time so the raw predictions stored +in image_prediction stay unmolested. """ from datetime import datetime diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 55b161c..6d71dc3 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -574,7 +574,7 @@ def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict: 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 + image's image_prediction rows (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 diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index a1cd9b5..d5d2185 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -1085,7 +1085,6 @@ class Importer: existing.height = height existing.thumbnail_path = None existing.integrity_status = "unknown" - existing.tagger_predictions = None existing.tagger_model_version = None existing.siglip_embedding = None existing.siglip_model_version = None diff --git a/backend/app/services/ml/aliases.py b/backend/app/services/ml/aliases.py index 4d65ecd..479e378 100644 --- a/backend/app/services/ml/aliases.py +++ b/backend/app/services/ml/aliases.py @@ -1,7 +1,7 @@ """Alias resolution + CRUD. A tag_alias maps (model_name, model_category) -> canonical Tag. Resolution -happens at suggestion-read time so raw tagger_predictions stay unmolested. +happens at suggestion-read time so the raw image_prediction rows stay unmolested. """ from collections.abc import Sequence diff --git a/backend/app/tasks/admin.py b/backend/app/tasks/admin.py index 4ca9faf..c6a1b08 100644 --- a/backend/app/tasks/admin.py +++ b/backend/app/tasks/admin.py @@ -207,212 +207,3 @@ def rescan_series_suggestions_task(self, after_post_id: int = 0) -> dict: ) rescan_series_suggestions_task.delay(summary["resume_after_id"]) return summary - - -@celery.task( - name="backend.app.tasks.admin.prune_low_confidence_predictions_task", - bind=True, - autoretry_for=(OperationalError, DBAPIError), - retry_backoff=15, retry_backoff_max=180, max_retries=1, - soft_time_limit=3600, time_limit=4200, # 60 min / 70 min -) -def prune_low_confidence_predictions_task(self, after_id: int = 0) -> dict: - """One-time #764 backfill: drop tagger_predictions entries below the DB - store floor (ml_settings.tagger_store_floor) from existing image_record - rows, and clamp any allowlist min_confidence below the floor up to it. - - The Camie tagger emits ~10k tags; the old 0.05 floor stored the entire - near-zero tail, bloating image_record's TOAST to ~100 GB. This rewrites - each row to the new floor. Keyset by id ASC (restart-safe via after_id); - idempotent — already-pruned rows rewrite to themselves and are skipped. - Rewriting rows generates bloat, so run VACUUM FULL / pg_repack on - image_record afterward to return the disk to the OS. - - The keep predicate (confidence >= floor) mirrors Tagger.infer's store - gate so backfilled rows match what new imports store. Self-resumes on the - soft time limit (re-enqueues from the last committed id).""" - from celery.exceptions import SoftTimeLimitExceeded - from sqlalchemy import select, update - - from ..models import ImageRecord, MLSettings, TagAllowlist - - SessionLocal = _sync_session_factory() - scanned = 0 - pruned = 0 - clamped = 0 - last_id = after_id - try: - with SessionLocal() as session: - floor = session.execute( - select(MLSettings.tagger_store_floor).where(MLSettings.id == 1) - ).scalar_one() - # Clamp allowlist thresholds below the new floor once, on the - # first pass (#764 consumer #4) — a sub-floor min_confidence can't - # apply more permissively now that nothing below it is stored. - if after_id == 0: - clamped = session.execute( - update(TagAllowlist) - .where(TagAllowlist.min_confidence < floor) - .values(min_confidence=floor) - ).rowcount or 0 - session.commit() - - while True: - rows = session.execute( - select(ImageRecord.id, ImageRecord.tagger_predictions) - .where(ImageRecord.id > last_id) - .where(ImageRecord.tagger_predictions.is_not(None)) - .order_by(ImageRecord.id.asc()) - .limit(500) - ).all() - if not rows: - break - for image_id, preds in rows: - scanned += 1 - if not preds: - continue - kept = { - name: p for name, p in preds.items() - if float(p.get("confidence", 0.0)) >= floor - } - if len(kept) != len(preds): - session.execute( - update(ImageRecord) - .where(ImageRecord.id == image_id) - .values(tagger_predictions=kept) - ) - pruned += 1 - session.commit() - last_id = rows[-1].id # advance only after commit, for resume - except SoftTimeLimitExceeded: - log.warning( - "prune_low_confidence_predictions soft-limited at id=%s " - "(scanned=%d pruned=%d) — re-enqueuing", last_id, scanned, pruned, - ) - prune_low_confidence_predictions_task.delay(last_id) - return { - "partial": True, "last_id": last_id, - "scanned": scanned, "pruned": pruned, - } - - log.info( - "prune_low_confidence_predictions complete: floor=%s scanned=%d " - "pruned=%d allowlist_clamped=%d", floor, scanned, pruned, clamped, - ) - return { - "floor": floor, "scanned": scanned, "pruned": pruned, - "allowlist_clamped": clamped, "last_id": last_id, - } - - -# Backfill image_prediction from image_record.tagger_predictions (#768). -# Deliberately NOT done in migration 0045: a single INSERT…SELECT over the -# ~100 GB TOAST is one transaction — invisible until commit, unmonitorable, and -# the MATERIALIZED-CTE form spilled the whole 100 GB to temp on NFS. Instead we -# walk image_record in id WINDOWS, running a bounded INSERT…SELECT over each -# window and committing per chunk: progress is visible (image_prediction grows -# live), it's resumable (re-enqueues from the last committed id), and json_each -# stays in the DB executor streaming each window (no Python-side 100 GB load, no -# materialization). Idempotent via ON CONFLICT DO NOTHING. -_BACKFILL_PRED_CHUNK_SECONDS = 600 # re-enqueue boundary, like normalize_tags -_BACKFILL_PRED_ID_WINDOW = 2000 # image_record ids per committed batch - - -@celery.task( - name="backend.app.tasks.admin.backfill_image_predictions_task", - bind=True, - autoretry_for=(OperationalError, DBAPIError), - retry_backoff=15, retry_backoff_max=180, max_retries=1, - soft_time_limit=1800, time_limit=2400, # 30 min / 40 min -) -def backfill_image_predictions_task(self, after_id: int = 0) -> dict: - """One-time #768 backfill: copy each image_record's stored tagger - predictions (the >= store-floor entries) from the tagger_predictions JSON - into the normalized image_prediction table. - - Batched by id window + committed per chunk so it's monitorable and - resumable; idempotent (ON CONFLICT DO NOTHING) so re-running is safe. - Filters to >= ml_settings.tagger_store_floor (default 0.70) so the table - stays small even from the full pre-prune JSON tail. Guards json_each against - non-object rows (scalar/null tagger_predictions → "cannot deconstruct a - scalar") via an inline CASE. Self-resumes on the soft time limit.""" - import time - - from celery.exceptions import SoftTimeLimitExceeded - from sqlalchemy import func, select, text - - from ..models import ImageRecord, MLSettings - - _INSERT_WINDOW = text( - """ - INSERT INTO image_prediction (image_record_id, raw_name, category, score) - SELECT ir.id, - je.key, - COALESCE(je.value ->> 'category', 'general'), - (je.value ->> 'confidence')::double precision - FROM image_record ir, - json_each( - CASE WHEN json_typeof(ir.tagger_predictions) = 'object' - THEN ir.tagger_predictions - ELSE '{}'::json END - ) je - WHERE ir.id > :lo AND ir.id <= :hi - AND je.value ->> 'confidence' IS NOT NULL - AND (je.value ->> 'confidence')::double precision >= :floor - ON CONFLICT (image_record_id, raw_name) DO NOTHING - """ - ) - - SessionLocal = _sync_session_factory() - started = time.monotonic() - last_id = after_id - inserted = 0 - windows = 0 - with SessionLocal() as session: - floor = session.execute( - select(MLSettings.tagger_store_floor).where(MLSettings.id == 1) - ).scalar_one() - max_id = session.execute( - select(func.max(ImageRecord.id)) - ).scalar() or 0 - - try: - while last_id < max_id: - hi = last_id + _BACKFILL_PRED_ID_WINDOW - res = session.execute( - _INSERT_WINDOW, {"lo": last_id, "hi": hi, "floor": floor} - ) - session.commit() - inserted += res.rowcount or 0 - windows += 1 - last_id = hi # advance only after commit, for resume - if time.monotonic() - started > _BACKFILL_PRED_CHUNK_SECONDS: - log.info( - "backfill_image_predictions chunk done (windows=%d " - "inserted=%d up to id=%d/%d) — re-enqueuing", - windows, inserted, min(last_id, max_id), max_id, - ) - backfill_image_predictions_task.delay(last_id) - return { - "partial": True, "last_id": last_id, "max_id": max_id, - "inserted": inserted, "windows": windows, - } - except SoftTimeLimitExceeded: - log.warning( - "backfill_image_predictions soft-limited at id=%d " - "(inserted=%d) — re-enqueuing", last_id, inserted, - ) - backfill_image_predictions_task.delay(last_id) - return { - "partial": True, "last_id": last_id, "max_id": max_id, - "inserted": inserted, "windows": windows, - } - - log.info( - "backfill_image_predictions complete: floor=%s inserted=%d windows=%d " - "max_id=%d", floor, inserted, windows, max_id, - ) - return { - "floor": floor, "inserted": inserted, "windows": windows, - "max_id": max_id, "last_id": max_id, - } diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index 3d62213..05d1581 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -157,15 +157,15 @@ def tag_and_embed(self, image_id: int) -> dict: ) phase = "persist" - record.tagger_predictions = preds record.tagger_model_version = settings.tagger_model_version record.siglip_embedding = embedding.tolist() record.siglip_model_version = settings.embedder_model_version session.add(record) - # Write the normalized image_prediction rows (#768). Delete-then- - # insert keeps a re-tag idempotent. tagger_store_floor was already - # applied in tagger.infer, so preds is the >=floor set. (Transitional - # dual-write alongside the JSON column until the read cutover lands.) + # Write the normalized image_prediction rows (#768) — the sole home + # for predictions now (image_record.tagger_predictions was dropped in + # migration 0046). Delete-then-insert keeps a re-tag idempotent; + # tagger_store_floor was already applied in tagger.infer, so preds is + # the >=floor set. session.execute( delete(ImagePrediction).where( ImagePrediction.image_record_id == image_id @@ -282,7 +282,7 @@ def backfill(self) -> int: select(ImageRecord.id) .where(ImageRecord.id > last_id) .where( - (ImageRecord.tagger_predictions.is_(None)) + (ImageRecord.tagger_model_version.is_(None)) | ( ImageRecord.tagger_model_version != settings.tagger_model_version diff --git a/frontend/src/components/settings/BackfillPredictionsCard.vue b/frontend/src/components/settings/BackfillPredictionsCard.vue deleted file mode 100644 index f1d6aaa..0000000 --- a/frontend/src/components/settings/BackfillPredictionsCard.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 3baf30e..76b85c5 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -12,8 +12,6 @@ - - @@ -33,8 +31,6 @@ import MLBackfillCard from './MLBackfillCard.vue' import CentroidRecomputeCard from './CentroidRecomputeCard.vue' import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue' import MLThresholdSliders from './MLThresholdSliders.vue' -import BackfillPredictionsCard from './BackfillPredictionsCard.vue' -import PrunePredictionsCard from './PrunePredictionsCard.vue' import AllowlistTable from './AllowlistTable.vue' import AliasTable from './AliasTable.vue' import DbMaintenanceCard from './DbMaintenanceCard.vue' diff --git a/frontend/src/components/settings/PrunePredictionsCard.vue b/frontend/src/components/settings/PrunePredictionsCard.vue deleted file mode 100644 index f3af5dc..0000000 --- a/frontend/src/components/settings/PrunePredictionsCard.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/tests/test_migration_0003.py b/tests/test_migration_0003.py index a46f76c..4fd9fab 100644 --- a/tests/test_migration_0003.py +++ b/tests/test_migration_0003.py @@ -24,8 +24,10 @@ def test_new_tables_registered(): def test_image_record_columns_renamed(): cols = {c.name for c in ImageRecord.__table__.columns} - assert "tagger_predictions" in cols + # tagger_predictions (the renamed wd14_predictions) was later dropped in + # migration 0046 — predictions live in image_prediction now (#768). assert "tagger_model_version" in cols + assert "tagger_predictions" not in cols assert "wd14_predictions" not in cols assert "wd14_model_version" not in cols diff --git a/tests/test_phash_dedup.py b/tests/test_phash_dedup.py index da57ac2..a051616 100644 --- a/tests/test_phash_dedup.py +++ b/tests/test_phash_dedup.py @@ -11,6 +11,7 @@ from PIL import Image from sqlalchemy import func, select from backend.app.models import ( + ImagePrediction, ImageProvenance, ImageRecord, ImportSettings, @@ -118,7 +119,11 @@ def test_smaller_existing_is_superseded(importer, import_layout): image_record_id=eid, tag_id=tag.id, source="manual" ) ) - old.tagger_predictions = {"x": 1} + importer.session.add( + ImagePrediction( + image_record_id=eid, raw_name="x", category="general", score=0.9 + ) + ) old.siglip_embedding = [0.0] * 1152 old.integrity_status = "ok" importer.session.commit() @@ -136,7 +141,11 @@ def test_smaller_existing_is_superseded(importer, import_layout): assert row.path != old_path assert row.phash is not None assert row.integrity_status == "unknown" - assert row.tagger_predictions is None + # #768: re-import clears the normalized predictions too + assert importer.session.execute( + select(func.count()).select_from(ImagePrediction) + .where(ImagePrediction.image_record_id == eid) + ).scalar_one() == 0 assert row.siglip_embedding is None linked = importer.session.execute( select(image_tag.c.tag_id).where( diff --git a/tests/test_tag_merge.py b/tests/test_tag_merge.py index 69d6b8a..924f133 100644 --- a/tests/test_tag_merge.py +++ b/tests/test_tag_merge.py @@ -324,9 +324,7 @@ async def test_protective_alias_uses_tag_kind(db): # The protective alias category is the tag's KIND — the tagger maps each name # to exactly one category and a tag's kind is set from it, so kind already IS # the tagger's category. The merge no longer scans image_record's predictions - # to rediscover it. Even with a (contrived) differing prediction category - # present, the merge writes a single (name, kind) alias. - from backend.app.models import ImageRecord + # to rediscover it — it writes a single (name, kind) alias from the tag kind. from backend.app.models.tag_alias import TagAlias svc = TagService(db) @@ -335,10 +333,6 @@ async def test_protective_alias_uses_tag_kind(db): img = await _img(db) # mark source machine-known so keep_as_alias is True await svc.add_to_image(img, a.id, source="ml_auto") - r1 = await db.get(ImageRecord, img) - r1.tagger_predictions = { - "predname": {"category": "copyright", "confidence": 0.8} - } await db.flush() result = await svc.merge(a.id, b.id) assert result.alias_created is True @@ -381,7 +375,6 @@ async def test_alias_fallback_to_kind_when_no_predictions(db): @pytest.mark.asyncio async def test_alias_create_does_not_clobber_existing(db): - from backend.app.models import ImageRecord from backend.app.models.tag_alias import TagAlias svc = TagService(db) @@ -397,10 +390,6 @@ async def test_alias_create_does_not_clobber_existing(db): ) img = await _img(db) await svc.add_to_image(img, a.id, source="ml_auto") - r = await db.get(ImageRecord, img) - r.tagger_predictions = { - "dupalias": {"category": "general", "confidence": 0.9} - } await db.flush() await svc.merge(a.id, b.id) cid = await db.scalar( diff --git a/tests/test_tasks_admin.py b/tests/test_tasks_admin.py index 80bf4e1..948dd39 100644 --- a/tests/test_tasks_admin.py +++ b/tests/test_tasks_admin.py @@ -42,75 +42,6 @@ def test_bulk_delete_images_task_registered(): ) -def test_prune_low_confidence_predictions_task_registered(): - assert ( - "backend.app.tasks.admin.prune_low_confidence_predictions_task" - in celery.tasks - ) - - -def test_backfill_image_predictions_task_registered(): - assert ( - "backend.app.tasks.admin.backfill_image_predictions_task" - in celery.tasks - ) - - -@pytest.mark.asyncio -async def test_prune_low_confidence_predictions(db_sync, tmp_path): - # #764: drop stored tagger predictions below the store floor (default - # 0.70) and clamp allowlist thresholds up to it. - from backend.app.models import Tag, TagAllowlist, TagKind - from backend.app.tasks.admin import prune_low_confidence_predictions_task - - f0 = tmp_path / "p0.jpg" - f0.write_bytes(b"x") - img0 = ImageRecord( - path=str(f0), sha256=f"{0:064x}", size_bytes=10, mime="image/jpeg", - origin="imported_filesystem", - tagger_predictions={ - "keep_high": {"category": "general", "confidence": 0.92}, - "keep_edge": {"category": "general", "confidence": 0.70}, - "drop_mid": {"category": "general", "confidence": 0.40}, - "drop_tiny": {"category": "general", "confidence": 0.06}, - }, - ) - db_sync.add(img0) - f1 = tmp_path / "p1.jpg" - f1.write_bytes(b"x") - img1 = ImageRecord( - path=str(f1), sha256=f"{1:064x}", size_bytes=10, mime="image/jpeg", - origin="imported_filesystem", - tagger_predictions={"only": {"category": "general", "confidence": 0.99}}, - ) - db_sync.add(img1) - tag = Tag(name="lowthr-tag", kind=TagKind.general) - db_sync.add(tag) - db_sync.flush() - db_sync.add(TagAllowlist(tag_id=tag.id, min_confidence=0.30)) - db_sync.commit() - img0_id, img1_id, tag_id = img0.id, img1.id, tag.id - - result = prune_low_confidence_predictions_task.delay().get() - assert result["floor"] == pytest.approx(0.70) - assert result["pruned"] == 1 # only img0 had sub-floor entries - assert result["allowlist_clamped"] == 1 - - db_sync.expire_all() - p0 = db_sync.execute( - select(ImageRecord.tagger_predictions).where(ImageRecord.id == img0_id) - ).scalar_one() - assert set(p0) == {"keep_high", "keep_edge"} # >=0.70 kept, <0.70 dropped - p1 = db_sync.execute( - select(ImageRecord.tagger_predictions).where(ImageRecord.id == img1_id) - ).scalar_one() - assert set(p1) == {"only"} # already clean — untouched - clamped = db_sync.execute( - select(TagAllowlist.min_confidence).where(TagAllowlist.tag_id == tag_id) - ).scalar_one() - assert clamped == pytest.approx(0.70) - - # --- delete_artist_cascade_task ------------------------------------- diff --git a/tests/test_tasks_ml.py b/tests/test_tasks_ml.py index eb59e47..3577360 100644 --- a/tests/test_tasks_ml.py +++ b/tests/test_tasks_ml.py @@ -44,7 +44,7 @@ async def test_backfill_enqueues_missing(db, monkeypatch): path="/images/n.jpg", sha256="n" * 64, size_bytes=1, mime="image/jpeg", width=1, height=1, origin="imported_filesystem", integrity_status="unknown", - tagger_predictions=None, siglip_embedding=None, + siglip_embedding=None, ) db.add(img) await db.commit() -- 2.52.0