feat(suggestions): user-managed blocklist for noisy auto-tags

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 08:41:44 -04:00
parent 3f11dcdc6c
commit a510665a17
7 changed files with 269 additions and 0 deletions
+62
View File
@@ -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/<int:image_id>/tags/add")
def add_tag(image_id):
"""
+13
View File
@@ -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)
+16
View File
@@ -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()
+51
View File
@@ -144,19 +144,70 @@ document.addEventListener('DOMContentLoaded', () => {
<span class="sugg-confidence">${pct}%</span>
</span>
<button type="button" class="sugg-btn sugg-reject" aria-label="Reject">✕</button>
<button type="button" class="sugg-btn sugg-block" aria-label="Never suggest this tag" title="Never suggest '${escapeHtml(suggestion.name)}' again">⊘</button>
`;
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 = '';
+27
View File
@@ -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);
+62
View File
@@ -457,9 +457,71 @@
</form>
</section>
<section class="settings-section">
<h3>Suggestion blocklist</h3>
<p class="settings-hint">
One canonical tag name per line. Matches are exact against the
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.
</p>
<textarea id="suggestionBlocklistArea" class="settings-textarea" rows="10"
placeholder="1girl&#10;2girls&#10;breasts&#10;nipples&#10;penis"></textarea>
<div class="mt-sm">
<button type="button" id="suggestionBlocklistSaveBtn" class="btn secondary-btn">Save blocklist</button>
<span id="suggestionBlocklistStatus" class="settings-hint" style="margin-left: 0.5rem;"></span>
</div>
</section>
</div>
</div>
</div><!-- end maintenance tab panel -->
<script>
(function(){
const area = document.getElementById('suggestionBlocklistArea');
const saveBtn = document.getElementById('suggestionBlocklistSaveBtn');
const status = document.getElementById('suggestionBlocklistStatus');
if (!area || !saveBtn) return;
async function load() {
const r = await fetch('/api/suggestions/blocklist');
const j = await r.json();
if (j.ok) {
area.value = (j.names || []).join('\n');
}
}
async function save() {
saveBtn.disabled = true;
status.textContent = 'Saving…';
const names = area.value.split('\n').map(s => s.trim()).filter(Boolean);
try {
const r = await fetch('/api/suggestions/blocklist/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ names }),
});
const j = await r.json();
if (j.ok) {
area.value = (j.names || []).join('\n');
status.textContent = `Saved ${j.count} entr${j.count === 1 ? 'y' : 'ies'}.`;
} 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 %}
<style>
@@ -0,0 +1,38 @@
"""Create tag_suggestion_blocklist table.
User-managed blocklist of canonical tag names that should never appear as
auto-suggestions. Filters WD14 + embedding results in get_suggestions before
grouping. Name is the primary key (exact-match against the canonicalized
WD14 output).
Revision ID: k26042201
Revises: j26042101
Create Date: 2026-04-22
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = 'k26042201'
down_revision = 'j26042101'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'tag_suggestion_blocklist',
sa.Column('name', sa.Text(), primary_key=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade():
op.drop_table('tag_suggestion_blocklist')