feat(heads): auto-apply UI on the Concept-heads card (#114 auto-apply C)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
@@ -92,6 +92,105 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Earned auto-apply -->
|
||||||
|
<div class="fc-auto mt-6">
|
||||||
|
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||||
|
<v-icon size="18" color="accent">mdi-lightning-bolt</v-icon>
|
||||||
|
<span class="fc-section-h">Auto-apply</span>
|
||||||
|
<v-switch
|
||||||
|
v-model="autoEnabled" :loading="settingBusy" hide-details density="compact"
|
||||||
|
color="success" class="ml-auto" @update:model-value="onToggleAuto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="d-flex mb-3" style="gap: 12px;">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="autoPrecisionInput" label="Precision target"
|
||||||
|
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
||||||
|
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||||
|
@change="onSaveSettings"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="autoMinPosInput" label="Min examples to fire"
|
||||||
|
type="number" min="1" density="compact" hide-details
|
||||||
|
style="max-width: 200px;" :disabled="settingBusy"
|
||||||
|
@change="onSaveSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex" style="gap: 8px;">
|
||||||
|
<v-btn
|
||||||
|
size="small" variant="tonal" color="accent" rounded="pill"
|
||||||
|
prepend-icon="mdi-eye-outline" :loading="autoBusy" @click="onPreview"
|
||||||
|
>Preview</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="small" variant="flat" color="accent" rounded="pill"
|
||||||
|
prepend-icon="mdi-lightning-bolt"
|
||||||
|
:loading="autoBusy || autoRunning" :disabled="!autoEnabled"
|
||||||
|
@click="onApplyNow"
|
||||||
|
>Apply now</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="autoRunning" class="mt-3">
|
||||||
|
<v-progress-linear indeterminate color="accent" />
|
||||||
|
<div class="text-body-2 mt-2 fc-muted">Sweeping the library…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="lastSweep && !autoRunning" class="mt-3">
|
||||||
|
<div class="fc-muted text-caption mb-1">
|
||||||
|
{{ lastSweep.dry_run ? 'Preview' : 'Applied' }} ·
|
||||||
|
{{ formatTime(lastSweep.finished_at) }} ·
|
||||||
|
{{ lastSweep.dry_run ? 'would apply' : 'applied' }}
|
||||||
|
<strong>{{ sweepTotal(lastSweep) }}</strong>
|
||||||
|
tag{{ sweepTotal(lastSweep) === 1 ? '' : 's' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="sweepConcepts(lastSweep).length" class="fc-chips">
|
||||||
|
<span v-for="c in sweepConcepts(lastSweep)" :key="c.tag_id" class="fc-chip">
|
||||||
|
{{ c.name }} <strong>{{ c.applied }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Performance / tuning -->
|
||||||
|
<div v-if="metricsConcepts.length" class="mt-5">
|
||||||
|
<div class="fc-section-h mb-1">How auto-apply is landing</div>
|
||||||
|
<div class="fc-muted text-caption mb-2">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
<div class="fc-table-wrap">
|
||||||
|
<table class="fc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="fc-l">Concept</th>
|
||||||
|
<th class="fc-r" title="Tags currently auto-applied">applied</th>
|
||||||
|
<th class="fc-r" title="Auto-tags you removed">misfires</th>
|
||||||
|
<th class="fc-r" title="Removed / (applied + removed)">rate</th>
|
||||||
|
<th class="fc-r" title="Tags you added by hand that a head exists for">missed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="c in metricsConcepts" :key="c.tag_id">
|
||||||
|
<td class="fc-l">{{ c.name }}</td>
|
||||||
|
<td class="fc-r fc-mono">{{ c.n_auto_applied }}</td>
|
||||||
|
<td class="fc-r fc-mono">{{ c.n_misfires }}</td>
|
||||||
|
<td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)">
|
||||||
|
{{ ratePct(c.misfire_rate) }}
|
||||||
|
</td>
|
||||||
|
<td class="fc-r fc-mono">{{ c.n_underfires }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</MaintenanceTile>
|
</MaintenanceTile>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -109,6 +208,21 @@ const summary = ref(null)
|
|||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
let pollTimer = null
|
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 headCount = computed(() => summary.value?.head_count ?? 0)
|
||||||
const graduatedCount = computed(() => summary.value?.graduated_count ?? 0)
|
const graduatedCount = computed(() => summary.value?.graduated_count ?? 0)
|
||||||
const heads = computed(() => summary.value?.heads ?? [])
|
const heads = computed(() => summary.value?.heads ?? [])
|
||||||
@@ -127,12 +241,21 @@ const startedAgo = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Settings power the "min N tags" copy; non-fatal if it fails.
|
// Settings power the copy + the auto-apply tuning inputs.
|
||||||
mlSettings.loadSettings().catch(() => {})
|
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()
|
await refresh()
|
||||||
if (running.value) startPoll()
|
if (running.value) startPoll()
|
||||||
|
await refreshAuto()
|
||||||
|
if (autoRunning.value) startAutoPoll()
|
||||||
|
refreshMetrics()
|
||||||
})
|
})
|
||||||
onUnmounted(stopPoll)
|
onUnmounted(() => { stopPoll(); stopAutoPoll() })
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
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 pct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
|
||||||
function apClass(ap) {
|
function apClass(ap) {
|
||||||
if (ap == null) return ''
|
if (ap == null) return ''
|
||||||
@@ -190,6 +389,21 @@ function relTime(iso) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
|
||||||
|
.fc-section-h {
|
||||||
|
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-auto {
|
||||||
|
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
|
||||||
|
}
|
||||||
|
.fc-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.fc-chip {
|
||||||
|
font-size: 12px; padding: 2px 8px; border-radius: 999px;
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
.fc-chip strong { color: rgb(var(--v-theme-on-surface)); }
|
||||||
|
|
||||||
.fc-stats { display: flex; gap: 28px; }
|
.fc-stats { display: flex; gap: 28px; }
|
||||||
.fc-stat__n {
|
.fc-stat__n {
|
||||||
font-size: 22px; font-weight: 700; line-height: 1.1;
|
font-size: 22px; font-weight: 700; line-height: 1.1;
|
||||||
|
|||||||
@@ -20,5 +20,22 @@ export const useHeadsStore = defineStore('heads', () => {
|
|||||||
return await api.post('/api/heads/train', { body: { params } })
|
return await api.post('/api/heads/train', { body: { params } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status, train }
|
// Earned auto-apply: trigger a sweep. dry_run previews (writes nothing);
|
||||||
|
// a real sweep needs head_auto_apply_enabled on (else 400).
|
||||||
|
async function autoApply(dryRun = false) {
|
||||||
|
return await api.post('/api/heads/auto-apply', { body: { dry_run: dryRun } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent sweeps + per-concept report (volume / projected per head).
|
||||||
|
async function autoApplyStatus() {
|
||||||
|
return await api.get('/api/heads/auto-apply')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observability: per-concept counts (volume, misfires, under-fires, realized
|
||||||
|
// misfire rate, head quality) + the daily time-series, to tune from.
|
||||||
|
async function metrics() {
|
||||||
|
return await api.get('/api/heads/metrics')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, train, autoApply, autoApplyStatus, metrics }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user