feat(heads): admin card to train + inspect concept heads (#114 B)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m20s

The UI for the heads subsystem: Settings → Tagging → "Concept heads". Shows
head count, auto-apply-ready count, and last-trained; a Train/Retrain button
(one run at a time, polls while running, surfaces a failed run's error); an
empty state guiding the operator to tag first; and a per-concept table (name,
category, +tags, AP, P, R, auto-apply ) sorted strongest-first so weak/under-
tagged concepts are obvious. Rehydrates status from GET /api/heads on mount so
it survives navigation. Pulls head_min_positives from ML settings for copy.

Slice C (swap the rail's suggestions to heads, remove Camie + centroid) is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-28 10:44:35 -04:00
parent 1ed0895e8d
commit 06d5e83da4
3 changed files with 267 additions and 0 deletions
@@ -0,0 +1,241 @@
<template>
<MaintenanceTile
icon="mdi-brain"
title="Concept heads (the learning suggester)"
blurb="Train the per-concept heads that turn your tags into suggestions — they replace Camie and sharpen every time you accept or reject."
:open="headCount > 0 || 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>
</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
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 "min N tags" copy; non-fatal if it fails.
mlSettings.loadSettings().catch(() => {})
await refresh()
if (running.value) startPoll()
})
onUnmounted(stopPoll)
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
}
}
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-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>
@@ -26,6 +26,7 @@
</p>
<div class="fc-tile-stack">
<MLThresholdSliders />
<HeadsCard />
<AllowlistTable />
<AliasTable />
<TagEvalCard />
@@ -52,6 +53,7 @@ import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue'
import HeadsCard from './HeadsCard.vue'
import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue'
import TagEvalCard from './TagEvalCard.vue'
+24
View File
@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { useApi } from '../composables/useApi.js'
// Heads (#114): the per-concept classifiers that LEARN from your tags and power
// suggestions (replacing Camie + centroid). Training runs as a background task;
// the card rehydrates status from GET /api/heads on mount so it survives
// navigation (the run lives in head_training_run server-side).
export const useHeadsStore = defineStore('heads', () => {
const api = useApi()
// Summary: head_count, graduated_count, last_trained_at, running_id, the
// per-concept head table, and recent training runs.
async function status() {
return await api.get('/api/heads')
}
// (Re)train all eligible heads. One run at a time (409 if already running).
async function train(params = {}) {
return await api.post('/api/heads/train', { body: { params } })
}
return { status, train }
})