feat(maintenance): library-wide apply of auto-accept predictions

Adds a Celery task + Settings button that walks every image and applies
general-category WD14 predictions at or above the auto-accept threshold,
without needing the user to open each modal. Same side effects as the
modal's per-image auto-accept flow (creates kind='user' tag if needed,
attaches to image, writes SuggestionFeedback decision='accepted').

Lazy-imports app.services.tag_suggestions so the maintenance worker
doesn't pay its overhead unless this task fires. Amortizes _config()
and _existing_tag_names() across the loop. Commits per-batch (100
images) to keep transactions short and let later batches see freshly
created Tag rows.

Skips integrity-flagged images (get_suggestions already returns empty
for those). Idempotent — re-running just no-ops on already-applied
images via the existing 'tag not in img.tags' guard.

Trigger: Settings -> Maintenance -> "Apply auto-accepts library-wide"
(confirm-gated since it walks the entire library).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 13:45:34 -04:00
parent 641a52d1ad
commit 6488dfff1a
4 changed files with 130 additions and 1 deletions
+101 -1
View File
@@ -12,7 +12,7 @@ from celery import shared_task
from sqlalchemy import text
from app import db
from app.models import ImageRecord, ImportTask, Tag
from app.models import ImageRecord, ImportTask, SuggestionFeedback, Tag
from app.services.integrity import verify_path
log = logging.getLogger(__name__)
@@ -336,3 +336,103 @@ def sweep_blocklisted_tag_from_images(name: str) -> dict:
'image_tags_deleted': rowcount,
'tag_deleted': True,
}
@shared_task(
name='app.tasks.maintenance.apply_auto_accept_predictions',
soft_time_limit=1800,
time_limit=2700,
)
def apply_auto_accept_predictions(batch_size: int = 100) -> dict:
"""Walk every image and apply general-category WD14 predictions whose
confidence is at or above the auto_accept_general_threshold config.
Mirrors the side effects of the modal's per-image auto-accept flow
(`get_image_suggestions`): for each candidate, find-or-create a
kind='user' Tag, attach it to the image, and log a SuggestionFeedback
row with decision='accepted'. Idempotent — re-running just skips
images whose existing tags + per-image rejections already cover the
candidates.
Designed for one-shot library-wide application after a backfill
completes, so the user doesn't have to open every modal to land
high-confidence WD14 tags. Skips integrity-flagged rows (the suggestion
service already returns empty for those) and amortizes the config
fetch + Tag-table scan across the loop.
"""
# Lazy-import the suggestion service so the maintenance worker doesn't
# pay its overhead on unrelated tasks.
from app.services.tag_suggestions import (
_config, _existing_tag_names, get_suggestions,
)
cfg = _config()
existing_all = _existing_tag_names()
scanned = 0
images_with_applies = 0
tags_applied = 0
last_id = 0
while True:
rows = (
ImageRecord.query
.filter(ImageRecord.id > last_id)
.filter(ImageRecord.integrity_status.in_(('ok', 'unknown')))
.order_by(ImageRecord.id.asc())
.limit(batch_size)
.all()
)
if not rows:
break
for img in rows:
scanned += 1
grouped = get_suggestions(img.id, cfg=cfg, existing_all=existing_all)
candidates = grouped.get('auto_accept_candidates') or []
if not candidates:
continue
applied_for_image = 0
for cand in candidates:
name = cand['name']
tag = Tag.query.filter_by(kind='user', name=name).first()
if tag is None:
tag = Tag(kind='user', name=name)
db.session.add(tag)
db.session.flush()
if tag not in img.tags:
img.tags.append(tag)
applied_for_image += 1
db.session.add(SuggestionFeedback(
image_id=img.id,
tag_name=name,
suggestion_source=cand.get('source', 'wd14'),
confidence=cand.get('confidence', 0.0),
decision='accepted',
))
if applied_for_image:
images_with_applies += 1
tags_applied += applied_for_image
# Commit once per batch — keeps transactions short and lets the
# next batch see freshly-created Tag rows.
db.session.commit()
last_id = rows[-1].id
log.info(
"apply_auto_accept_predictions: progress scanned=%d "
"images_with_applies=%d tags_applied=%d last_id=%d",
scanned, images_with_applies, tags_applied, last_id,
)
log.info(
"apply_auto_accept_predictions: done scanned=%d "
"images_with_applies=%d tags_applied=%d",
scanned, images_with_applies, tags_applied,
)
return {
'scanned': scanned,
'images_with_applies': images_with_applies,
'tags_applied': tags_applied,
}