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
+1
View File
@@ -71,6 +71,7 @@ def make_celery(app=None):
'app.tasks.scan.cleanup_old_tasks': {'queue': 'maintenance'},
'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
'app.tasks.scan.update_batch_stats': {'queue': 'maintenance'},
'app.tasks.maintenance.sweep_blocklisted_tag_from_images': {'queue': 'maintenance'},
# Import tasks - handled by worker (heavy processing)
'app.tasks.import_file.*': {'queue': 'import'},
+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")
+26
View File
@@ -57,6 +57,10 @@ _DEFAULTS = {
'threshold_meta': 0.5,
'threshold_embedding': 0.85,
'min_reference_images': 5.0,
# Above this confidence, general-category suggestions are auto-applied
# to the image by the route layer (see app.main.get_image_suggestions).
# Use 1.1 or greater to effectively disable. Default is conservative.
'auto_accept_general_threshold': 0.95,
'wd14_model_version': 'wd-eva02-large-tagger-v3',
'siglip_model_version': 'siglip-so400m-patch14-384',
}
@@ -246,17 +250,34 @@ def get_suggestions(
if existing_all is None:
existing_all = _existing_tag_names()
# Separate out general-category suggestions above the auto-accept
# threshold. The route layer applies these to the image directly;
# we just identify them here so the service remains side-effect-free.
try:
auto_accept_threshold = float(cfg.get('auto_accept_general_threshold',
_DEFAULTS['auto_accept_general_threshold']))
except (TypeError, ValueError):
auto_accept_threshold = _DEFAULTS['auto_accept_general_threshold']
auto_accept_candidates: list[dict] = []
grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
for s in merged:
if s['category'] not in grouped:
continue # meta / rating / artist / unknown are dropped
s['exists_in_db'] = s['name'] in existing_all
if s['category'] == 'general' and s['confidence'] >= auto_accept_threshold:
auto_accept_candidates.append(s)
continue
grouped[s['category']].append(s)
for cat in grouped:
grouped[cat].sort(key=lambda x: x['confidence'], reverse=True)
grouped[cat] = grouped[cat][:top_k_per_category]
# Stash candidates in a way the shape documents but legacy callers can
# ignore. We keep the three core keys at the top level for backwards
# compat and add a 4th list.
grouped['auto_accept_candidates'] = auto_accept_candidates
return grouped
@@ -308,6 +329,11 @@ def get_bulk_suggestions(
existing_all=existing_all,
)
for category, items in grouped.items():
# `auto_accept_candidates` is returned by get_suggestions for the
# single-image route to apply; skip in bulk (bulk has no single
# image to attach to).
if category == 'auto_accept_candidates':
continue
for item in items:
name = item['name']
stat = tag_stats.get(name)
+98 -2
View File
@@ -242,13 +242,109 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const r = await fetch(`/image/${imageId}/suggestions`);
const j = await r.json();
if (j.ok) renderSuggestions(j.suggestions || {}, imageId);
else clearSuggestions();
if (!j.ok) { clearSuggestions(); return; }
// Render regular suggestions first — it wipes innerHTML. Then prepend
// the auto-accepted block so both coexist.
renderSuggestions(j.suggestions || {}, imageId);
renderAutoAccepted(j.auto_accepted || [], imageId);
if ((j.auto_accepted || []).length > 0) {
suggestionsSection.style.display = '';
}
// Auto-accepted tags are now attached to the image — refresh the
// pill list so the user sees them in the Tags section.
if ((j.auto_accepted || []).length > 0 && typeof loadTags === 'function') {
await loadTags(imageId);
}
} catch {
clearSuggestions();
}
}
function renderAutoAccepted(items, imageId) {
if (!suggestionsList) return;
// Remove any prior auto-accepted block from the current modal view.
suggestionsList.querySelectorAll('.suggestions-auto-accepted').forEach(el => el.remove());
if (!items || items.length === 0) return;
const block = document.createElement('div');
block.className = 'suggestions-auto-accepted';
const label = document.createElement('div');
label.className = 'suggestions-category-label';
label.textContent = `Auto-accepted (${items.length})`;
block.appendChild(label);
const chips = document.createElement('div');
chips.className = 'suggestions-chips';
for (const item of items) {
chips.appendChild(buildAutoAcceptedChip(item, imageId));
}
block.appendChild(chips);
suggestionsList.prepend(block);
}
function buildAutoAcceptedChip(item, imageId) {
const chip = document.createElement('div');
chip.className = 'suggestion-chip suggestion-chip-auto';
chip.dataset.tagId = String(item.id);
chip.dataset.tagName = item.name;
chip.dataset.source = item.source || 'wd14';
chip.dataset.confidence = String(item.confidence || 0);
chip.title = `Auto-accepted from ${item.source || 'wd14'} at ${Math.round((item.confidence || 0) * 100)}%`;
const pct = Math.round((item.confidence || 0) * 100);
chip.innerHTML = `
<span class="sugg-body">
<span class="sugg-label">${escapeHtml(item.display_name || item.name)}</span>
<span class="sugg-confidence">${pct}%</span>
</span>
<button type="button" class="sugg-btn sugg-undo" aria-label="Undo (remove from this image)" title="Remove from this image">✕</button>
<button type="button" class="sugg-btn sugg-block" aria-label="Blocklist this tag" title="Never suggest '${escapeHtml(item.name)}' again — removes from all images">⊘</button>
`;
chip.querySelector('.sugg-undo')?.addEventListener('click', () => undoAutoAccepted(chip, imageId));
chip.querySelector('.sugg-block')?.addEventListener('click', () => blocklistSuggestion(chip, imageId));
return chip;
}
async function undoAutoAccepted(chip, imageId) {
if (chip.dataset.disabled === 'true') return;
chip.dataset.disabled = 'true';
chip.style.pointerEvents = 'none';
const name = chip.dataset.tagName;
try {
// Remove the tag from this image (uses the same remove endpoint the
// manual tag-pill '×' button uses).
const fd = new FormData();
fd.append('name', name);
const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd });
const j = await r.json();
if (!j.ok) {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
if (tagActionFeedback) tagActionFeedback.textContent = `Couldn't undo: ${j.error || 'error'}`;
return;
}
// Log the rejection for ML feedback so future threshold tuning sees this.
try {
await fetch(`/image/${imageId}/suggestions/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: name,
source: chip.dataset.source || 'wd14',
confidence: Number(chip.dataset.confidence) || 0,
}),
});
} catch {
// best-effort
}
if (tagActionFeedback) tagActionFeedback.textContent = `Removed '${name}'`;
animateChipOut(chip);
if (typeof loadTags === 'function') loadTags(imageId);
} catch {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
}
}
function animateChipOut(chip) {
chip.classList.add('leaving');
setTimeout(() => { chip.remove(); }, 200);
+40 -1
View File
@@ -1020,7 +1020,46 @@ header {
border-color: rgba(255, 180, 0, 0.6);
}
/* Settings: blocklist textarea */
/* Auto-accepted suggestion block (top of suggestions section) */
.suggestions-auto-accepted {
margin-bottom: 0.75rem;
padding: 0.5rem;
background: rgba(139, 231, 139, 0.04);
border: 1px solid rgba(139, 231, 139, 0.2);
border-radius: 6px;
}
.suggestions-auto-accepted .suggestions-category-label {
color: #8be78b;
font-weight: 600;
}
.suggestion-chip.suggestion-chip-auto {
border-color: rgba(139, 231, 139, 0.3);
background: rgba(139, 231, 139, 0.08);
}
.suggestion-chip .sugg-btn.sugg-undo {
color: #e78b8b;
border-color: rgba(231, 139, 139, 0.35);
}
.suggestion-chip .sugg-btn.sugg-undo:hover {
background: rgba(231, 139, 139, 0.12);
border-color: rgba(231, 139, 139, 0.6);
}
/* Settings: threshold number input + blocklist textarea */
.settings-input {
padding: 0.4rem 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
font-size: 0.95rem;
}
.settings-input:focus {
outline: none;
border-color: var(--btn-primary);
background: rgba(255, 255, 255, 0.12);
}
.settings-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
+57 -3
View File
@@ -1,4 +1,58 @@
"""Maintenance-queue Celery tasks. Currently empty — the
sync_character_fandoms task was removed on 2026-04-21 as part of the
bare-name refactor (tag.fandom_id is now authoritative; no drift to sync).
"""Maintenance-queue Celery tasks.
Currently holds the blocklist cleanup task that retroactively removes
blocklisted tag names from the library. The earlier sync_character_fandoms
task was removed on 2026-04-21 as part of the bare-name refactor.
"""
import logging
from celery import shared_task
from sqlalchemy import text
from app import db
from app.models import Tag
log = logging.getLogger(__name__)
@shared_task(
name='app.tasks.maintenance.sweep_blocklisted_tag_from_images',
soft_time_limit=60,
time_limit=120,
)
def sweep_blocklisted_tag_from_images(name: str) -> dict:
"""Remove a blocklisted tag name from every image that has it attached,
then delete the Tag row itself.
Scope is limited to kind='user' tags — that's the kind WD14's general
category gets materialized as when accepted, which is the vast majority
of blocklist hits. Character / fandom / artist / series / post / archive
tags sharing the same name are left alone: those are deliberate, curated
entities, and removing them silently because of a blocklist text match
would be destructive.
Returns a summary dict so the Celery result is introspectable.
"""
tag = Tag.query.filter_by(kind='user', name=name).first()
if tag is None:
log.info("sweep_blocklisted_tag_from_images: no kind='user' tag named %r; nothing to do", name)
return {'name': name, 'tag_found': False, 'image_tags_deleted': 0, 'tag_deleted': False}
result = db.session.execute(
text("DELETE FROM image_tags WHERE tag_id = :tid"),
{'tid': tag.id},
)
rowcount = result.rowcount or 0
db.session.delete(tag)
db.session.commit()
log.info(
"sweep_blocklisted_tag_from_images: removed %r (tag_id=%d) from %d images",
name, tag.id, rowcount,
)
return {
'name': name,
'tag_found': True,
'image_tags_deleted': rowcount,
'tag_deleted': True,
}
+70 -2
View File
@@ -458,6 +458,23 @@
</section>
<section class="settings-section">
<h3>Auto-accept threshold (general tags)</h3>
<p class="settings-hint">
When the modal loads suggestions for an image, any general-category
tag with confidence at or above this threshold is auto-applied and
shown in a reviewable "Auto-accepted" block at the top of the
suggestions section. Lower values accept more tags (riskier);
values above 1.0 effectively disable the feature. Default is 0.95.
</p>
<div class="mt-sm">
<input type="number" id="autoAcceptThresholdInput" class="settings-input"
min="0" max="1.1" step="0.01" style="width: 7rem;" />
<button type="button" id="autoAcceptThresholdSaveBtn" class="btn secondary-btn">Save threshold</button>
<span id="autoAcceptThresholdStatus" class="settings-hint" style="margin-left: 0.5rem;"></span>
</div>
</section>
<section class="settings-section">
<h3>Suggestion blocklist</h3>
<p class="settings-hint">
@@ -465,8 +482,11 @@
post-canonicalization WD14 output (e.g. <code>1girl</code>,
<code>breasts</code>). Blocklisted names never appear in the
modal's auto-suggestions — either WD14 or embedding-similarity
sourced. You can also add entries inline by clicking the ⊘ button
on any suggestion chip.
sourced. Adding a name also enqueues a cleanup task that removes
the tag from every image that has it attached (kind='user' only;
deliberate character / artist / fandom tags sharing the same name
are left alone). You can also add entries inline by clicking the
⊘ button on any suggestion chip.
</p>
<textarea id="suggestionBlocklistArea" class="settings-textarea" rows="10"
placeholder="1girl&#10;2girls&#10;breasts&#10;nipples&#10;penis"></textarea>
@@ -521,6 +541,54 @@
saveBtn.addEventListener('click', save);
load();
})();
(function(){
const input = document.getElementById('autoAcceptThresholdInput');
const saveBtn = document.getElementById('autoAcceptThresholdSaveBtn');
const status = document.getElementById('autoAcceptThresholdStatus');
if (!input || !saveBtn) return;
async function load() {
try {
const r = await fetch('/api/suggestions/config/auto-accept-threshold');
const j = await r.json();
if (j.ok) input.value = String(j.threshold);
} catch { /* leave empty */ }
}
async function save() {
saveBtn.disabled = true;
status.textContent = 'Saving…';
const threshold = parseFloat(input.value);
if (isNaN(threshold) || threshold < 0) {
status.textContent = 'Enter a number >= 0 (use > 1 to disable).';
saveBtn.disabled = false;
return;
}
try {
const r = await fetch('/api/suggestions/config/auto-accept-threshold', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threshold }),
});
const j = await r.json();
if (j.ok) {
status.textContent = `Saved (threshold = ${j.threshold}).`;
input.value = String(j.threshold);
} else {
status.textContent = 'Save failed: ' + (j.error || 'unknown');
}
} catch (e) {
status.textContent = 'Save failed: ' + e;
} finally {
saveBtn.disabled = false;
setTimeout(() => { status.textContent = ''; }, 4000);
}
}
saveBtn.addEventListener('click', save);
load();
})();
</script>
{% endif %}