feat(system-tags): process auto-tag settings UI + mode-aware review strip (#1464)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m44s

Frontend for the system-tag refactor (milestone #157 step 6).

- HeadsCard: new 'Auto-tag work-in-progress' section (enable + tag-confidence +
  conflict knobs) for wip/editor process auto-apply, mirroring the chrome card;
  copy notes they stay VISIBLE and the head only learns from titles/manual (no
  runaway). Presentation copy narrowed to banner-only.
- HiddenReviewStrip: mode-aware — chrome flags read 'hidden as X / Keep hidden /
  Un-hide'; process flags read 'auto-tagged X / Keep tag / Remove tag'. Same
  endpoints (the backend returns mode), different words.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:22:13 -04:00
parent ad2a5fc5fe
commit d9a14e890d
2 changed files with 93 additions and 17 deletions
@@ -1,14 +1,15 @@
<template>
<!-- Auto-hidden chrome that ALSO looked like real content surfaced PROACTIVELY
atop the gallery whenever there's something to review (NOT gated on the
Show-hidden toggle, so misfires can't go unnoticed), most-concerning first,
with keep / un-hide (#141). Renders nothing when there's nothing to review. -->
<section v-if="items.length" class="fc-review" aria-label="Hidden images to review">
<!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked
like real content surfaced PROACTIVELY atop the gallery whenever there's
something to review (NOT gated on the Show-hidden toggle, so misfires can't
go unnoticed), most-concerning first, with keep / remove (#141, #1464).
Renders nothing when there's nothing to review. -->
<section v-if="items.length" class="fc-review" aria-label="Auto-tagged images to review">
<div class="fc-review__head">
<v-icon size="18" color="warning">mdi-alert-outline</v-icon>
<span class="fc-review__title">
{{ items.length }} auto-hidden {{ items.length === 1 ? 'image' : 'images' }}
may be real content — review before they stay hidden
{{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }}
may be real content — review
</span>
</div>
<div class="fc-review__cards">
@@ -26,16 +27,16 @@
>
also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
</div>
<div class="fc-review-card__tag">hidden as {{ it.tag_name }}</div>
<div class="fc-review-card__tag">{{ tagLine(it) }}</div>
<div class="fc-review-card__acts">
<button
type="button" class="fc-review-btn fc-review-btn--keep"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
>Keep hidden</button>
>{{ keepLabel(it) }}</button>
<button
type="button" class="fc-review-btn fc-review-btn--unhide"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
>Un-hide</button>
>{{ removeLabel(it) }}</button>
</div>
</div>
</div>
@@ -54,6 +55,11 @@ const items = ref([])
const busy = ref([])
function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
// Chrome flags hide the image (keep-hidden / un-hide); process flags leave it
// visible and just tagged (keep-tag / remove-tag). Same endpoints, different words.
function tagLine(it) { return (it.mode === 'process' ? 'auto-tagged ' : 'hidden as ') + it.tag_name }
function keepLabel(it) { return it.mode === 'process' ? 'Keep tag' : 'Keep hidden' }
function removeLabel(it) { return it.mode === 'process' ? 'Remove tag' : 'Un-hide' }
async function load() {
// Fetched unconditionally on mount — the strip prompts for pending misfires
@@ -71,7 +77,8 @@ async function resolve(it, action) {
await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
items.value = items.value.filter((x) => keyOf(x) !== k)
if (action === 'unhide') {
toast({ text: `Un-hidden — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
const verb = it.mode === 'process' ? 'Removed' : 'Un-hidden'
toast({ text: `${verb} — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
}
} catch (e) {
toast({
+75 -6
View File
@@ -171,13 +171,13 @@
/>
</div>
<p class="fc-muted text-body-2 mb-3">
Auto-hide banners and editor screenshots from the gallery once a head has
learned them ( {{ minPositives }} examples) and clears
Auto-hide <code>banner</code> chrome from the gallery once a head has
learned it ( {{ minPositives }} examples) and clears
{{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence.
<code>wip</code> is never auto-hidden. If a hidden image also looks like
real content ( {{ Math.round((presentationConflictInput || 0) * 100) }}%
on a content tag), it's flagged in the Hidden view instead of buried.
Every auto-hide is reversible.
(<code>wip</code> and <code>editor screenshot</code> are handled by the
process auto-tagger below.) If a hidden image also looks like real content
( {{ Math.round((presentationConflictInput || 0) * 100) }}% on a content
tag), it's flagged for review instead of buried. Every auto-hide is reversible.
</p>
<div class="d-flex mb-3" style="gap: 12px;">
<v-text-field
@@ -195,6 +195,43 @@
</div>
</div>
<!-- Process auto-tagging (#1464): wip / editor screenshot -->
<div class="fc-auto mt-6">
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon size="18" color="accent">mdi-progress-wrench</v-icon>
<span class="fc-section-h">Auto-tag work-in-progress</span>
<v-switch
v-model="processEnabled" :loading="settingBusy" hide-details
density="compact" color="success" class="ml-auto"
@update:model-value="onToggleProcess"
/>
</div>
<p class="fc-muted text-body-2 mb-3">
Auto-tag <code>wip</code> and <code>editor screenshot</code> process art
once a head has learned them (≥ {{ minPositives }} examples) and clears
{{ Math.round((processThresholdInput || 0) * 100) }}% confidence. These stay
<strong>visible</strong> in the gallery — the tag just keeps them out of
training and the Explore rabbit-hole. Off by default. If a tagged image also
looks like real content (≥ {{ Math.round((processConflictInput || 0) * 100) }}%
on a content tag), it's flagged for review. Learns only from your titles +
manual tags, never its own guesses so it can't run away. Every tag reversible.
</p>
<div class="d-flex mb-3" style="gap: 12px;">
<v-text-field
v-model.number="processThresholdInput" label="Tag confidence"
type="number" min="0.5" max="0.999" step="0.01" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveProcess"
/>
<v-text-field
v-model.number="processConflictInput" label="Flag if content ≥"
type="number" min="0" max="1" step="0.05" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveProcess"
/>
</div>
</div>
<!-- Performance / tuning -->
<div v-if="metricsConcepts.length" class="mt-5">
<div class="fc-section-h mb-1">How auto-apply is landing</div>
@@ -258,6 +295,9 @@ let autoTimer = null
const presentationEnabled = ref(true)
const presentationThresholdInput = ref(0.90)
const presentationConflictInput = ref(0.50)
const processEnabled = ref(false)
const processThresholdInput = ref(0.90)
const processConflictInput = ref(0.50)
const autoRunning = computed(() => autoStatus.value?.running_id != null)
const lastSweep = computed(() =>
@@ -292,6 +332,9 @@ onMounted(async () => {
presentationEnabled.value = s.presentation_auto_apply_enabled ?? true
presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90
presentationConflictInput.value = s.presentation_conflict_threshold ?? 0.50
processEnabled.value = s.process_auto_apply_enabled ?? false
processThresholdInput.value = s.process_auto_apply_threshold ?? 0.90
processConflictInput.value = s.process_conflict_threshold ?? 0.50
} catch { /* non-fatal */ }
await refresh()
if (running.value) startPoll()
@@ -402,6 +445,32 @@ async function onSavePresentation() {
settingBusy.value = false
}
}
async function onToggleProcess(val) {
settingBusy.value = true
try {
await mlSettings.patchSettings({ process_auto_apply_enabled: !!val })
toast({ text: val ? 'WIP auto-tag on' : 'WIP auto-tag off', type: 'success' })
} catch (e) {
processEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
}
async function onSaveProcess() {
settingBusy.value = true
try {
await mlSettings.patchSettings({
process_auto_apply_threshold: Number(processThresholdInput.value),
process_conflict_threshold: Number(processConflictInput.value),
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
}
function onPreview() { startSweep(true) }
function onApplyNow() { startSweep(false) }
async function startSweep(dryRun) {