Files
FabledCurator/frontend/src/components/settings/HeadsCard.vue
T
bvandeusen 5b34c9221c
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m32s
feat(ia): wave 1 — Import tab dissolves, Maintenance regroups by system, one extension home
Settings IA per the approved A3 design (the old layout was the two-app merge
fossilized):
- Import tab retired: ImportTriggerPanel + ImportTaskList deleted (manual
  /import scans stay API-level; imports arrive via downloads/extension, heal
  via the Layer-2 auto-refetch sweep, and show in Activity). ImportFiltersForm
  moves to Maintenance → 'Ingestion & filters' and loads its own settings; the
  import store shrinks to settings-only (no remaining consumers of the
  scan/task-list machinery). Overview's pending banner now points at Activity.
- Maintenance regrouped: Ingestion & filters / GPU agent & embeddings
  (GpuAgent, Failed processing, CPU embedding backfill) / Tagging (sliders,
  Heads, Aliases) / Library health (MissingFiles, Thumbnails, DB, Archive
  re-extract demoted last) / Storage.
- One extension home: BrowserExtensionCard moves from Settings → Overview to
  Subscriptions → Settings, above the API key bar it authenticates.
- Single-color import filter WIRED: skip_single_color/threshold existed since
  FC-2 but nothing read them (the audit module's docstring said as much) —
  now enforced on both import paths via the audit's canonical predicate
  (tolerance 30, matching the Cleanup card default; animated images exempt
  like the transparency check). Default stays off; test added.
- Dead weight: PlaceholderView (zero refs) and the permanently-disabled
  'Export failed logs (CSV — v2)' menu stub deleted; stale docs fixed
  (celery queue docstring, threshold comment citing retired tasks, ml
  package docstring, HeadsCard 'replaces Camie' blurb).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-02 17:37:21 -04:00

456 lines
16 KiB
Vue

<template>
<MaintenanceTile
icon="mdi-brain"
title="Concept heads (the learning suggester)"
blurb="Train the per-concept heads that turn your tags into suggestions — they learn from your library and sharpen every time you accept or reject."
:open="running"
>
<p class="fc-muted text-body-2 mb-3">
A <strong>head</strong> is a tiny classifier trained on the SigLIP
embeddings already stored on your images your positives plus your
negatives (rejections). One is built per general/character concept with at
least <strong>{{ minPositives }}</strong> tagged images. Retrain after a
tagging session to fold in your latest accepts/rejects; scoring is live, so
the rail reflects a retrain on the next image you open.
</p>
<!-- Summary stats -->
<div class="fc-stats mb-3">
<div class="fc-stat">
<div class="fc-stat__n">{{ headCount }}</div>
<div class="fc-stat__l">heads</div>
</div>
<div class="fc-stat">
<div class="fc-stat__n">{{ graduatedCount }}</div>
<div class="fc-stat__l" title="Heads precise enough to auto-apply without review">auto-apply ready</div>
</div>
<div class="fc-stat">
<div class="fc-stat__n fc-stat__n--time">{{ lastTrained }}</div>
<div class="fc-stat__l">last trained</div>
</div>
</div>
<v-btn
v-if="!running"
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-play" :loading="busy" @click="onTrain"
>{{ headCount > 0 ? 'Retrain heads' : 'Train heads' }}</v-btn>
<div v-if="running" class="mt-3">
<v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 fc-muted">Training (started {{ startedAgo }})</div>
</div>
<v-alert
v-if="lastError"
type="error" variant="tonal" density="compact" class="mt-3"
>Training failed: {{ lastError }}</v-alert>
<!-- Empty state -->
<div v-if="!running && headCount === 0" class="fc-empty mt-4">
<v-icon size="32" color="accent">mdi-brain</v-icon>
<p class="fc-muted text-body-2 mt-2 mb-0">
No heads yet. Tag a handful of images for the concepts you care about,
then train each concept with {{ minPositives }} tags becomes a head.
</p>
</div>
<!-- Per-concept table -->
<div v-if="heads.length" class="mt-4">
<div class="fc-muted text-caption mb-2">
{{ heads.length }} concept{{ heads.length === 1 ? '' : 's' }}, strongest first
(AP = average precision; auto-apply = precise enough to fire without review)
</div>
<div class="fc-table-wrap">
<table class="fc-table">
<thead>
<tr>
<th class="fc-l">Concept</th>
<th>Cat</th>
<th class="fc-r" title="Tagged positives the head trained on">+tags</th>
<th class="fc-r">AP</th>
<th class="fc-r" title="Precision at the suggest operating point">P</th>
<th class="fc-r" title="Recall at the suggest operating point">R</th>
<th class="fc-c"></th>
</tr>
</thead>
<tbody>
<tr v-for="h in heads" :key="h.tag_id">
<td class="fc-l">{{ h.name }}</td>
<td><span class="fc-cat">{{ h.category }}</span></td>
<td class="fc-r fc-mono">{{ h.n_pos }}</td>
<td class="fc-r fc-mono" :class="apClass(h.ap)">{{ pct(h.ap) }}</td>
<td class="fc-r fc-mono">{{ pct(h.precision) }}</td>
<td class="fc-r fc-mono">{{ pct(h.recall) }}</td>
<td class="fc-c">
<v-icon v-if="h.auto_apply" size="16" color="success"
title="Auto-apply ready">mdi-lightning-bolt</v-icon>
<span v-else class="fc-muted"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Earned auto-apply -->
<div class="fc-auto mt-6">
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon size="18" color="accent">mdi-lightning-bolt</v-icon>
<span class="fc-section-h">Auto-apply</span>
<v-switch
v-model="autoEnabled" :loading="settingBusy" hide-details density="compact"
color="success" class="ml-auto" @update:model-value="onToggleAuto"
/>
</div>
<p class="fc-muted text-body-2 mb-3">
Graduated heads (, with {{ autoMinPosInput }} examples) apply their tag
on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}%
precision. Every auto-tag is reversible; removing one teaches the head it
misfired.
</p>
<div class="d-flex mb-3" style="gap: 12px;">
<v-text-field
v-model.number="autoPrecisionInput" label="Precision target"
type="number" min="0.5" max="0.999" step="0.01" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveSettings"
/>
<v-text-field
v-model.number="autoMinPosInput" label="Min examples to fire"
type="number" min="1" density="compact" hide-details
style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveSettings"
/>
</div>
<div class="d-flex" style="gap: 8px;">
<v-btn
size="small" variant="tonal" color="accent" rounded="pill"
prepend-icon="mdi-eye-outline" :loading="autoBusy" @click="onPreview"
>Preview</v-btn>
<v-btn
size="small" variant="flat" color="accent" rounded="pill"
prepend-icon="mdi-lightning-bolt"
:loading="autoBusy || autoRunning" :disabled="!autoEnabled"
@click="onApplyNow"
>Apply now</v-btn>
</div>
<div v-if="autoRunning" class="mt-3">
<v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 fc-muted">Sweeping the library</div>
</div>
<div v-if="lastSweep && !autoRunning" class="mt-3">
<div class="fc-muted text-caption mb-1">
{{ lastSweep.dry_run ? 'Preview' : 'Applied' }} ·
{{ formatTime(lastSweep.finished_at) }} ·
{{ lastSweep.dry_run ? 'would apply' : 'applied' }}
<strong>{{ sweepTotal(lastSweep) }}</strong>
tag{{ sweepTotal(lastSweep) === 1 ? '' : 's' }}
</div>
<div v-if="sweepConcepts(lastSweep).length" class="fc-chips">
<span v-for="c in sweepConcepts(lastSweep)" :key="c.tag_id" class="fc-chip">
{{ c.name }} <strong>{{ c.applied }}</strong>
</span>
</div>
</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>
<div class="fc-muted text-caption mb-2">
Misfire = an auto-tag you removed; missed = a tag you added by hand that a
head should have caught. Tune the precision target from the misfire rate.
</div>
<div class="fc-table-wrap">
<table class="fc-table">
<thead>
<tr>
<th class="fc-l">Concept</th>
<th class="fc-r" title="Tags currently auto-applied">applied</th>
<th class="fc-r" title="Auto-tags you removed">misfires</th>
<th class="fc-r" title="Removed / (applied + removed)">rate</th>
<th class="fc-r" title="Tags you added by hand that a head exists for">missed</th>
</tr>
</thead>
<tbody>
<tr v-for="c in metricsConcepts" :key="c.tag_id">
<td class="fc-l">{{ c.name }}</td>
<td class="fc-r fc-mono">{{ c.n_auto_applied }}</td>
<td class="fc-r fc-mono">{{ c.n_misfires }}</td>
<td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)">
{{ ratePct(c.misfire_rate) }}
</td>
<td class="fc-r fc-mono">{{ c.n_underfires }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</MaintenanceTile>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { useHeadsStore } from '../../stores/heads.js'
import { useMLStore } from '../../stores/ml.js'
const store = useHeadsStore()
const mlSettings = useMLStore()
const summary = ref(null)
const busy = ref(false)
let pollTimer = null
// --- Auto-apply state ---
const autoEnabled = ref(false)
const autoPrecisionInput = ref(0.97)
const autoMinPosInput = ref(30)
const settingBusy = ref(false)
const autoBusy = ref(false)
const autoStatus = ref(null)
const metricsData = ref(null)
let autoTimer = null
const autoRunning = computed(() => autoStatus.value?.running_id != null)
const lastSweep = computed(() =>
(autoStatus.value?.runs || []).find(r => r.status !== 'running') || null)
const metricsConcepts = computed(() => metricsData.value?.concepts ?? [])
const headCount = computed(() => summary.value?.head_count ?? 0)
const graduatedCount = computed(() => summary.value?.graduated_count ?? 0)
const heads = computed(() => summary.value?.heads ?? [])
const running = computed(() => summary.value?.running_id != null)
const minPositives = computed(() => mlSettings.settings?.head_min_positives ?? 8)
const lastTrained = computed(() =>
summary.value?.last_trained_at ? relTime(summary.value.last_trained_at) : 'never')
// Surface the most recent terminal run's error (if it ended in error).
const lastError = computed(() => {
const r = (summary.value?.runs || []).find(x => x.status !== 'running')
return r && r.status === 'error' ? r.error : null
})
const startedAgo = computed(() => {
const r = (summary.value?.runs || []).find(x => x.status === 'running')
return r?.started_at ? formatTime(r.started_at) : ''
})
onMounted(async () => {
// Settings power the copy + the auto-apply tuning inputs.
try {
await mlSettings.loadSettings()
const s = mlSettings.settings || {}
autoEnabled.value = !!s.head_auto_apply_enabled
autoPrecisionInput.value = s.head_auto_apply_precision ?? 0.97
autoMinPosInput.value = s.head_auto_apply_min_positives ?? 30
} catch { /* non-fatal */ }
await refresh()
if (running.value) startPoll()
await refreshAuto()
if (autoRunning.value) startAutoPoll()
refreshMetrics()
})
onUnmounted(() => { stopPoll(); stopAutoPoll() })
async function refresh() {
try {
summary.value = await store.status()
} catch { /* non-fatal — the card still offers a fresh train */ }
}
function startPoll() {
stopPoll()
pollTimer = setInterval(async () => {
await refresh()
if (!running.value) stopPoll()
}, 5000)
}
function stopPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
async function onTrain() {
busy.value = true
try {
await store.train()
await refresh()
startPoll()
} catch (e) {
const msg = e.body?.running_id ? 'Training is already running.' : e.message
toast({ text: `Could not start training: ${msg}`, type: 'error' })
} finally {
busy.value = false
}
}
// --- Auto-apply ---
async function refreshAuto() {
try { autoStatus.value = await store.autoApplyStatus() } catch { /* non-fatal */ }
}
async function refreshMetrics() {
try { metricsData.value = await store.metrics() } catch { /* non-fatal */ }
}
function startAutoPoll() {
stopAutoPoll()
autoTimer = setInterval(async () => {
const was = autoRunning.value
await refreshAuto()
// Sweep just finished → refresh the counts + landing metrics.
if (was && !autoRunning.value) { refreshMetrics(); refresh() }
if (!autoRunning.value) stopAutoPoll()
}, 4000)
}
function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } }
async function onToggleAuto(val) {
settingBusy.value = true
try {
await mlSettings.patchSettings({ head_auto_apply_enabled: !!val })
toast({ text: val ? 'Auto-apply on' : 'Auto-apply off', type: 'success' })
} catch (e) {
autoEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
}
async function onSaveSettings() {
settingBusy.value = true
try {
await mlSettings.patchSettings({
head_auto_apply_precision: Number(autoPrecisionInput.value),
head_auto_apply_min_positives: Number(autoMinPosInput.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) {
autoBusy.value = true
try {
await store.autoApply(dryRun)
await refreshAuto()
startAutoPoll()
} catch (e) {
const code = e.body?.error
const msg = code === 'auto_apply_already_running' ? 'A sweep is already running.'
: code === 'auto_apply_disabled' ? 'Enable auto-apply first.'
: e.message
toast({ text: `Could not start sweep: ${msg}`, type: 'error' })
} finally {
autoBusy.value = false
}
}
function sweepConcepts(run) {
return (run?.report?.concepts || [])
.filter(c => c.applied > 0)
.sort((a, b) => b.applied - a.applied)
}
function sweepTotal(run) { return run?.n_applied ?? 0 }
function ratePct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
function rateClass(x) {
if (x == null) return ''
if (x <= 0.03) return 'fc-good'
if (x <= 0.1) return 'fc-ok'
return 'fc-weak'
}
function pct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
function apClass(ap) {
if (ap == null) return ''
if (ap >= 0.85) return 'fc-good'
if (ap >= 0.7) return 'fc-ok'
return 'fc-weak'
}
function formatTime(iso) {
if (!iso) return ''
try { return new Date(iso).toLocaleString() } catch { return iso }
}
function relTime(iso) {
try {
const d = (Date.now() - new Date(iso).getTime()) / 1000
if (d < 60) return 'just now'
if (d < 3600) return `${Math.floor(d / 60)}m ago`
if (d < 86400) return `${Math.floor(d / 3600)}h ago`
return `${Math.floor(d / 86400)}d ago`
} catch { return iso }
}
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-auto {
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
}
.fc-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.fc-chip {
font-size: 12px; padding: 2px 8px; border-radius: 999px;
background: rgb(var(--v-theme-surface-light));
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-chip strong { color: rgb(var(--v-theme-on-surface)); }
.fc-stats { display: flex; gap: 28px; }
.fc-stat__n {
font-size: 22px; font-weight: 700; line-height: 1.1;
color: rgb(var(--v-theme-on-surface));
font-family: 'JetBrains Mono', monospace;
}
.fc-stat__n--time { font-size: 15px; font-weight: 600; }
.fc-stat__l {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-empty {
text-align: center; padding: 18px 12px;
border: 1px dashed rgb(var(--v-theme-surface-light)); border-radius: 8px;
}
.fc-table-wrap {
max-height: 360px; overflow-y: auto;
border: 1px solid rgb(var(--v-theme-surface-light)); border-radius: 8px;
}
.fc-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.fc-table thead th {
position: sticky; top: 0; z-index: 1;
background: rgb(var(--v-theme-surface));
text-align: right; padding: 6px 10px; font-weight: 600;
color: rgb(var(--v-theme-on-surface-variant));
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
white-space: nowrap;
}
.fc-table td {
padding: 5px 10px; text-align: right;
border-bottom: 1px solid rgba(var(--v-theme-surface-light), 0.5);
}
.fc-table tbody tr:hover { background: rgb(var(--v-theme-surface-light)); }
.fc-l { text-align: left !important; }
.fc-r { text-align: right; }
.fc-c { text-align: center !important; }
.fc-mono { font-family: 'JetBrains Mono', monospace; }
.fc-cat {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em;
color: rgb(var(--v-theme-on-surface-variant));
background: rgb(var(--v-theme-surface-light));
padding: 1px 6px; border-radius: 999px;
}
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-ok { color: rgb(var(--v-theme-on-surface)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
</style>