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:
+114
-4
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user