From 06d5e83da4a070474407e08e5b26600cbb24ada3 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 28 Jun 2026 10:44:35 -0400
Subject: [PATCH] feat(heads): admin card to train + inspect concept heads
(#114 B)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
.../src/components/settings/HeadsCard.vue | 241 ++++++++++++++++++
.../components/settings/MaintenancePanel.vue | 2 +
frontend/src/stores/heads.js | 24 ++
3 files changed, 267 insertions(+)
create mode 100644 frontend/src/components/settings/HeadsCard.vue
create mode 100644 frontend/src/stores/heads.js
diff --git a/frontend/src/components/settings/HeadsCard.vue b/frontend/src/components/settings/HeadsCard.vue
new file mode 100644
index 0000000..673ff1f
--- /dev/null
+++ b/frontend/src/components/settings/HeadsCard.vue
@@ -0,0 +1,241 @@
+
+
+
+ A head 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 {{ minPositives }} 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.
+
+
+
+
+
+
{{ headCount }}
+
heads
+
+
+
{{ graduatedCount }}
+
auto-apply ready
+
+
+
{{ lastTrained }}
+
last trained
+
+
+
+ {{ headCount > 0 ? 'Retrain heads' : 'Train heads' }}
+
+
+
+
Training… (started {{ startedAgo }})
+
+
+ Training failed: {{ lastError }}
+
+
+
+
mdi-brain
+
+ No heads yet. Tag a handful of images for the concepts you care about,
+ then train — each concept with ≥ {{ minPositives }} tags becomes a head.
+
+
+
+
+
+
+ {{ heads.length }} concept{{ heads.length === 1 ? '' : 's' }}, strongest first
+ (AP = average precision; auto-apply ⚡ = precise enough to fire without review)
+
+
+
+
+
+ | Concept |
+ Cat |
+ +tags |
+ AP |
+ P |
+ R |
+ ⚡ |
+
+
+
+
+ | {{ h.name }} |
+ {{ h.category }} |
+ {{ h.n_pos }} |
+ {{ pct(h.ap) }} |
+ {{ pct(h.precision) }} |
+ {{ pct(h.recall) }} |
+
+ mdi-lightning-bolt
+ —
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue
index 68c30d1..44dafe6 100644
--- a/frontend/src/components/settings/MaintenancePanel.vue
+++ b/frontend/src/components/settings/MaintenancePanel.vue
@@ -26,6 +26,7 @@
+
@@ -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'
diff --git a/frontend/src/stores/heads.js b/frontend/src/stores/heads.js
new file mode 100644
index 0000000..c1f481e
--- /dev/null
+++ b/frontend/src/stores/heads.js
@@ -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 }
+})