feat(#62): Fine-Tune tab — training pair management + real submit
Some checks failed
CI / test (push) Failing after 21s

API (dev-api.py):
- GET /api/settings/fine-tune/pairs — list pairs from JSONL with index/instruction/source_file
- DELETE /api/settings/fine-tune/pairs/{index} — remove a pair and rewrite JSONL
- POST /api/settings/fine-tune/submit — now queues prepare_training task (replaces UUID stub)
- GET /api/settings/fine-tune/status — returns pairs_count from JSONL (not just DB task)

Store (fineTune.ts):
- TrainingPair interface
- pairs, pairsLoading refs
- loadPairs(), deletePair() actions

Vue (FineTuneView.vue):
- Step 2 shows scrollable pairs list with instruction + source file
- ✕ button on each pair calls deletePair(); list/count update immediately
- loadPairs() called on mount
This commit is contained in:
pyr0ball 2026-04-04 22:30:16 -07:00
parent 42c9c882ee
commit ac3e97d6c8
3 changed files with 124 additions and 9 deletions

View file

@ -2169,18 +2169,73 @@ def save_deploy_config(payload: dict):
# ── Settings: Fine-Tune ─────────────────────────────────────────────────────── # ── Settings: Fine-Tune ───────────────────────────────────────────────────────
_TRAINING_JSONL = Path("/Library/Documents/JobSearch/training_data/cover_letters.jsonl")
def _load_training_pairs() -> list[dict]:
"""Load training pairs from the JSONL file. Returns empty list if missing."""
if not _TRAINING_JSONL.exists():
return []
pairs = []
with open(_TRAINING_JSONL, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
pairs.append(json.loads(line))
except json.JSONDecodeError:
pass
return pairs
def _save_training_pairs(pairs: list[dict]) -> None:
_TRAINING_JSONL.parent.mkdir(parents=True, exist_ok=True)
with open(_TRAINING_JSONL, "w", encoding="utf-8") as f:
for p in pairs:
f.write(json.dumps(p, ensure_ascii=False) + "\n")
@app.get("/api/settings/fine-tune/status") @app.get("/api/settings/fine-tune/status")
def finetune_status(): def finetune_status():
try: try:
pairs_count = len(_load_training_pairs())
from scripts.task_runner import get_task_status from scripts.task_runner import get_task_status
task = get_task_status("finetune_extract") task = get_task_status("finetune_extract")
if not task: if task:
return {"status": "idle", "pairs_count": 0} # Prefer the DB task count if available and larger (recent extraction)
return {"status": task.get("status", "idle"), "pairs_count": task.get("result_count", 0)} db_count = task.get("result_count", 0) or 0
pairs_count = max(pairs_count, db_count)
status = task.get("status", "idle") if task else "idle"
# Stub quota for self-hosted; cloud overrides via its own middleware
return {"status": status, "pairs_count": pairs_count, "quota_remaining": None}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/settings/fine-tune/pairs")
def list_training_pairs():
"""Return training pairs with index for display and removal."""
pairs = _load_training_pairs()
return {
"pairs": [
{"index": i, "instruction": p.get("instruction", ""), "source_file": p.get("source_file", "")}
for i, p in enumerate(pairs)
],
"total": len(pairs),
}
@app.delete("/api/settings/fine-tune/pairs/{index}")
def delete_training_pair(index: int):
"""Remove a training pair by index."""
pairs = _load_training_pairs()
if index < 0 or index >= len(pairs):
raise HTTPException(404, "Pair index out of range")
pairs.pop(index)
_save_training_pairs(pairs)
return {"ok": True, "remaining": len(pairs)}
@app.post("/api/settings/fine-tune/extract") @app.post("/api/settings/fine-tune/extract")
def finetune_extract(): def finetune_extract():
try: try:
@ -2211,12 +2266,11 @@ async def finetune_upload(files: list[UploadFile]):
@app.post("/api/settings/fine-tune/submit") @app.post("/api/settings/fine-tune/submit")
def finetune_submit(): def finetune_submit():
"""Trigger prepare_training_data extraction and queue fine-tune background task."""
try: try:
# Cloud-only: submit a managed fine-tune job from scripts.task_runner import submit_task
# In dev mode, stub a job_id for local testing task_id, is_new = submit_task(Path(DB_PATH), "prepare_training", None)
import uuid return {"job_id": str(task_id), "is_new": is_new}
job_id = str(uuid.uuid4())
return {"job_id": job_id}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View file

@ -2,6 +2,12 @@ import { ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi' import { useApiFetch } from '../../composables/useApi'
export interface TrainingPair {
index: number
instruction: string
source_file: string
}
export const useFineTuneStore = defineStore('settings/fineTune', () => { export const useFineTuneStore = defineStore('settings/fineTune', () => {
const step = ref(1) const step = ref(1)
const inFlightJob = ref(false) const inFlightJob = ref(false)
@ -10,6 +16,8 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
const quotaRemaining = ref<number | null>(null) const quotaRemaining = ref<number | null>(null)
const uploading = ref(false) const uploading = ref(false)
const loading = ref(false) const loading = ref(false)
const pairs = ref<TrainingPair[]>([])
const pairsLoading = ref(false)
let _pollTimer: ReturnType<typeof setInterval> | null = null let _pollTimer: ReturnType<typeof setInterval> | null = null
function resetStep() { step.value = 1 } function resetStep() { step.value = 1 }
@ -37,6 +45,26 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' } if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
} }
async function loadPairs() {
pairsLoading.value = true
const { data } = await useApiFetch<{ pairs: TrainingPair[]; total: number }>('/api/settings/fine-tune/pairs')
pairsLoading.value = false
if (data) {
pairs.value = data.pairs
pairsCount.value = data.total
}
}
async function deletePair(index: number) {
const { data } = await useApiFetch<{ ok: boolean; remaining: number }>(
`/api/settings/fine-tune/pairs/${index}`, { method: 'DELETE' }
)
if (data?.ok) {
pairs.value = pairs.value.filter(p => p.index !== index).map((p, i) => ({ ...p, index: i }))
pairsCount.value = data.remaining
}
}
return { return {
step, step,
inFlightJob, inFlightJob,
@ -45,10 +73,14 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
quotaRemaining, quotaRemaining,
uploading, uploading,
loading, loading,
pairs,
pairsLoading,
resetStep, resetStep,
loadStatus, loadStatus,
startPolling, startPolling,
stopPolling, stopPolling,
submitJob, submitJob,
loadPairs,
deletePair,
} }
}) })

View file

@ -6,7 +6,7 @@ import { useAppConfigStore } from '../../stores/appConfig'
const store = useFineTuneStore() const store = useFineTuneStore()
const config = useAppConfigStore() const config = useAppConfigStore()
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining } = storeToRefs(store) const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining, pairs, pairsLoading } = storeToRefs(store)
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([]) const selectedFiles = ref<File[]>([])
@ -45,6 +45,7 @@ async function checkLocalModel() {
onMounted(async () => { onMounted(async () => {
store.startPolling() store.startPolling()
await store.loadPairs()
if (store.step === 3 && !config.isCloud) await checkLocalModel() if (store.step === 3 && !config.isCloud) await checkLocalModel()
}) })
onUnmounted(() => { store.stopPolling(); store.resetStep() }) onUnmounted(() => { store.stopPolling(); store.resetStep() })
@ -99,6 +100,22 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
</button> </button>
<button @click="store.step = 3" class="btn-secondary">Skip Train</button> <button @click="store.step = 3" class="btn-secondary">Skip Train</button>
</div> </div>
<!-- Training pairs list -->
<div v-if="pairs.length > 0" class="pairs-list">
<h4>Training Pairs <span class="pairs-badge">{{ pairs.length }}</span></h4>
<p class="section-note">Review and remove any low-quality pairs before training.</p>
<div v-if="pairsLoading" class="pairs-loading">Loading</div>
<ul v-else class="pairs-items">
<li v-for="pair in pairs" :key="pair.index" class="pair-item">
<div class="pair-info">
<span class="pair-instruction">{{ pair.instruction }}</span>
<span class="pair-source">{{ pair.source_file }}</span>
</div>
<button class="pair-delete" @click="store.deletePair(pair.index)" title="Remove this pair"></button>
</li>
</ul>
</div>
</section> </section>
<!-- Step 3: Train --> <!-- Step 3: Train -->
@ -160,4 +177,16 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
.status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); } .status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); }
.status-ok { color: var(--color-success, #16a34a); } .status-ok { color: var(--color-success, #16a34a); }
.status-fail { color: var(--color-error, #dc2626); } .status-fail { color: var(--color-error, #dc2626); }
.pairs-list { margin-top: var(--space-6, 1.5rem); }
.pairs-list h4 { font-size: 0.95rem; font-weight: 600; margin: 0 0 var(--space-2, 0.5rem); display: flex; align-items: center; gap: 0.5rem; }
.pairs-badge { background: var(--color-primary, #2d5a27); color: #fff; font-size: 0.75rem; padding: 1px 7px; border-radius: var(--radius-full, 9999px); }
.pairs-loading { color: var(--color-text-muted); font-size: 0.875rem; padding: var(--space-2, 0.5rem) 0; }
.pairs-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); max-height: 280px; overflow-y: auto; }
.pair-item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt); border: 1px solid var(--color-border-light); border-radius: var(--radius-md); }
.pair-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pair-instruction { font-size: 0.85rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pair-source { font-size: 0.75rem; color: var(--color-text-muted); }
.pair-delete { flex-shrink: 0; background: none; border: none; color: var(--color-error); cursor: pointer; font-size: 0.9rem; padding: 2px 4px; border-radius: var(--radius-sm); transition: background 150ms; }
.pair-delete:hover { background: var(--color-error); color: #fff; }
</style> </style>