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:
2026-04-23 17:23:25 -04:00
parent a510665a17
commit 5b7e033d0c
7 changed files with 406 additions and 12 deletions
+98 -2
View File
@@ -242,13 +242,109 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const r = await fetch(`/image/${imageId}/suggestions`);
const j = await r.json();
if (j.ok) renderSuggestions(j.suggestions || {}, imageId);
else clearSuggestions();
if (!j.ok) { clearSuggestions(); return; }
// 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 {
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) {
chip.classList.add('leaving');
setTimeout(() => { chip.remove(); }, 200);
+40 -1
View File
@@ -1020,7 +1020,46 @@ header {
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 {
width: 100%;
padding: 0.5rem 0.75rem;