From 1463794778ea4763e0d2e30d8cbc4a9a17b15cc4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 29 Jun 2026 00:51:59 -0400 Subject: [PATCH] feat(heads): auto-apply UI on the Concept-heads card (#114 auto-apply C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces earned auto-apply + its observability in Settings → Tagging → Concept heads: - Auto-apply section: an on/off switch (writes head_auto_apply_enabled), the precision-target + min-examples-to-fire tuning inputs, a Preview (dry-run → "would apply N", per-concept chips) and Apply-now button, with live run state. - "How auto-apply is landing": per-concept table from /api/heads/metrics — applied volume, misfires, realized misfire rate (green/amber/red), and missed (under-fires) — the signal to tune the precision target from. store: autoApply(dryRun) / autoApplyStatus() / metrics(). Card polls the sweep to completion, then refreshes counts + metrics. Completes the auto-apply task. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa --- .../src/components/settings/HeadsCard.vue | 220 +++++++++++++++++- frontend/src/stores/heads.js | 19 +- 2 files changed, 235 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/settings/HeadsCard.vue b/frontend/src/components/settings/HeadsCard.vue index 673ff1f..a4c5627 100644 --- a/frontend/src/components/settings/HeadsCard.vue +++ b/frontend/src/components/settings/HeadsCard.vue @@ -92,6 +92,105 @@ + + +
+
+ mdi-lightning-bolt + Auto-apply + +
+

+ 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. +

+ +
+ + +
+ +
+ Preview + Apply now +
+ +
+ +
Sweeping the library…
+
+ +
+
+ {{ lastSweep.dry_run ? 'Preview' : 'Applied' }} · + {{ formatTime(lastSweep.finished_at) }} · + {{ lastSweep.dry_run ? 'would apply' : 'applied' }} + {{ sweepTotal(lastSweep) }} + tag{{ sweepTotal(lastSweep) === 1 ? '' : 's' }} +
+
+ + {{ c.name }} {{ c.applied }} + +
+
+
+ + +
+
How auto-apply is landing
+
+ 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. +
+
+ + + + + + + + + + + + + + + + + + + +
Conceptappliedmisfiresratemissed
{{ c.name }}{{ c.n_auto_applied }}{{ c.n_misfires }} + {{ ratePct(c.misfire_rate) }} + {{ c.n_underfires }}
+
+
@@ -109,6 +208,21 @@ 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 ?? []) @@ -127,12 +241,21 @@ const startedAgo = computed(() => { }) onMounted(async () => { - // Settings power the "min N tags" copy; non-fatal if it fails. - mlSettings.loadSettings().catch(() => {}) + // 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) +onUnmounted(() => { stopPoll(); stopAutoPoll() }) async function refresh() { try { @@ -165,6 +288,82 @@ async function onTrain() { } } +// --- 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 '' @@ -190,6 +389,21 @@ function relTime(iso) {