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
+1
View File
@@ -75,6 +75,7 @@ def make_celery(app=None):
'app.tasks.maintenance.sync_character_fandoms_to_images': {'queue': 'maintenance'},
'app.tasks.maintenance.verify_media_integrity': {'queue': 'maintenance'},
'app.tasks.maintenance.verify_unverified_images': {'queue': 'maintenance'},
'app.tasks.maintenance.apply_auto_accept_predictions': {'queue': 'maintenance'},
# Import tasks - handled by worker (heavy processing)
'app.tasks.import_file.*': {'queue': 'import'},
+10
View File
@@ -655,6 +655,16 @@ def trigger_sync_character_fandoms_to_images():
return redirect(url_for('main.settings', tab='maintenance'))
@main.post('/settings/maintenance/apply-auto-accept-predictions')
def trigger_apply_auto_accept_predictions():
"""Sweep every image, apply general WD14 predictions above the auto-accept
threshold without requiring the user to open each modal. One-shot —
intended to be run after a backfill drains."""
from app.tasks.maintenance import apply_auto_accept_predictions
apply_auto_accept_predictions.apply_async(queue='maintenance')
return redirect(url_for('main.settings', tab='maintenance'))
@main.post('/settings/maintenance/verify-images')
def trigger_verify_unverified_images():
"""Sweep ImageRecords and enqueue per-image structural verification.
+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,
}
+18
View File
@@ -494,6 +494,24 @@
</form>
</section>
<section class="settings-section">
<h3>Apply auto-accepted predictions to all images</h3>
<p class="settings-hint">
Walks every image and applies general-category WD14 predictions
whose confidence is at or above the auto-accept threshold. Same
side-effects as opening each modal individually (creates the
kind='user' tag if needed, attaches it to the image, logs a
SuggestionFeedback rejection-protected record). Idempotent — safe
to re-run. Useful after a fresh ML backfill so high-confidence
tags land without needing to view each image. Skips images flagged
as integrity-corrupt.
</p>
<form action="{{ url_for('main.trigger_apply_auto_accept_predictions') }}" method="post"
onsubmit="return confirm('Apply every above-threshold general-category WD14 prediction to its image? This walks the entire library.');">
<button type="submit" class="btn secondary-btn">Apply auto-accepts library-wide</button>
</form>
</section>
<section class="settings-section">
<h3>Auto-accept threshold (general tags)</h3>
<p class="settings-hint">