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})
|
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")
|
@main.post("/image/<int:image_id>/tags/add")
|
||||||
def add_tag(image_id):
|
def add_tag(image_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -310,3 +310,16 @@ class TagSuggestionConfig(db.Model):
|
|||||||
key = db.Column(db.Text, primary_key=True)
|
key = db.Column(db.Text, primary_key=True)
|
||||||
value = db.Column(db.Text, nullable=False)
|
value = db.Column(db.Text, nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
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()}
|
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]:
|
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),
|
_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:
|
if existing_all is None:
|
||||||
existing_all = _existing_tag_names()
|
existing_all = _existing_tag_names()
|
||||||
|
|
||||||
|
|||||||
@@ -144,19 +144,70 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<span class="sugg-confidence">${pct}%</span>
|
<span class="sugg-confidence">${pct}%</span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="sugg-btn sugg-reject" aria-label="Reject">✕</button>
|
<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 acceptBtn = chip.querySelector('.sugg-accept');
|
||||||
const rejectBtn = chip.querySelector('.sugg-reject');
|
const rejectBtn = chip.querySelector('.sugg-reject');
|
||||||
|
const blockBtn = chip.querySelector('.sugg-block');
|
||||||
if (acceptBtn) {
|
if (acceptBtn) {
|
||||||
acceptBtn.addEventListener('click', () => acceptSuggestion(chip, imageId));
|
acceptBtn.addEventListener('click', () => acceptSuggestion(chip, imageId));
|
||||||
}
|
}
|
||||||
if (rejectBtn) {
|
if (rejectBtn) {
|
||||||
rejectBtn.addEventListener('click', () => rejectSuggestion(chip, imageId));
|
rejectBtn.addEventListener('click', () => rejectSuggestion(chip, imageId));
|
||||||
}
|
}
|
||||||
|
if (blockBtn) {
|
||||||
|
blockBtn.addEventListener('click', () => blocklistSuggestion(chip, imageId));
|
||||||
|
}
|
||||||
return chip;
|
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) {
|
function renderSuggestions(grouped, imageId) {
|
||||||
if (!suggestionsSection || !suggestionsList) return;
|
if (!suggestionsSection || !suggestionsList) return;
|
||||||
suggestionsList.innerHTML = '';
|
suggestionsList.innerHTML = '';
|
||||||
|
|||||||
@@ -1011,6 +1011,33 @@ header {
|
|||||||
background: rgba(231, 139, 139, 0.12);
|
background: rgba(231, 139, 139, 0.12);
|
||||||
border-color: rgba(231, 139, 139, 0.6);
|
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 {
|
.suggestion-chip.leaving {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(6px);
|
transform: translateX(6px);
|
||||||
|
|||||||
@@ -457,9 +457,71 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</div><!-- end maintenance tab panel -->
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
<style>
|
<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