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:
parent
a380ec33ec
commit
eb72776e9f
4 changed files with 323 additions and 0 deletions
67
dev-api.py
67
dev-api.py
|
|
@ -1448,3 +1448,70 @@ def get_deploy_config():
|
||||||
def save_deploy_config(payload: dict):
|
def save_deploy_config(payload: dict):
|
||||||
# Deployment config changes require restart; just acknowledge
|
# Deployment config changes require restart; just acknowledge
|
||||||
return {"ok": True, "note": "Restart required to apply changes"}
|
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}
|
||||||
|
|
|
||||||
39
web/src/stores/settings/fineTune.test.ts
Normal file
39
web/src/stores/settings/fineTune.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
54
web/src/stores/settings/fineTune.ts
Normal file
54
web/src/stores/settings/fineTune.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
163
web/src/views/settings/FineTuneView.vue
Normal file
163
web/src/views/settings/FineTuneView.vue
Normal 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>
|
||||||
Loading…
Reference in a new issue