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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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) {
|
||||
<style scoped>
|
||||
.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-stat__n {
|
||||
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 { 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