feat(suggestions): auto-accept threshold + retroactive blocklist sweep

Two capabilities that work together to turn the suggestion system from
a 'review every noisy chip' UX into a 'curated tags only, configurable
auto-apply' UX for a solo-user library.

Auto-accept threshold
  Service (tag_suggestions.py):
    - New default auto_accept_general_threshold = 0.95 in _DEFAULTS
    - get_suggestions splits general-category hits at/above the threshold
      into a separate auto_accept_candidates list; the core 'character'/
      'copyright'/'general' keys stay the same shape for existing callers
    - get_bulk_suggestions ignores the new key (no side effects in bulk)
  Route (main.py GET /image/<id>/suggestions):
    - For each candidate: find-or-create Tag(kind='user', name=name), attach
      to image, log SuggestionFeedback(source='wd14'|etc, decision='accepted')
    - Response adds 'auto_accepted: [{id, name, display_name, kind,
      confidence, source}, ...]' so the modal can review + undo
  Config endpoints (main.py):
    GET  /api/suggestions/config/auto-accept-threshold
    POST /api/suggestions/config/auto-accept-threshold {threshold}
    Values > 1.0 effectively disable the feature
  UI (view-modal.js):
    - New renderAutoAccepted block at top of suggestions section with
      green-tinted chips carrying ✕ (undo for this image, logs rejection)
      and ⊘ (blocklist + remove from all images)
    - loadSuggestions refreshes the tag pill list when auto_accepted has
      items so the user sees them in the Tags section too
  Settings:
    - Maintenance tab gains a number input + save button backed by
      /api/suggestions/config/auto-accept-threshold

Retroactive blocklist sweep
  New Celery task app.tasks.maintenance.sweep_blocklisted_tag_from_images
  on the maintenance queue. Enqueued automatically when a name is added
  via POST /api/suggestions/blocklist or appears new in the bulk replace.
  Task body: finds kind='user' Tag matching the name, deletes its
  image_tags rows, deletes the Tag itself. Scope limited to kind='user'
  so deliberate character/fandom/artist tags sharing a blocklisted name
  aren't silently destroyed.

Verification (local dev):
  - Threshold GET/POST round-trip correct (default 0.95, persisted in
    tag_suggestion_config)
  - Image 633 at threshold=0.9: 4 general tags auto-applied, each with
    a SuggestionFeedback row, returned in auto_accepted
  - Blocklist add of 'sweeptestonly' with an attached test tag: Redis
    maintenance queue depth = 1, task body removes 1 image_tags row +
    the Tag row
  - get_bulk_suggestions still works (auto_accept_candidates key skipped)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 17:23:25 -04:00
parent a510665a17
commit 5b7e033d0c
7 changed files with 406 additions and 12 deletions
+114 -4
View File
@@ -743,9 +743,57 @@ def list_tags(image_id):
@main.get("/image/<int:image_id>/suggestions")
def get_image_suggestions(image_id):
"""Return the modal's suggestion shape AND auto-apply general-category
suggestions above the auto_accept_general_threshold config. Each
auto-applied tag is attached to the image and a SuggestionFeedback row
logged, and the applied tags are surfaced in `auto_accepted` so the UI
can show them for review.
"""
from app.services.tag_suggestions import get_suggestions
suggestions = get_suggestions(image_id)
return jsonify({'ok': True, 'suggestions': suggestions})
from app.models import SuggestionFeedback
img = ImageRecord.query.get(image_id)
if img is None:
return jsonify({'ok': False, 'error': 'image_not_found'}), 404
grouped = get_suggestions(image_id)
candidates = grouped.pop('auto_accept_candidates', []) or []
auto_accepted = []
for cand in candidates:
name = cand['name']
# General-category auto-accepts become kind='user' tags (same shape
# as a manual accept of a general suggestion via accept_image_suggestion).
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)
db.session.add(SuggestionFeedback(
image_id=image_id,
tag_name=name,
suggestion_source=cand.get('source', 'wd14'),
confidence=cand.get('confidence', 0.0),
decision='accepted',
))
auto_accepted.append({
'id': tag.id,
'name': tag.name,
'display_name': tag.display_name,
'kind': tag.kind,
'confidence': cand.get('confidence', 0.0),
'source': cand.get('source', 'wd14'),
})
if auto_accepted:
db.session.commit()
return jsonify({
'ok': True,
'suggestions': grouped,
'auto_accepted': auto_accepted,
})
@main.post("/api/suggestions/bulk")
@@ -952,6 +1000,19 @@ def list_suggestion_blocklist():
return jsonify(ok=True, names=[r.name for r in rows])
def _enqueue_blocklist_sweep(name: str) -> None:
"""Enqueue the Celery task that removes blocklisted name from image_tags.
Best-effort: if enqueue fails, log and continue so the blocklist add
itself still succeeds."""
try:
from app.tasks.maintenance import sweep_blocklisted_tag_from_images
sweep_blocklisted_tag_from_images.apply_async(args=[name], queue='maintenance')
except Exception as e:
current_app.logger.warning(
"failed to enqueue blocklist sweep for %r: %s", name, e,
)
@main.post("/api/suggestions/blocklist")
def add_suggestion_blocklist():
from app.models import TagSuggestionBlocklistEntry
@@ -964,6 +1025,9 @@ def add_suggestion_blocklist():
return jsonify(ok=True, added=False, name=name)
db.session.add(TagSuggestionBlocklistEntry(name=name))
db.session.commit()
# Fire-and-forget retroactive cleanup: remove the now-blocklisted name
# from every image that still has it attached.
_enqueue_blocklist_sweep(name)
return jsonify(ok=True, added=True, name=name)
@@ -982,20 +1046,66 @@ def remove_suggestion_blocklist():
return jsonify(ok=True, removed=False, name=name)
@main.get("/api/suggestions/config/auto-accept-threshold")
def get_auto_accept_threshold():
from app.models import TagSuggestionConfig
row = TagSuggestionConfig.query.filter_by(key='auto_accept_general_threshold').first()
try:
value = float(row.value) if row else 0.95
except (TypeError, ValueError):
value = 0.95
return jsonify(ok=True, threshold=value)
@main.post("/api/suggestions/config/auto-accept-threshold")
def set_auto_accept_threshold():
from app.models import TagSuggestionConfig
payload = request.get_json(force=True, silent=True) or {}
try:
threshold = float(payload.get('threshold'))
except (TypeError, ValueError):
return jsonify(ok=False, error='invalid_threshold'), 400
if threshold < 0:
threshold = 0.0
row = TagSuggestionConfig.query.filter_by(key='auto_accept_general_threshold').first()
if row is None:
row = TagSuggestionConfig(
key='auto_accept_general_threshold',
value=str(threshold),
description='General-category suggestions at or above this confidence are auto-applied by the modal suggestions load. Set to 1.1 or greater to disable.',
)
db.session.add(row)
else:
row.value = str(threshold)
db.session.commit()
return jsonify(ok=True, threshold=threshold)
@main.post("/api/suggestions/blocklist/bulk")
def bulk_replace_suggestion_blocklist():
"""Replace the entire blocklist with the provided names. Textarea-save path."""
"""Replace the entire blocklist with the provided names. Textarea-save path.
Enqueues a cleanup sweep for every name that wasn't already blocklisted —
names that were already in the list skip the sweep since they've presumably
already been swept when first added."""
from app.models import TagSuggestionBlocklistEntry
payload = request.get_json(force=True, silent=True) or {}
names = payload.get('names', [])
if not isinstance(names, list):
return jsonify(ok=False, error='names_must_be_list'), 400
cleaned = sorted({str(n).strip() for n in names if str(n) and str(n).strip()})
previous = {row.name for row in TagSuggestionBlocklistEntry.query.all()}
newly_added = [n for n in cleaned if n not in previous]
TagSuggestionBlocklistEntry.query.delete()
for name in cleaned:
db.session.add(TagSuggestionBlocklistEntry(name=name))
db.session.commit()
return jsonify(ok=True, count=len(cleaned), names=cleaned)
for name in newly_added:
_enqueue_blocklist_sweep(name)
return jsonify(ok=True, count=len(cleaned), names=cleaned, newly_added=len(newly_added))
@main.post("/image/<int:image_id>/tags/add")