From ac3e97d6c8570f0c2ff2c4b8f83c4a9a954cfc5e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:30:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(#62):=20Fine-Tune=20tab=20=E2=80=94=20trai?= =?UTF-8?q?ning=20pair=20management=20+=20real=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dev-api.py | 70 ++++++++++++++++++++++--- web/src/stores/settings/fineTune.ts | 32 +++++++++++ web/src/views/settings/FineTuneView.vue | 31 ++++++++++- 3 files changed, 124 insertions(+), 9 deletions(-) diff --git a/dev-api.py b/dev-api.py index 9d9db3f..811e2b9 100644 --- a/dev-api.py +++ b/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)) diff --git a/web/src/stores/settings/fineTune.ts b/web/src/stores/settings/fineTune.ts index 65d802e..bdbeb5d 100644 --- a/web/src/stores/settings/fineTune.ts +++ b/web/src/stores/settings/fineTune.ts @@ -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(null) const uploading = ref(false) const loading = ref(false) + const pairs = ref([]) + const pairsLoading = ref(false) let _pollTimer: ReturnType | 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, } }) diff --git a/web/src/views/settings/FineTuneView.vue b/web/src/views/settings/FineTuneView.vue index 1d1a370..e4f0b0e 100644 --- a/web/src/views/settings/FineTuneView.vue +++ b/web/src/views/settings/FineTuneView.vue @@ -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(null) const selectedFiles = ref([]) @@ -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() }) + + +
+

Training Pairs {{ pairs.length }}

+

Review and remove any low-quality pairs before training.

+
Loading…
+
    +
  • +
    + {{ pair.instruction }} + {{ pair.source_file }} +
    + +
  • +
+
@@ -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; }