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:
+62
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 2girls breasts nipples 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')
|
||||
Reference in New Issue
Block a user