feat(ml): drop image_record.tagger_predictions — image_prediction is sole store (#768 step 3)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m14s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 18:52:33 -04:00
parent 65211a3f2f
commit 3610ba495f
17 changed files with 74 additions and 445 deletions
+11 -2
View File
@@ -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(