From a510665a172537f4e38d98725c5c4a3e956e8933 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 08:41:44 -0400 Subject: [PATCH] feat(suggestions): user-managed blocklist for noisy auto-tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a tag_suggestion_blocklist table + service-layer filter so the user can permanently suppress specific canonical tag names from the suggestion stream (WD14 anatomy/composition tags like '1girl', 'breasts', 'nipples' that aren't useful for the discoverability use case this app targets). Data model - migration k26042201: tag_suggestion_blocklist(name TEXT PK, created_at) - model TagSuggestionBlocklistEntry Service - tag_suggestions._blocklisted_names() snapshots the current set - get_suggestions filters merged results before grouping, so both WD14- and embedding-sourced suggestions respect the blocklist - get_bulk_suggestions inherits the filter via its per-image call to get_suggestions API - GET /api/suggestions/blocklist -> {ok, names} - POST /api/suggestions/blocklist -> add one - POST /api/suggestions/blocklist/delete -> remove one - POST /api/suggestions/blocklist/bulk -> replace the whole list (backs the settings textarea save button) UI - modal suggestion chip gets a third action button (⊘) alongside accept (✓) / reject (✕). Clicking it adds the name to the blocklist, logs a rejection for ML feedback on this image, and sweeps every chip on the page carrying that same name. - Settings -> Maintenance -> Suggestion blocklist section with a monospaced textarea (one name per line) + Save. Loads current entries on mount. Co-Authored-By: Claude Opus 4.7 --- app/main.py | 62 +++++++++++++++++++ app/models.py | 13 ++++ app/services/tag_suggestions.py | 16 +++++ app/static/js/view-modal.js | 51 +++++++++++++++ app/static/style.css | 27 ++++++++ app/templates/settings.html | 62 +++++++++++++++++++ .../k26042201_add_tag_suggestion_blocklist.py | 38 ++++++++++++ 7 files changed, 269 insertions(+) create mode 100644 migrations/versions/k26042201_add_tag_suggestion_blocklist.py diff --git a/app/main.py b/app/main.py index 7dd9755..2dca9f3 100644 --- a/app/main.py +++ b/app/main.py @@ -936,6 +936,68 @@ def reject_image_suggestion(image_id): return jsonify({'ok': True}) +# --------------------------- +# Suggestion blocklist — user-managed list of canonical tag names that +# should never appear as auto-suggestions (e.g. noisy WD14 anatomy tags). +# --------------------------- + +@main.get("/api/suggestions/blocklist") +def list_suggestion_blocklist(): + from app.models import TagSuggestionBlocklistEntry + rows = ( + TagSuggestionBlocklistEntry.query + .order_by(TagSuggestionBlocklistEntry.name) + .all() + ) + return jsonify(ok=True, names=[r.name for r in rows]) + + +@main.post("/api/suggestions/blocklist") +def add_suggestion_blocklist(): + from app.models import TagSuggestionBlocklistEntry + payload = request.get_json(force=True, silent=True) or {} + name = (payload.get('name') or '').strip() + if not name: + return jsonify(ok=False, error='missing_name'), 400 + existing = TagSuggestionBlocklistEntry.query.get(name) + if existing is not None: + return jsonify(ok=True, added=False, name=name) + db.session.add(TagSuggestionBlocklistEntry(name=name)) + db.session.commit() + return jsonify(ok=True, added=True, name=name) + + +@main.post("/api/suggestions/blocklist/delete") +def remove_suggestion_blocklist(): + from app.models import TagSuggestionBlocklistEntry + payload = request.get_json(force=True, silent=True) or {} + name = (payload.get('name') or '').strip() + if not name: + return jsonify(ok=False, error='missing_name'), 400 + existing = TagSuggestionBlocklistEntry.query.get(name) + if existing is not None: + db.session.delete(existing) + db.session.commit() + return jsonify(ok=True, removed=True, name=name) + return jsonify(ok=True, removed=False, name=name) + + +@main.post("/api/suggestions/blocklist/bulk") +def bulk_replace_suggestion_blocklist(): + """Replace the entire blocklist with the provided names. Textarea-save path.""" + 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()}) + 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) + + @main.post("/image//tags/add") def add_tag(image_id): """ diff --git a/app/models.py b/app/models.py index 142562b..dfbe02f 100644 --- a/app/models.py +++ b/app/models.py @@ -310,3 +310,16 @@ class TagSuggestionConfig(db.Model): key = db.Column(db.Text, primary_key=True) value = db.Column(db.Text, nullable=False) description = db.Column(db.Text, nullable=True) + + +class TagSuggestionBlocklistEntry(db.Model): + """Canonical tag names the user never wants surfaced as a suggestion. + + Matched by exact string against the canonicalized WD14 name (post + underscores-to-space + title-case for character/fandom). Filter applies + in app.services.tag_suggestions.get_suggestions, after WD14 + embedding + results are merged and before they're grouped for the client. + """ + __tablename__ = "tag_suggestion_blocklist" + name = db.Column(db.Text, primary_key=True) + created_at = db.Column(db.DateTime(timezone=True), server_default=db.func.now(), nullable=False) diff --git a/app/services/tag_suggestions.py b/app/services/tag_suggestions.py index f57a42b..5c6ac0f 100644 --- a/app/services/tag_suggestions.py +++ b/app/services/tag_suggestions.py @@ -102,6 +102,14 @@ def _existing_tag_names() -> set[str]: return {row[0] for row in db.session.query(Tag.name).all()} +def _blocklisted_names() -> set[str]: + """User-managed set of canonical names that must never be surfaced as + suggestions. Stored in tag_suggestion_blocklist; matched exactly against + the post-canonicalization suggestion name.""" + from app.models import TagSuggestionBlocklistEntry + return {row[0] for row in db.session.query(TagSuggestionBlocklistEntry.name).all()} + + def _wd14_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict]: @@ -227,6 +235,14 @@ def get_suggestions( _embedding_tag_suggestions(image_id, cfg, already), ) + # Drop any name the user has blocklisted (e.g. anatomy/composition noise + # from WD14 like '1girl', 'breasts'). Applied after merge so it hides + # the suggestion regardless of whether WD14 or embedding-similarity + # surfaced it. + blocklist = _blocklisted_names() + if blocklist: + merged = [s for s in merged if s['name'] not in blocklist] + if existing_all is None: existing_all = _existing_tag_names() diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index 9084645..02777a1 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -144,19 +144,70 @@ document.addEventListener('DOMContentLoaded', () => { ${pct}% + `; const acceptBtn = chip.querySelector('.sugg-accept'); const rejectBtn = chip.querySelector('.sugg-reject'); + const blockBtn = chip.querySelector('.sugg-block'); if (acceptBtn) { acceptBtn.addEventListener('click', () => acceptSuggestion(chip, imageId)); } if (rejectBtn) { rejectBtn.addEventListener('click', () => rejectSuggestion(chip, imageId)); } + if (blockBtn) { + blockBtn.addEventListener('click', () => blocklistSuggestion(chip, imageId)); + } return chip; } + async function blocklistSuggestion(chip, imageId) { + if (chip.dataset.disabled === 'true') return; + chip.dataset.disabled = 'true'; + chip.style.pointerEvents = 'none'; + const name = chip.dataset.tagName; + try { + // Add to the user's blocklist. + const r = await fetch('/api/suggestions/blocklist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const j = await r.json(); + if (!j.ok) { + chip.dataset.disabled = 'false'; + chip.style.pointerEvents = ''; + if (tagActionFeedback) tagActionFeedback.textContent = `Couldn't blocklist: ${j.error || 'error'}`; + return; + } + // Also log a rejection for this image so the ML feedback signal is + // captured — the user rejected this specific suggestion too. + try { + await fetch(`/image/${imageId}/suggestions/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tag_name: name, + source: chip.dataset.source || '', + confidence: Number(chip.dataset.confidence) || 0, + }), + }); + } catch { + // best-effort — blocklist succeeded is what matters + } + if (tagActionFeedback) tagActionFeedback.textContent = `Blocklisted '${name}'`; + // Remove every chip on the page with this name so the user doesn't have to + // dismiss the same suggestion once per category. + document.querySelectorAll('.suggestion-chip').forEach(el => { + if (el.dataset.tagName === name) animateChipOut(el); + }); + } catch { + chip.dataset.disabled = 'false'; + chip.style.pointerEvents = ''; + } + } + function renderSuggestions(grouped, imageId) { if (!suggestionsSection || !suggestionsList) return; suggestionsList.innerHTML = ''; diff --git a/app/static/style.css b/app/static/style.css index b50f98e..37524bd 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1011,6 +1011,33 @@ header { background: rgba(231, 139, 139, 0.12); border-color: rgba(231, 139, 139, 0.6); } +.suggestion-chip .sugg-btn.sugg-block { + color: #ffb400; + border-color: rgba(255, 180, 0, 0.35); +} +.suggestion-chip .sugg-btn.sugg-block:hover { + background: rgba(255, 180, 0, 0.12); + border-color: rgba(255, 180, 0, 0.6); +} + +/* Settings: blocklist textarea */ +.settings-textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + color: var(--text); + font-family: var(--mono, monospace); + font-size: 0.9rem; + resize: vertical; + min-height: 8rem; +} +.settings-textarea:focus { + outline: none; + border-color: var(--btn-primary); + background: rgba(255, 255, 255, 0.12); +} .suggestion-chip.leaving { opacity: 0; transform: translateX(6px); diff --git a/app/templates/settings.html b/app/templates/settings.html index d1991dd..d059c0e 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -457,9 +457,71 @@ + +
+

Suggestion blocklist

+

+ One canonical tag name per line. Matches are exact against the + 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. +

+ +
+ + +
+
+ {% endif %}