feat(#62): Fine-Tune tab — training pair management + real submit
Some checks failed
CI / test (push) Failing after 21s
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:
parent
42c9c882ee
commit
ac3e97d6c8
3 changed files with 124 additions and 9 deletions
70
dev-api.py
70
dev-api.py
|
|
@ -2169,18 +2169,73 @@ def save_deploy_config(payload: dict):
|
|||
|
||||
# ── 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")
|
||||
def finetune_status():
|
||||
try:
|
||||
pairs_count = len(_load_training_pairs())
|
||||
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)}
|
||||
if task:
|
||||
# Prefer the DB task count if available and larger (recent extraction)
|
||||
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:
|
||||
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")
|
||||
def finetune_extract():
|
||||
try:
|
||||
|
|
@ -2211,12 +2266,11 @@ async def finetune_upload(files: list[UploadFile]):
|
|||
|
||||
@app.post("/api/settings/fine-tune/submit")
|
||||
def finetune_submit():
|
||||
"""Trigger prepare_training_data extraction and queue fine-tune background task."""
|
||||
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}
|
||||
from scripts.task_runner import submit_task
|
||||
task_id, is_new = submit_task(Path(DB_PATH), "prepare_training", None)
|
||||
return {"job_id": str(task_id), "is_new": is_new}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { ref } from 'vue'
|
|||
import { defineStore } from 'pinia'
|
||||
import { useApiFetch } from '../../composables/useApi'
|
||||
|
||||
export interface TrainingPair {
|
||||
index: number
|
||||
instruction: string
|
||||
source_file: string
|
||||
}
|
||||
|
||||
export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
||||
const step = ref(1)
|
||||
const inFlightJob = ref(false)
|
||||
|
|
@ -10,6 +16,8 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
|||
const quotaRemaining = ref<number | null>(null)
|
||||
const uploading = ref(false)
|
||||
const loading = ref(false)
|
||||
const pairs = ref<TrainingPair[]>([])
|
||||
const pairsLoading = ref(false)
|
||||
let _pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function resetStep() { step.value = 1 }
|
||||
|
|
@ -37,6 +45,26 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
|||
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 {
|
||||
step,
|
||||
inFlightJob,
|
||||
|
|
@ -45,10 +73,14 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
|||
quotaRemaining,
|
||||
uploading,
|
||||
loading,
|
||||
pairs,
|
||||
pairsLoading,
|
||||
resetStep,
|
||||
loadStatus,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
submitJob,
|
||||
loadPairs,
|
||||
deletePair,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useAppConfigStore } from '../../stores/appConfig'
|
|||
|
||||
const store = useFineTuneStore()
|
||||
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 selectedFiles = ref<File[]>([])
|
||||
|
|
@ -45,6 +45,7 @@ async function checkLocalModel() {
|
|||
|
||||
onMounted(async () => {
|
||||
store.startPolling()
|
||||
await store.loadPairs()
|
||||
if (store.step === 3 && !config.isCloud) await checkLocalModel()
|
||||
})
|
||||
onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
||||
|
|
@ -99,6 +100,22 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
|||
</button>
|
||||
<button @click="store.step = 3" class="btn-secondary">Skip → Train</button>
|
||||
</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>
|
||||
|
||||
<!-- 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-ok { color: var(--color-success, #16a34a); }
|
||||
.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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue