feat(settings): Fine-Tune tab — wizard, polling, step lifecycle

Add useFineTuneStore (Pinia setup-function) with step state, polling via
setInterval, loadStatus, startPolling/stopPolling, and submitJob. Add
FineTuneView.vue with a 3-step wizard (upload → extract → train), mode-aware
train step (self-hosted shows make finetune + model check; cloud shows
submit job + quota). Add fine-tune endpoints to dev-api.py: status, extract,
upload, submit, and local-status. All 4 store unit tests pass.
This commit is contained in:
pyr0ball 2026-03-22 15:52:53 -07:00
parent ab684301a5
commit 6eaa1fef79
4 changed files with 323 additions and 0 deletions

View file

@ -1448,3 +1448,70 @@ def get_deploy_config():
def save_deploy_config(payload: dict):
# Deployment config changes require restart; just acknowledge
return {"ok": True, "note": "Restart required to apply changes"}
# ── Settings: Fine-Tune ───────────────────────────────────────────────────────
@app.get("/api/settings/fine-tune/status")
def finetune_status():
try:
from scripts.task_runner import get_task_status
task = get_task_status("finetune_extract")
if not task:
return {"status": "idle", "pairs_count": 0}
return {"status": task.get("status", "idle"), "pairs_count": task.get("result_count", 0)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/fine-tune/extract")
def finetune_extract():
try:
from scripts.task_runner import submit_task
task_id = submit_task(DB_PATH, "finetune_extract", None)
return {"task_id": str(task_id)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/fine-tune/upload")
async def finetune_upload(files: list[UploadFile]):
try:
upload_dir = Path("data/finetune_uploads")
upload_dir.mkdir(parents=True, exist_ok=True)
saved = []
for f in files:
dest = upload_dir / (f.filename or "upload.bin")
content = await f.read()
fd = os.open(str(dest), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as out:
out.write(content)
saved.append(str(dest))
return {"file_count": len(saved), "paths": saved}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/fine-tune/submit")
def finetune_submit():
try:
# Cloud-only: submit a managed fine-tune job
# In dev mode, stub a job_id for local testing
import uuid
job_id = str(uuid.uuid4())
return {"job_id": job_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/settings/fine-tune/local-status")
def finetune_local_status():
try:
import subprocess
result = subprocess.run(
["ollama", "list"], capture_output=True, text=True, timeout=5
)
model_ready = "alex-cover-writer" in (result.stdout or "")
return {"model_ready": model_ready}
except Exception:
return {"model_ready": False}

View file

@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFineTuneStore } from './fineTune'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useFineTuneStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('initial step is 1', () => {
expect(useFineTuneStore().step).toBe(1)
})
it('resetStep() returns to step 1', () => {
const store = useFineTuneStore()
store.step = 3
store.resetStep()
expect(store.step).toBe(1)
})
it('loadStatus() sets inFlightJob when status is running', async () => {
mockFetch.mockResolvedValue({ data: { status: 'running', pairs_count: 10 }, error: null })
const store = useFineTuneStore()
await store.loadStatus()
expect(store.inFlightJob).toBe(true)
})
it('startPolling() calls loadStatus on interval', async () => {
mockFetch.mockResolvedValue({ data: { status: 'idle' }, error: null })
const store = useFineTuneStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(4000)
expect(mockFetch).toHaveBeenCalledWith('/api/settings/fine-tune/status')
store.stopPolling()
})
})

View file

@ -0,0 +1,54 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export const useFineTuneStore = defineStore('settings/fineTune', () => {
const step = ref(1)
const inFlightJob = ref(false)
const jobStatus = ref<string>('idle')
const pairsCount = ref(0)
const quotaRemaining = ref<number | null>(null)
const uploading = ref(false)
const loading = ref(false)
let _pollTimer: ReturnType<typeof setInterval> | null = null
function resetStep() { step.value = 1 }
async function loadStatus() {
const { data } = await useApiFetch<{ status: string; pairs_count: number; quota_remaining?: number }>('/api/settings/fine-tune/status')
if (!data) return
jobStatus.value = data.status
pairsCount.value = data.pairs_count ?? 0
quotaRemaining.value = data.quota_remaining ?? null
inFlightJob.value = ['queued', 'running'].includes(data.status)
}
function startPolling() {
loadStatus()
_pollTimer = setInterval(loadStatus, 2000)
}
function stopPolling() {
if (_pollTimer !== null) { clearInterval(_pollTimer); _pollTimer = null }
}
async function submitJob() {
const { data, error } = await useApiFetch<{ job_id: string }>('/api/settings/fine-tune/submit', { method: 'POST' })
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
}
return {
step,
inFlightJob,
jobStatus,
pairsCount,
quotaRemaining,
uploading,
loading,
resetStep,
loadStatus,
startPolling,
stopPolling,
submitJob,
}
})

View file

@ -0,0 +1,163 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useFineTuneStore } from '../../stores/settings/fineTune'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useFineTuneStore()
const config = useAppConfigStore()
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining } = storeToRefs(store)
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([])
const uploadResult = ref<{ file_count: number } | null>(null)
const extractError = ref<string | null>(null)
const modelReady = ref<boolean | null>(null)
async function handleUpload() {
if (!selectedFiles.value.length) return
store.uploading = true
const form = new FormData()
for (const f of selectedFiles.value) form.append('files', f)
try {
const res = await fetch('/api/settings/fine-tune/upload', { method: 'POST', body: form })
uploadResult.value = await res.json()
store.step = 2
} catch {
extractError.value = 'Upload failed'
} finally {
store.uploading = false
}
}
async function handleExtract() {
extractError.value = null
const res = await fetch('/api/settings/fine-tune/extract', { method: 'POST' })
if (!res.ok) { extractError.value = 'Extraction failed'; return }
store.step = 3
}
async function checkLocalModel() {
const res = await fetch('/api/settings/fine-tune/local-status')
const data = await res.json()
modelReady.value = data.model_ready
}
onMounted(async () => {
store.startPolling()
if (store.step === 3 && !config.isCloud) await checkLocalModel()
})
onUnmounted(() => { store.stopPolling(); store.resetStep() })
</script>
<template>
<div class="fine-tune-view">
<h2>Fine-Tune Model</h2>
<!-- Wizard steps indicator -->
<div class="wizard-steps">
<span :class="['step', step >= 1 ? 'active' : '']">1. Upload</span>
<span class="step-divider"></span>
<span :class="['step', step >= 2 ? 'active' : '']">2. Extract</span>
<span class="step-divider"></span>
<span :class="['step', step >= 3 ? 'active' : '']">3. Train</span>
</div>
<!-- Step 1: Upload -->
<section v-if="step === 1" class="form-section">
<h3>Upload Cover Letters</h3>
<p class="section-note">Upload .md or .txt cover letter files to build your training dataset.</p>
<input
ref="fileInput"
type="file"
accept=".md,.txt"
multiple
@change="selectedFiles = Array.from(($event.target as HTMLInputElement).files ?? [])"
class="file-input"
/>
<div class="form-actions">
<button
@click="handleUpload"
:disabled="!selectedFiles.length || store.uploading"
class="btn-primary"
>
{{ store.uploading ? 'Uploading…' : `Upload ${selectedFiles.length} file(s)` }}
</button>
</div>
</section>
<!-- Step 2: Extract pairs -->
<section v-else-if="step === 2" class="form-section">
<h3>Extract Training Pairs</h3>
<p v-if="uploadResult">{{ uploadResult.file_count }} file(s) uploaded.</p>
<p class="section-note">Extract job description + cover letter pairs for training.</p>
<p v-if="pairsCount > 0" class="pairs-count">{{ pairsCount }} pairs extracted so far.</p>
<p v-if="extractError" class="error-msg">{{ extractError }}</p>
<div class="form-actions">
<button @click="handleExtract" :disabled="inFlightJob" class="btn-primary">
{{ inFlightJob ? 'Extracting…' : 'Extract Pairs' }}
</button>
<button @click="store.step = 3" class="btn-secondary">Skip Train</button>
</div>
</section>
<!-- Step 3: Train -->
<section v-else class="form-section">
<h3>Train Model</h3>
<p class="pairs-count">{{ pairsCount }} training pairs available.</p>
<!-- Job status banner (if in-flight) -->
<div v-if="inFlightJob" class="status-banner status-running">
Job {{ jobStatus }} polling every 2s
</div>
<div v-else-if="jobStatus === 'completed'" class="status-banner status-ok">
Training complete.
</div>
<div v-else-if="jobStatus === 'failed'" class="status-banner status-fail">
Training failed. Check logs.
</div>
<!-- Self-hosted path -->
<template v-if="!config.isCloud">
<p class="section-note">Run locally with Unsloth + Ollama:</p>
<pre class="code-block">make finetune</pre>
<div v-if="modelReady === null" class="form-actions">
<button @click="checkLocalModel" class="btn-secondary">Check Model Status</button>
</div>
<p v-else-if="modelReady" class="status-ok"> alex-cover-writer model is ready in Ollama.</p>
<p v-else class="status-fail">Model not yet registered. Run <code>make finetune</code> first.</p>
</template>
<!-- Cloud path -->
<template v-else>
<p v-if="quotaRemaining !== null" class="section-note">
Cloud quota remaining: {{ quotaRemaining }} jobs
</p>
<div class="form-actions">
<button
@click="store.submitJob()"
:disabled="inFlightJob || pairsCount === 0"
class="btn-primary"
>
{{ inFlightJob ? 'Job queued…' : 'Submit Training Job' }}
</button>
</div>
</template>
</section>
</div>
</template>
<style scoped>
.fine-tune-view { max-width: 640px; }
.wizard-steps { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; font-size: 0.9rem; }
.step { padding: 0.25rem 0.75rem; border-radius: 99px; background: var(--color-surface-2, #eee); color: var(--color-text-muted, #888); }
.step.active { background: var(--color-accent, #3b82f6); color: #fff; }
.step-divider { color: var(--color-text-muted, #888); }
.file-input { display: block; margin: 0.75rem 0; }
.pairs-count { font-weight: 600; margin-bottom: 0.5rem; }
.code-block { background: var(--color-surface-2, #f5f5f5); padding: 0.75rem 1rem; border-radius: 6px; font-family: monospace; margin: 0.75rem 0; }
.status-banner { padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); }
.status-ok { color: var(--color-success, #16a34a); }
.status-fail { color: var(--color-error, #dc2626); }
</style>