Earned auto-apply (fire + observability + UI), retrain cadences, Explore arrow-nav #143
@@ -109,6 +109,10 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.ml.apply_allowlist_tags",
|
||||
"schedule": 86400.0,
|
||||
},
|
||||
"train-heads-nightly": {
|
||||
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
||||
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
||||
},
|
||||
"integrity-verify-weekly": {
|
||||
"task": "backend.app.tasks.maintenance.verify_integrity",
|
||||
"schedule": 604800.0, # weekly
|
||||
|
||||
@@ -629,3 +629,33 @@ def train_heads(self, run_id: int) -> str:
|
||||
run.finished_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
return "ready"
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.scheduled_train_heads")
|
||||
def scheduled_train_heads() -> str:
|
||||
"""Nightly passive retrain (#114): fold the day's accepts/rejects + any
|
||||
newly-eligible concepts into the heads without the operator clicking. Skips
|
||||
if a run is already in flight (one at a time). Creates + COMMITS the run row
|
||||
before dispatching so the ml-queue worker can always find it."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from ..models import HeadTrainingRun
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
running = session.execute(
|
||||
sa_select(HeadTrainingRun.id).where(HeadTrainingRun.status == "running")
|
||||
).scalar_one_or_none()
|
||||
if running is not None:
|
||||
return "already running"
|
||||
run = HeadTrainingRun(
|
||||
params={"source": "scheduled"}, status="running",
|
||||
last_progress_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
run_id = run.id
|
||||
train_heads.delay(run_id)
|
||||
return "dispatched"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useHeadsStore } from '../stores/heads.js'
|
||||
import { toast } from '../utils/toast.js'
|
||||
|
||||
// Shared "(re)train the concept heads" behaviour (#114): trigger + poll status,
|
||||
// with toasts on start/finish. Backs both the Settings card and the Explore
|
||||
// view's inline button so the active retrain works the same everywhere.
|
||||
// Call start() on mount and stop() on unmount to manage the poll timer.
|
||||
export function useHeadTraining() {
|
||||
const store = useHeadsStore()
|
||||
const summary = ref(null)
|
||||
const busy = ref(false) // the trigger POST is in flight
|
||||
let timer = null
|
||||
|
||||
const running = computed(() => summary.value?.running_id != null)
|
||||
const headCount = computed(() => summary.value?.head_count ?? 0)
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
summary.value = await store.status()
|
||||
} catch { /* non-fatal — the button still offers a fresh train */ }
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll()
|
||||
timer = setInterval(async () => {
|
||||
const wasRunning = running.value
|
||||
await refresh()
|
||||
// Announce completion once, on the running → done transition.
|
||||
if (wasRunning && !running.value) {
|
||||
toast({
|
||||
text: `Heads retrained — ${headCount.value} concept${headCount.value === 1 ? '' : 's'}`,
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
if (!running.value) stopPoll()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
// Reflect an already-running run (e.g. the nightly one) on mount.
|
||||
async function start() {
|
||||
await refresh()
|
||||
if (running.value) startPoll()
|
||||
}
|
||||
|
||||
async function train() {
|
||||
busy.value = true
|
||||
try {
|
||||
await store.train()
|
||||
toast({ text: 'Head training started…', type: 'info' })
|
||||
await refresh()
|
||||
startPoll()
|
||||
} catch (e) {
|
||||
const msg = e.body?.running_id ? 'Training is already running.' : e.message
|
||||
toast({ text: `Could not start training: ${msg}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { summary, running, busy, headCount, refresh, train, start, stop: stopPoll }
|
||||
}
|
||||
@@ -30,11 +30,22 @@
|
||||
>
|
||||
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
||||
</button>
|
||||
<v-btn
|
||||
class="fc-ex__reseed" size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-shuffle-variant" :loading="seeding"
|
||||
@click="reseed"
|
||||
>Random image</v-btn>
|
||||
<div class="fc-ex__trail-actions">
|
||||
<!-- Active retrain right where you tag: fold the +/- you just gave
|
||||
into the heads without a trip to Settings (the nightly beat is the
|
||||
passive cadence). -->
|
||||
<v-btn
|
||||
size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-brain" :loading="headsBusy || headsRunning"
|
||||
title="Retrain the concept heads on your latest tags"
|
||||
@click="trainHeads"
|
||||
>{{ headsRunning ? 'Training…' : 'Retrain heads' }}</v-btn>
|
||||
<v-btn
|
||||
size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-shuffle-variant" :loading="seeding"
|
||||
@click="reseed"
|
||||
>Random image</v-btn>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" class="ma-3">
|
||||
@@ -115,6 +126,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useExploreStore } from '../stores/explore.js'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import { useHeadTraining } from '../composables/useHeadTraining.js'
|
||||
import { isTextEntry } from '../utils/textEntry.js'
|
||||
import ImageCanvas from '../components/modal/ImageCanvas.vue'
|
||||
import ImageMetaBar from '../components/modal/ImageMetaBar.vue'
|
||||
@@ -132,6 +144,13 @@ const seeding = ref(false)
|
||||
const seedError = ref(null)
|
||||
const tagPanelRef = ref(null)
|
||||
|
||||
// Inline head-retrain (shared with the Settings card) so banking your latest
|
||||
// +/- feedback is one click while you walk content.
|
||||
const {
|
||||
running: headsRunning, busy: headsBusy, train: trainHeads,
|
||||
start: startHeads, stop: stopHeads,
|
||||
} = useHeadTraining()
|
||||
|
||||
// Auto-focus the tag input after any action so tagging needs no extra click —
|
||||
// the whole point of the workspace (operator-asked 2026-06-26). nextTick waits
|
||||
// for the post-navigation re-render, then rAF lands the focus AFTER paint so a
|
||||
@@ -216,8 +235,14 @@ function onKeyDown (ev) {
|
||||
const id = ev.key === 'ArrowLeft' ? store.backTarget() : store.forwardTarget()
|
||||
if (id != null) { ev.preventDefault(); goTo(id) }
|
||||
}
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
startHeads() // reflect a run already in flight (e.g. the nightly one)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
stopHeads()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -250,7 +275,9 @@ onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
||||
.fc-ex__crumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-ex__crumb--current { border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-ex__crumb:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
|
||||
.fc-ex__reseed { margin-left: auto; }
|
||||
.fc-ex__trail-actions {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* The three panes fill the remaining height; each scrolls on its own.
|
||||
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
|
||||
|
||||
Reference in New Issue
Block a user