feat(suggestions): auto-accept threshold + retroactive blocklist sweep
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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,7 @@ def make_celery(app=None):
|
|||||||
'app.tasks.scan.cleanup_old_tasks': {'queue': 'maintenance'},
|
'app.tasks.scan.cleanup_old_tasks': {'queue': 'maintenance'},
|
||||||
'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
|
'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
|
||||||
'app.tasks.scan.update_batch_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)
|
# Import tasks - handled by worker (heavy processing)
|
||||||
'app.tasks.import_file.*': {'queue': 'import'},
|
'app.tasks.import_file.*': {'queue': 'import'},
|
||||||
|
|||||||
+114
-4
@@ -743,9 +743,57 @@ def list_tags(image_id):
|
|||||||
|
|
||||||
@main.get("/image/<int:image_id>/suggestions")
|
@main.get("/image/<int:image_id>/suggestions")
|
||||||
def get_image_suggestions(image_id):
|
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
|
from app.services.tag_suggestions import get_suggestions
|
||||||
suggestions = get_suggestions(image_id)
|
from app.models import SuggestionFeedback
|
||||||
return jsonify({'ok': True, 'suggestions': suggestions})
|
|
||||||
|
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")
|
@main.post("/api/suggestions/bulk")
|
||||||
@@ -952,6 +1000,19 @@ def list_suggestion_blocklist():
|
|||||||
return jsonify(ok=True, names=[r.name for r in rows])
|
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")
|
@main.post("/api/suggestions/blocklist")
|
||||||
def add_suggestion_blocklist():
|
def add_suggestion_blocklist():
|
||||||
from app.models import TagSuggestionBlocklistEntry
|
from app.models import TagSuggestionBlocklistEntry
|
||||||
@@ -964,6 +1025,9 @@ def add_suggestion_blocklist():
|
|||||||
return jsonify(ok=True, added=False, name=name)
|
return jsonify(ok=True, added=False, name=name)
|
||||||
db.session.add(TagSuggestionBlocklistEntry(name=name))
|
db.session.add(TagSuggestionBlocklistEntry(name=name))
|
||||||
db.session.commit()
|
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)
|
return jsonify(ok=True, added=True, name=name)
|
||||||
|
|
||||||
|
|
||||||
@@ -982,20 +1046,66 @@ def remove_suggestion_blocklist():
|
|||||||
return jsonify(ok=True, removed=False, name=name)
|
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")
|
@main.post("/api/suggestions/blocklist/bulk")
|
||||||
def bulk_replace_suggestion_blocklist():
|
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
|
from app.models import TagSuggestionBlocklistEntry
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
names = payload.get('names', [])
|
names = payload.get('names', [])
|
||||||
if not isinstance(names, list):
|
if not isinstance(names, list):
|
||||||
return jsonify(ok=False, error='names_must_be_list'), 400
|
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()})
|
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()
|
TagSuggestionBlocklistEntry.query.delete()
|
||||||
for name in cleaned:
|
for name in cleaned:
|
||||||
db.session.add(TagSuggestionBlocklistEntry(name=name))
|
db.session.add(TagSuggestionBlocklistEntry(name=name))
|
||||||
db.session.commit()
|
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/<int:image_id>/tags/add")
|
@main.post("/image/<int:image_id>/tags/add")
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ _DEFAULTS = {
|
|||||||
'threshold_meta': 0.5,
|
'threshold_meta': 0.5,
|
||||||
'threshold_embedding': 0.85,
|
'threshold_embedding': 0.85,
|
||||||
'min_reference_images': 5.0,
|
'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',
|
'wd14_model_version': 'wd-eva02-large-tagger-v3',
|
||||||
'siglip_model_version': 'siglip-so400m-patch14-384',
|
'siglip_model_version': 'siglip-so400m-patch14-384',
|
||||||
}
|
}
|
||||||
@@ -246,17 +250,34 @@ def get_suggestions(
|
|||||||
if existing_all is None:
|
if existing_all is None:
|
||||||
existing_all = _existing_tag_names()
|
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': []}
|
grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
|
||||||
for s in merged:
|
for s in merged:
|
||||||
if s['category'] not in grouped:
|
if s['category'] not in grouped:
|
||||||
continue # meta / rating / artist / unknown are dropped
|
continue # meta / rating / artist / unknown are dropped
|
||||||
s['exists_in_db'] = s['name'] in existing_all
|
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)
|
grouped[s['category']].append(s)
|
||||||
|
|
||||||
for cat in grouped:
|
for cat in grouped:
|
||||||
grouped[cat].sort(key=lambda x: x['confidence'], reverse=True)
|
grouped[cat].sort(key=lambda x: x['confidence'], reverse=True)
|
||||||
grouped[cat] = grouped[cat][:top_k_per_category]
|
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
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
@@ -308,6 +329,11 @@ def get_bulk_suggestions(
|
|||||||
existing_all=existing_all,
|
existing_all=existing_all,
|
||||||
)
|
)
|
||||||
for category, items in grouped.items():
|
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:
|
for item in items:
|
||||||
name = item['name']
|
name = item['name']
|
||||||
stat = tag_stats.get(name)
|
stat = tag_stats.get(name)
|
||||||
|
|||||||
@@ -242,13 +242,109 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(`/image/${imageId}/suggestions`);
|
const r = await fetch(`/image/${imageId}/suggestions`);
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.ok) renderSuggestions(j.suggestions || {}, imageId);
|
if (!j.ok) { clearSuggestions(); return; }
|
||||||
else clearSuggestions();
|
// 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 {
|
} catch {
|
||||||
clearSuggestions();
|
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 = `
|
||||||
|
<span class="sugg-body">
|
||||||
|
<span class="sugg-label">${escapeHtml(item.display_name || item.name)}</span>
|
||||||
|
<span class="sugg-confidence">${pct}%</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="sugg-btn sugg-undo" aria-label="Undo (remove from this image)" title="Remove from this image">✕</button>
|
||||||
|
<button type="button" class="sugg-btn sugg-block" aria-label="Blocklist this tag" title="Never suggest '${escapeHtml(item.name)}' again — removes from all images">⊘</button>
|
||||||
|
`;
|
||||||
|
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) {
|
function animateChipOut(chip) {
|
||||||
chip.classList.add('leaving');
|
chip.classList.add('leaving');
|
||||||
setTimeout(() => { chip.remove(); }, 200);
|
setTimeout(() => { chip.remove(); }, 200);
|
||||||
|
|||||||
+40
-1
@@ -1020,7 +1020,46 @@ header {
|
|||||||
border-color: rgba(255, 180, 0, 0.6);
|
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 {
|
.settings-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|||||||
@@ -1,4 +1,58 @@
|
|||||||
"""Maintenance-queue Celery tasks. Currently empty — the
|
"""Maintenance-queue Celery tasks.
|
||||||
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).
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -458,6 +458,23 @@
|
|||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Auto-accept threshold (general tags)</h3>
|
||||||
|
<p class="settings-hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="mt-sm">
|
||||||
|
<input type="number" id="autoAcceptThresholdInput" class="settings-input"
|
||||||
|
min="0" max="1.1" step="0.01" style="width: 7rem;" />
|
||||||
|
<button type="button" id="autoAcceptThresholdSaveBtn" class="btn secondary-btn">Save threshold</button>
|
||||||
|
<span id="autoAcceptThresholdStatus" class="settings-hint" style="margin-left: 0.5rem;"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Suggestion blocklist</h3>
|
<h3>Suggestion blocklist</h3>
|
||||||
<p class="settings-hint">
|
<p class="settings-hint">
|
||||||
@@ -465,8 +482,11 @@
|
|||||||
post-canonicalization WD14 output (e.g. <code>1girl</code>,
|
post-canonicalization WD14 output (e.g. <code>1girl</code>,
|
||||||
<code>breasts</code>). Blocklisted names never appear in the
|
<code>breasts</code>). Blocklisted names never appear in the
|
||||||
modal's auto-suggestions — either WD14 or embedding-similarity
|
modal's auto-suggestions — either WD14 or embedding-similarity
|
||||||
sourced. You can also add entries inline by clicking the ⊘ button
|
sourced. Adding a name also enqueues a cleanup task that removes
|
||||||
on any suggestion chip.
|
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.
|
||||||
</p>
|
</p>
|
||||||
<textarea id="suggestionBlocklistArea" class="settings-textarea" rows="10"
|
<textarea id="suggestionBlocklistArea" class="settings-textarea" rows="10"
|
||||||
placeholder="1girl 2girls breasts nipples penis"></textarea>
|
placeholder="1girl 2girls breasts nipples penis"></textarea>
|
||||||
@@ -521,6 +541,54 @@
|
|||||||
saveBtn.addEventListener('click', save);
|
saveBtn.addEventListener('click', save);
|
||||||
load();
|
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();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user