From 5b7e033d0cf0c07d687e53b7196b70eecfc6b4bb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 17:23:25 -0400 Subject: [PATCH] feat(suggestions): auto-accept threshold + retroactive blocklist sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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 --- app/celery_app.py | 1 + app/main.py | 118 ++++++++++++++++++++++++++++++-- app/services/tag_suggestions.py | 26 +++++++ app/static/js/view-modal.js | 100 ++++++++++++++++++++++++++- app/static/style.css | 41 ++++++++++- app/tasks/maintenance.py | 60 +++++++++++++++- app/templates/settings.html | 72 ++++++++++++++++++- 7 files changed, 406 insertions(+), 12 deletions(-) diff --git a/app/celery_app.py b/app/celery_app.py index c0624ca..0bf1df1 100644 --- a/app/celery_app.py +++ b/app/celery_app.py @@ -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'}, diff --git a/app/main.py b/app/main.py index 2dca9f3..7a37511 100644 --- a/app/main.py +++ b/app/main.py @@ -743,9 +743,57 @@ def list_tags(image_id): @main.get("/image//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//tags/add") diff --git a/app/services/tag_suggestions.py b/app/services/tag_suggestions.py index 5c6ac0f..4de9922 100644 --- a/app/services/tag_suggestions.py +++ b/app/services/tag_suggestions.py @@ -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) diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index 02777a1..979bd14 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -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 = ` + + ${escapeHtml(item.display_name || item.name)} + ${pct}% + + + + `; + 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); diff --git a/app/static/style.css b/app/static/style.css index 37524bd..a93a5d9 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; diff --git a/app/tasks/maintenance.py b/app/tasks/maintenance.py index fa6b484..3d3d741 100644 --- a/app/tasks/maintenance.py +++ b/app/tasks/maintenance.py @@ -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, + } diff --git a/app/templates/settings.html b/app/templates/settings.html index d059c0e..da1bc05 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -458,6 +458,23 @@ +
+

Auto-accept threshold (general tags)

+

+ 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. +

+
+ + + +
+
+

Suggestion blocklist

@@ -465,8 +482,11 @@ post-canonicalization WD14 output (e.g. 1girl, breasts). 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.

@@ -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(); +})(); {% endif %}