feat(heads): admin card to train + inspect concept heads (#114 B)
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:
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user