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.
+
+
+
+
+
+ | Concept |
+ applied |
+ misfires |
+ rate |
+ missed |
+
+
+
+
+ | {{ 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) {