diff --git a/dev-api.py b/dev-api.py index 2bca90c..ece6794 100644 --- a/dev-api.py +++ b/dev-api.py @@ -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} diff --git a/web/src/stores/settings/fineTune.test.ts b/web/src/stores/settings/fineTune.test.ts new file mode 100644 index 0000000..838a7e5 --- /dev/null +++ b/web/src/stores/settings/fineTune.test.ts @@ -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() + }) +}) diff --git a/web/src/stores/settings/fineTune.ts b/web/src/stores/settings/fineTune.ts new file mode 100644 index 0000000..65d802e --- /dev/null +++ b/web/src/stores/settings/fineTune.ts @@ -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('idle') + const pairsCount = ref(0) + const quotaRemaining = ref(null) + const uploading = ref(false) + const loading = ref(false) + let _pollTimer: ReturnType | 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, + } +}) diff --git a/web/src/views/settings/FineTuneView.vue b/web/src/views/settings/FineTuneView.vue new file mode 100644 index 0000000..1d1a370 --- /dev/null +++ b/web/src/views/settings/FineTuneView.vue @@ -0,0 +1,163 @@ + + + + +