Spaces:
Runtime error
Runtime error
| """ModelForge API — FastAPI entrypoint.""" | |
| import json | |
| import logging | |
| import os | |
| import sys | |
| import time | |
| import uuid | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| from dotenv import load_dotenv | |
| load_dotenv(Path(__file__).parent / ".env") | |
| import pandas as pd | |
| from fastapi import FastAPI, HTTPException, UploadFile, File, WebSocket, WebSocketDisconnect, Depends | |
| from starlette.background import BackgroundTask | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, StreamingResponse | |
| from pydantic import BaseModel, field_validator | |
| from typing import Literal | |
| logging.basicConfig(stream=sys.stdout, level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| app = FastAPI(title="ModelForge API", version="0.3.0") | |
| _CORS_ORIGINS = [ | |
| o.strip() | |
| for o in os.getenv("CORS_ORIGIN", "http://localhost:3000,http://localhost:3456").split(",") | |
| if o.strip() | |
| ] | |
| # Project-scoped regex: production + this project's Vercel PREVIEW deployments | |
| # only (e.g. no-code-model-trainer-git-feature.vercel.app) — NOT every | |
| # *.vercel.app / *.hf.space, which previously let any site on those hosts make | |
| # credentialed requests. Override via CORS_ORIGIN_REGEX, or add exact origins | |
| # through CORS_ORIGIN. | |
| _CORS_ORIGIN_REGEX = os.getenv( | |
| "CORS_ORIGIN_REGEX", | |
| r"https://no-code-model-trainer[a-z0-9-]*\.vercel\.app", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=_CORS_ORIGINS, | |
| allow_origin_regex=_CORS_ORIGIN_REGEX, | |
| allow_credentials=True, | |
| allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], | |
| allow_headers=["Authorization", "Content-Type"], | |
| ) | |
| UPLOADS_DIR = Path(os.getenv("UPLOAD_DIR", str(Path(__file__).parent / "uploads"))) | |
| UPLOADS_DIR.mkdir(parents=True, exist_ok=True) | |
| # Known runs directory — artifact_path must stay within this tree | |
| RUNS_DIR = Path(os.getenv("RUNS_DIR", str(Path(__file__).parent.parent / "agents" / "runs"))) | |
| # In-memory file registry: file_id → absolute Path. | |
| # Rebuilt from .meta.json sidecars on startup so it survives server restarts. | |
| _FILE_REGISTRY: dict[str, Path] = {} | |
| # Pending clarifications: run_id → original ChatRequest params (+ "_expires_at"). | |
| # Populated when IntentAgent returns confidence < 0.7 and asks a clarifying question. | |
| # Consumed (and removed) by POST /clarify/{run_id}. | |
| # TTL-bounded so abandoned clarifications don't accumulate forever (issue #23). | |
| _pending_clarifications: dict[str, dict] = {} | |
| _CLARIFICATION_TTL_SECONDS = int(os.getenv("CLARIFICATION_TTL_SECONDS", "3600")) | |
| _MAX_PENDING_CLARIFICATIONS = 500 | |
| def _purge_expired_clarifications() -> None: | |
| now = time.monotonic() | |
| for k in [k for k, v in _pending_clarifications.items() if v.get("_expires_at", 0) < now]: | |
| _pending_clarifications.pop(k, None) | |
| def _rebuild_registry_from_disk() -> None: | |
| """Scan UPLOADS_DIR for .meta.json sidecars and restore the in-memory registry.""" | |
| for meta_file in UPLOADS_DIR.glob("*.meta.json"): | |
| try: | |
| import json as _json | |
| meta = _json.loads(meta_file.read_text()) | |
| file_id = meta.get("file_id") | |
| data_path = Path(meta.get("data_path", "")) | |
| if file_id and data_path.exists(): | |
| _FILE_REGISTRY[file_id] = data_path | |
| except Exception as exc: | |
| logger.warning("Could not restore registry entry from %s: %s", meta_file, exc) | |
| if _FILE_REGISTRY: | |
| logger.info("Restored %d file(s) to registry from disk", len(_FILE_REGISTRY)) | |
| _rebuild_registry_from_disk() | |
| from auth import get_current_user | |
| def _service_client(): | |
| """Return a service-role Supabase client, or None if not configured.""" | |
| url = os.getenv("SUPABASE_URL") or os.getenv("NEXT_PUBLIC_SUPABASE_URL") | |
| key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") | |
| if not url or not key: | |
| return None | |
| try: | |
| from supabase import create_client | |
| return create_client(url, key) | |
| except Exception as exc: | |
| logger.warning("Could not init Supabase service client: %s", exc) | |
| return None | |
| def _assert_run_owner(run_id: str, user: dict[str, Any] | None) -> None: | |
| """ | |
| Reject the request if `user` is not the owner of `run_id`. | |
| Best-effort: when no user is attached (permissive rollout phase) or Supabase | |
| is not configured, the check is skipped. When a verified user IS present and | |
| the run is owned by someone else, raise 403. | |
| """ | |
| if not user: | |
| return | |
| sb = _service_client() | |
| if sb is None: | |
| return | |
| try: | |
| res = sb.table("runs").select("user_id").eq("id", run_id).limit(1).execute() | |
| except Exception as exc: | |
| logger.warning("Ownership lookup failed for run %s: %s", run_id, exc) | |
| return | |
| rows = res.data or [] | |
| if rows and rows[0].get("user_id") and rows[0]["user_id"] != user.get("id"): | |
| raise HTTPException(403, "You do not have access to this run.") | |
| # ── Per-user quotas (issue #29) ────────────────────────────────────────────── | |
| _MAX_CONCURRENT_RUNS = int(os.getenv("MAX_CONCURRENT_RUNS", "2")) | |
| _MAX_DAILY_RUNS = int(os.getenv("MAX_DAILY_RUNS", "25")) | |
| # Only recent runs count toward the concurrency cap, so an orphaned 'running' | |
| # row (until the #22 reconciliation lands) can't lock a user out permanently. | |
| _ACTIVE_RUN_WINDOW_HOURS = 6 | |
| def _enforce_quota( | |
| user: dict[str, Any] | None, | |
| new_runs: int = 1, | |
| exclude_run_id: str | None = None, | |
| ) -> None: | |
| """ | |
| Reject the request (429) if starting `new_runs` would exceed the user's | |
| concurrency or daily run quota. Best-effort: skipped when unauthenticated | |
| or Supabase is unavailable, so it never blocks the permissive rollout phase. | |
| """ | |
| if not user: | |
| return | |
| uid = user.get("id") | |
| if not uid: | |
| return | |
| sb = _service_client() | |
| if sb is None: | |
| return | |
| from datetime import timedelta | |
| now = datetime.now(timezone.utc) | |
| try: | |
| active_cut = (now - timedelta(hours=_ACTIVE_RUN_WINDOW_HOURS)).isoformat() | |
| q = ( | |
| sb.table("runs").select("id", count="exact") | |
| .eq("user_id", uid) | |
| .in_("status", ["pending", "running"]) | |
| .gte("created_at", active_cut) | |
| ) | |
| if exclude_run_id: | |
| q = q.neq("id", exclude_run_id) | |
| active = q.execute().count or 0 | |
| except Exception as exc: | |
| logger.warning("[quota] concurrency check failed for %s: %s", uid, exc) | |
| return | |
| if active + new_runs > _MAX_CONCURRENT_RUNS: | |
| raise HTTPException( | |
| 429, | |
| f"You already have {active} run(s) in progress (limit " | |
| f"{_MAX_CONCURRENT_RUNS}). Wait for one to finish before starting more.", | |
| ) | |
| try: | |
| day_start = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() | |
| q = ( | |
| sb.table("runs").select("id", count="exact") | |
| .eq("user_id", uid) | |
| .gte("created_at", day_start) | |
| ) | |
| if exclude_run_id: | |
| q = q.neq("id", exclude_run_id) | |
| today = q.execute().count or 0 | |
| except Exception as exc: | |
| logger.warning("[quota] daily check failed for %s: %s", uid, exc) | |
| return | |
| if today + new_runs > _MAX_DAILY_RUNS: | |
| raise HTTPException( | |
| 429, | |
| f"Daily run limit reached ({_MAX_DAILY_RUNS} runs/day). " | |
| "This resets at 00:00 UTC.", | |
| ) | |
| # ── Orphaned-run reconciliation (issue #22) ────────────────────────────────── | |
| # Training runs in-process, so a restart/redeploy kills the in-memory task but | |
| # leaves the Supabase row stuck at 'running'/'pending' forever. On startup, fail | |
| # any such row older than the threshold so the UI stops showing a phantom run. | |
| _ORPHAN_RUN_MINUTES = int(os.getenv("ORPHAN_RUN_MINUTES", "30")) | |
| def _reconcile_orphaned_runs() -> None: | |
| sb = _service_client() | |
| if sb is None: | |
| return | |
| from datetime import timedelta | |
| now = datetime.now(timezone.utc) | |
| cutoff = (now - timedelta(minutes=_ORPHAN_RUN_MINUTES)).isoformat() | |
| try: | |
| res = ( | |
| sb.table("runs") | |
| .update({ | |
| "status": "failed", | |
| "error_message": "Run was interrupted by a backend restart and could not be resumed.", | |
| "completed_at": now.isoformat(), | |
| }) | |
| .in_("status", ["running", "pending"]) | |
| .lt("created_at", cutoff) | |
| .execute() | |
| ) | |
| n = len(res.data or []) | |
| if n: | |
| logger.info("[startup] Reconciled %d orphaned run(s) → failed", n) | |
| except Exception as exc: | |
| logger.warning("[startup] orphan reconciliation failed: %s", exc) | |
| _reconcile_orphaned_runs() | |
| from services.socket_manager import manager as socket_manager | |
| async def websocket_endpoint(websocket: WebSocket, job_id: str): | |
| await socket_manager.connect(job_id, websocket) | |
| try: | |
| while True: | |
| await websocket.receive_text() | |
| except WebSocketDisconnect: | |
| socket_manager.disconnect(job_id, websocket) | |
| except Exception as exc: | |
| logger.warning("WebSocket error for job %s: %s", job_id, exc) | |
| socket_manager.disconnect(job_id, websocket) | |
| async def upload_dataset( | |
| file: UploadFile = File(...), | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> dict[str, Any]: | |
| """ | |
| Upload a dataset file. Returns metadata including sample rows, class | |
| distribution, and data quality warnings — all without hitting the GPU. | |
| """ | |
| if not file.filename: | |
| raise HTTPException(400, "No filename provided") | |
| safe_name = Path(file.filename).name | |
| if not safe_name: | |
| raise HTTPException(400, "Invalid filename") | |
| allowed = {".csv", ".json", ".jsonl", ".txt"} | |
| suffix = Path(safe_name).suffix.lower() | |
| if suffix not in allowed: | |
| raise HTTPException(400, f"Unsupported file type {suffix}. Allowed: {', '.join(allowed)}") | |
| file_id = str(uuid.uuid4()) | |
| dest = UPLOADS_DIR / f"{file_id}{suffix}" | |
| content = await file.read() | |
| MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB | |
| if len(content) > MAX_UPLOAD_BYTES: | |
| raise HTTPException( | |
| 413, | |
| f"File too large ({len(content) // (1024 * 1024)} MB). Maximum allowed size is 50 MB.", | |
| ) | |
| dest.write_bytes(content) | |
| _FILE_REGISTRY[file_id] = dest | |
| # Write sidecar so the registry survives server restarts | |
| meta_file = UPLOADS_DIR / f"{file_id}.meta.json" | |
| meta_file.write_text(json.dumps({ | |
| "file_id": file_id, | |
| "original_name": safe_name, | |
| "data_path": str(dest), | |
| })) | |
| try: | |
| if suffix == ".csv": | |
| df = pd.read_csv(dest) | |
| elif suffix == ".jsonl": | |
| df = pd.read_json(dest, lines=True) | |
| elif suffix == ".json": | |
| df = pd.read_json(dest) | |
| else: # .txt — one text record per non-empty line | |
| lines = [ln for ln in dest.read_text(encoding="utf-8").splitlines() if ln.strip()] | |
| df = pd.DataFrame({"text": lines}) | |
| except Exception as exc: | |
| dest.unlink(missing_ok=True) | |
| meta_file.unlink(missing_ok=True) | |
| _FILE_REGISTRY.pop(file_id, None) | |
| raise HTTPException(422, f"Could not parse file: {exc}") from exc | |
| text_cols = [ | |
| c for c in df.columns | |
| if df[c].dtype == "object" and df[c].dropna().str.len().mean() > 10 | |
| ] | |
| label_cols = [ | |
| c for c in df.columns | |
| if c.lower() in {"label", "target", "class", "sentiment", "category"} | |
| ] | |
| # Class distribution for the first detected label column | |
| class_distribution: dict[str, int] = {} | |
| if label_cols: | |
| class_distribution = df[label_cols[0]].astype(str).value_counts().to_dict() | |
| # Text length stats for the first detected text column | |
| text_length_stats: dict[str, float] = {} | |
| if text_cols: | |
| lengths = df[text_cols[0]].dropna().astype(str).str.len() | |
| text_length_stats = { | |
| "min": int(lengths.min()), | |
| "max": int(lengths.max()), | |
| "mean": round(float(lengths.mean()), 1), | |
| "p50": round(float(lengths.quantile(0.50)), 1), | |
| "p90": round(float(lengths.quantile(0.90)), 1), | |
| "p99": round(float(lengths.quantile(0.99)), 1), | |
| } | |
| # Quick data quality warnings (pure Python, no GPU) | |
| data_warnings: list[str] = [] | |
| total = len(df) | |
| if total < 50: | |
| data_warnings.append(f"Very small dataset ({total} rows) — results may be unreliable.") | |
| elif total < 200: | |
| data_warnings.append(f"Small dataset ({total} rows) — consider data augmentation.") | |
| if label_cols and class_distribution: | |
| counts = list(class_distribution.values()) | |
| if len(counts) > 1: | |
| imbalance = max(counts) / max(min(counts), 1) | |
| if imbalance > 10: | |
| data_warnings.append(f"Severe class imbalance ({imbalance:.0f}:1 ratio) — weighted loss will be applied automatically.") | |
| elif imbalance > 3: | |
| data_warnings.append(f"Moderate class imbalance ({imbalance:.1f}:1 ratio) — consider oversampling.") | |
| if text_cols and text_length_stats: | |
| if text_length_stats.get("p90", 0) > 400: | |
| data_warnings.append("Many texts exceed 400 chars — they will be truncated to model max_length.") | |
| if text_length_stats.get("mean", 100) < 10: | |
| data_warnings.append("Average text is very short — there may be insufficient signal for training.") | |
| dup_count = int(df.duplicated().sum()) | |
| if dup_count > 0: | |
| data_warnings.append(f"{dup_count} duplicate row(s) detected — CleanAgent will remove them.") | |
| null_count = int(df[text_cols[0]].isna().sum()) if text_cols else 0 | |
| if null_count > 0: | |
| data_warnings.append(f"{null_count} row(s) with empty text detected — will be removed before training.") | |
| # Build histogram of text lengths (10 bins) | |
| text_length_histogram: list[dict] = [] | |
| if text_cols and len(df) > 0: | |
| lengths_series = df[text_cols[0]].dropna().astype(str).str.len() | |
| try: | |
| hist, bin_edges = _compute_histogram(lengths_series.tolist(), bins=10) | |
| text_length_histogram = [ | |
| {"bin_start": int(bin_edges[i]), "bin_end": int(bin_edges[i + 1]), "count": int(hist[i])} | |
| for i in range(len(hist)) | |
| ] | |
| except Exception: | |
| pass | |
| return { | |
| "file_id": file_id, | |
| "filename": safe_name, | |
| "rows": len(df), | |
| "columns": list(df.columns), | |
| "text_columns": text_cols, | |
| "label_columns": label_cols, | |
| "sample_rows": df.sample(min(5, len(df)), random_state=42).to_dict("records"), | |
| "unique_labels": ( | |
| df[label_cols[0]].dropna().unique().tolist() if label_cols else [] | |
| ), | |
| "class_distribution": class_distribution, | |
| "text_length_stats": text_length_stats, | |
| "text_length_histogram": text_length_histogram, | |
| "data_warnings": data_warnings, | |
| "duplicate_count": dup_count, | |
| "null_count": null_count, | |
| "file_size_bytes": len(content), | |
| } | |
| def _validate_file_id(file_id: str) -> str: | |
| """Security: ensure file_id is a valid UUID before touching the filesystem.""" | |
| try: | |
| uuid.UUID(file_id) | |
| except ValueError: | |
| raise HTTPException(400, "Invalid file_id format.") | |
| return file_id | |
| class RenameDatasetRequest(BaseModel): | |
| filename: str | |
| def validate_filename(cls, v: str) -> str: | |
| v = v.strip() | |
| if not v: | |
| raise ValueError("filename cannot be empty") | |
| if "/" in v or "\\" in v or ".." in v: | |
| raise ValueError("filename contains invalid characters") | |
| return v | |
| async def delete_dataset( | |
| file_id: str, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> dict[str, str]: | |
| """Remove an uploaded dataset and its registry entry.""" | |
| _validate_file_id(file_id) | |
| if file_id not in _FILE_REGISTRY: | |
| raise HTTPException(404, f"Dataset {file_id} not found.") | |
| data_path = _FILE_REGISTRY.pop(file_id) | |
| data_path.unlink(missing_ok=True) | |
| meta_file = UPLOADS_DIR / f"{file_id}.meta.json" | |
| meta_file.unlink(missing_ok=True) | |
| return {"status": "deleted", "file_id": file_id} | |
| async def rename_dataset( | |
| file_id: str, | |
| req: RenameDatasetRequest, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> dict[str, str]: | |
| """Rename a dataset (updates the stored display name, not the physical file).""" | |
| _validate_file_id(file_id) | |
| if file_id not in _FILE_REGISTRY: | |
| raise HTTPException(404, f"Dataset {file_id} not found.") | |
| meta_file = UPLOADS_DIR / f"{file_id}.meta.json" | |
| if meta_file.exists(): | |
| try: | |
| meta = json.loads(meta_file.read_text()) | |
| meta["original_name"] = req.filename | |
| meta_file.write_text(json.dumps(meta)) | |
| except Exception as exc: | |
| logger.warning("Could not update meta for %s: %s", file_id, exc) | |
| return {"status": "renamed", "file_id": file_id, "filename": req.filename} | |
| def _compute_histogram(values: list, bins: int = 10) -> tuple[list[int], list[float]]: | |
| """Pure-Python histogram computation (avoids numpy import at module level).""" | |
| if not values: | |
| return [], [] | |
| mn, mx = min(values), max(values) | |
| if mn == mx: | |
| return [len(values)], [float(mn), float(mx) + 1] | |
| step = (mx - mn) / bins | |
| edges = [mn + i * step for i in range(bins + 1)] | |
| counts = [0] * bins | |
| for v in values: | |
| idx = min(int((v - mn) / step), bins - 1) | |
| counts[idx] += 1 | |
| return counts, edges | |
| class ChatRequest(BaseModel): | |
| message: str | |
| file_id: str | None = None | |
| run_id: str | None = None | |
| hyperparameter_overrides: dict[str, Any] = {} | |
| hf_token: str | None = None | |
| # A3: set to a previous run_id to resume from its last checkpoint | |
| resume_from_run_id: str | None = None | |
| def message_not_empty(cls, v: str) -> str: | |
| if not v.strip(): | |
| raise ValueError("message cannot be empty") | |
| return v.strip() | |
| def _resolve_file_id(file_id: str | None) -> str | None: | |
| if not file_id: | |
| return None | |
| path = _FILE_REGISTRY.get(file_id) | |
| if path is None: | |
| raise HTTPException(404, "Dataset not found. Please re-upload your file.") | |
| return str(path) | |
| async def chat( | |
| req: ChatRequest, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> StreamingResponse: | |
| """ | |
| Stream agent responses as Server-Sent Events. | |
| Pass resume_from_run_id to continue a pipeline that failed mid-way — | |
| completed stages (Intent, Data, Clean, Model) are restored from the | |
| checkpoint and skipped, so only the remaining stages execute. | |
| """ | |
| if req.run_id: | |
| _assert_run_owner(req.run_id, user) | |
| # Don't count a resume of an already-running run against the quota. | |
| if not req.resume_from_run_id: | |
| _enforce_quota(user, new_runs=1, exclude_run_id=req.run_id) | |
| dataset_path = _resolve_file_id(req.file_id) | |
| _agents_import() | |
| async def event_stream(): | |
| try: | |
| from agents.pipeline import TrainingPipeline | |
| from services.run_event_writer import ( | |
| write_agent_event, | |
| write_pipeline_checkpoint, | |
| load_pipeline_checkpoint, | |
| ) | |
| # A3: load checkpoint when resuming | |
| initial_context: dict | None = None | |
| if req.resume_from_run_id: | |
| initial_context = await load_pipeline_checkpoint(req.resume_from_run_id) | |
| if initial_context: | |
| logger.info( | |
| "Resuming run %s from checkpoint (completed: %s)", | |
| req.resume_from_run_id, | |
| initial_context.get("completed_stages", []), | |
| ) | |
| pipeline = TrainingPipeline() | |
| async for result, context in pipeline.run_streaming( | |
| user_intent=req.message, | |
| dataset_path=dataset_path, | |
| hyperparameter_overrides=req.hyperparameter_overrides, | |
| hf_token=req.hf_token, | |
| run_id=req.run_id, | |
| initial_context=initial_context, | |
| ): | |
| data = json.dumps({ | |
| "agent": result.agent_name, | |
| "success": result.success, | |
| "message": result.message, | |
| "output": result.output, | |
| "metadata": result.metadata, | |
| }) | |
| yield f"data: {data}\n\n" | |
| # ── HITL: pause pipeline when IntentAgent needs clarification ── | |
| # IntentAgent returns next_agent=None with clarification_needed set | |
| # when confidence < 0.7. Store params so /clarify/{run_id} can resume. | |
| if ( | |
| result.agent_name == "Intent" | |
| and result.success | |
| and result.output.get("clarification_needed") | |
| and req.run_id | |
| ): | |
| _purge_expired_clarifications() | |
| if len(_pending_clarifications) >= _MAX_PENDING_CLARIFICATIONS: | |
| oldest = min( | |
| _pending_clarifications, | |
| key=lambda k: _pending_clarifications[k].get("_expires_at", 0), | |
| ) | |
| _pending_clarifications.pop(oldest, None) | |
| _pending_clarifications[req.run_id] = { | |
| "message": req.message, | |
| "file_id": req.file_id, | |
| "hyperparameter_overrides": req.hyperparameter_overrides, | |
| "hf_token": req.hf_token, | |
| "clarification_question": result.output["clarification_needed"], | |
| "_expires_at": time.monotonic() + _CLARIFICATION_TTL_SECONDS, | |
| } | |
| logger.info( | |
| "[%s] Clarification needed — run paused awaiting user response", | |
| req.run_id, | |
| ) | |
| if req.run_id: | |
| # Always persist the agent event | |
| await write_agent_event( | |
| run_id=req.run_id, | |
| agent_name=result.agent_name, | |
| success=result.success, | |
| message=result.message, | |
| output=result.output, | |
| ) | |
| # A3: after each successful stage, checkpoint the full context | |
| # so a resume can skip already-completed work | |
| if result.success: | |
| await write_pipeline_checkpoint( | |
| run_id=req.run_id, | |
| completed_stages=list(context.completed_stages), | |
| checkpoint_data=pipeline.context_snapshot(context), | |
| ) | |
| if not result.success: | |
| break | |
| except Exception as exc: | |
| logger.error("Agent pipeline error: %s", exc, exc_info=True) | |
| err_payload = json.dumps({ | |
| "agent": "System", "success": False, | |
| "message": "An internal error occurred. Please try again.", "output": {}, | |
| }) | |
| yield f"data: {err_payload}\n\n" | |
| finally: | |
| yield "data: [DONE]\n\n" | |
| return StreamingResponse(event_stream(), media_type="text/event-stream") | |
| class ClarifyRequest(BaseModel): | |
| user_response: str | |
| def response_not_empty(cls, v: str) -> str: | |
| if not v.strip(): | |
| raise ValueError("user_response cannot be empty") | |
| return v.strip() | |
| async def clarify_intent( | |
| run_id: str, | |
| req: ClarifyRequest, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> StreamingResponse: | |
| """ | |
| Resume a pipeline that paused waiting for user clarification. | |
| The original intent is amended with the user's response and the pipeline | |
| restarts from IntentAgent with the enriched intent. Returns SSE. | |
| """ | |
| params = _pending_clarifications.pop(run_id, None) | |
| if params is not None and params.get("_expires_at", 0) < time.monotonic(): | |
| params = None # expired — treat as not found | |
| if params is None: | |
| raise HTTPException( | |
| 404, | |
| f"No pending clarification found for run_id={run_id}. " | |
| "The session may have timed out or already been resumed." | |
| ) | |
| original_intent = params["message"] | |
| amended_intent = ( | |
| f"{original_intent}\n\n" | |
| f"User clarification: {req.user_response}" | |
| ) | |
| file_id = params.get("file_id") | |
| overrides = params.get("hyperparameter_overrides", {}) | |
| hf_token = params.get("hf_token") | |
| dataset_path = _resolve_file_id(file_id) | |
| _agents_import() | |
| async def clarify_stream(): | |
| try: | |
| from agents.pipeline import TrainingPipeline | |
| from services.run_event_writer import write_agent_event, write_pipeline_checkpoint | |
| pipeline = TrainingPipeline() | |
| async for result, context in pipeline.run_streaming( | |
| user_intent=amended_intent, | |
| dataset_path=dataset_path, | |
| hyperparameter_overrides=overrides, | |
| hf_token=hf_token, | |
| run_id=run_id, | |
| ): | |
| data = json.dumps({ | |
| "agent": result.agent_name, | |
| "success": result.success, | |
| "message": result.message, | |
| "output": result.output, | |
| "metadata": result.metadata, | |
| }) | |
| yield f"data: {data}\n\n" | |
| await write_agent_event( | |
| run_id=run_id, | |
| agent_name=result.agent_name, | |
| success=result.success, | |
| message=result.message, | |
| output=result.output, | |
| ) | |
| if result.success: | |
| await write_pipeline_checkpoint( | |
| run_id=run_id, | |
| completed_stages=list(context.completed_stages), | |
| checkpoint_data=pipeline.context_snapshot(context), | |
| ) | |
| if not result.success: | |
| break | |
| except Exception as exc: | |
| logger.error("Clarify pipeline error: %s", exc, exc_info=True) | |
| yield f"data: {json.dumps({'agent': 'System', 'success': False, 'message': str(exc), 'output': {}})}\n\n" | |
| finally: | |
| yield "data: [DONE]\n\n" | |
| return StreamingResponse(clarify_stream(), media_type="text/event-stream") | |
| async def start_training_job_deprecated() -> None: | |
| """ | |
| Deprecated legacy training endpoint. | |
| Use POST /chat with the agent pipeline instead. | |
| """ | |
| raise HTTPException( | |
| status_code=410, | |
| detail="This endpoint is deprecated. Use POST /chat with the agent pipeline for training.", | |
| ) | |
| def _agents_import(): | |
| agents_path = Path(__file__).parent.parent / "agents" | |
| if str(agents_path) not in sys.path: | |
| sys.path.insert(0, str(agents_path)) | |
| async def cancel_training( | |
| run_id: str, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> dict[str, str]: | |
| """Signal an active training run to stop at the next step boundary.""" | |
| _assert_run_owner(run_id, user) | |
| _agents_import() | |
| from agents.train_agent import cancel_run | |
| found = cancel_run(run_id) | |
| if not found: | |
| raise HTTPException(404, f"No active training run found for run_id={run_id}") | |
| return {"status": "cancelling", "run_id": run_id} | |
| async def pause_training( | |
| run_id: str, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> dict[str, str]: | |
| """Block training at the next step boundary.""" | |
| _assert_run_owner(run_id, user) | |
| _agents_import() | |
| from agents.train_agent import pause_run, is_paused | |
| if is_paused(run_id): | |
| raise HTTPException(409, "Training is already paused.") | |
| found = pause_run(run_id) | |
| if not found: | |
| raise HTTPException(404, f"No active training run found for run_id={run_id}") | |
| return {"status": "paused", "run_id": run_id} | |
| async def resume_training( | |
| run_id: str, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> dict[str, str]: | |
| """Unblock a paused training run.""" | |
| _assert_run_owner(run_id, user) | |
| _agents_import() | |
| from agents.train_agent import resume_run, is_paused | |
| if not is_paused(run_id): | |
| raise HTTPException(409, "Training is not paused.") | |
| found = resume_run(run_id) | |
| if not found: | |
| raise HTTPException(404, f"No active training run found for run_id={run_id}") | |
| return {"status": "resumed", "run_id": run_id} | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Hyperparameter Sweep | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| _SWEEP_MAX_RUNS = 12 # hard cap to prevent runaway sweeps | |
| class SweepConfig(BaseModel): | |
| lr_values: list[float] = [] | |
| batch_values: list[int] = [] | |
| epoch_values: list[int] = [] | |
| lora_r_values: list[int] = [] | |
| class SweepRequest(BaseModel): | |
| message: str | |
| file_id: str | None = None | |
| hf_token: str | None = None | |
| parent_run_id: str | None = None | |
| hyperparameter_overrides: dict[str, Any] = {} | |
| sweep_config: SweepConfig | |
| def message_not_empty(cls, v: str) -> str: | |
| if not v.strip(): | |
| raise ValueError("message cannot be empty") | |
| return v.strip() | |
| def _build_sweep_combos(cfg: SweepConfig, base: dict[str, Any]) -> list[dict[str, Any]]: | |
| """Cartesian product of all non-empty sweep_config lists merged into base overrides.""" | |
| import itertools | |
| axes: list[tuple[str, list[Any]]] = [] | |
| if cfg.lr_values: | |
| axes.append(("learning_rate", cfg.lr_values)) | |
| if cfg.batch_values: | |
| axes.append(("batch_size", cfg.batch_values)) | |
| if cfg.epoch_values: | |
| axes.append(("num_epochs", cfg.epoch_values)) | |
| if cfg.lora_r_values: | |
| axes.append(("lora_r", cfg.lora_r_values)) | |
| if not axes: | |
| return [dict(base)] | |
| keys = [k for k, _ in axes] | |
| values = [v for _, v in axes] | |
| return [ | |
| {**base, **dict(zip(keys, combo))} | |
| for combo in itertools.product(*values) | |
| ] | |
| async def _run_sweep_child( | |
| run_id: str, | |
| message: str, | |
| dataset_path: str | None, | |
| overrides: dict[str, Any], | |
| hf_token: str | None, | |
| sweep_id: str, | |
| sweep_config_combo: dict[str, Any], | |
| ) -> None: | |
| """Train one child run of a sweep. Updates Supabase run record on completion.""" | |
| _agents_import() | |
| from agents.pipeline import TrainingPipeline | |
| from services.run_event_writer import write_agent_event, write_pipeline_checkpoint | |
| supabase_url = os.getenv("SUPABASE_URL") or os.getenv("NEXT_PUBLIC_SUPABASE_URL") | |
| supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") | |
| sb = None | |
| if supabase_url and supabase_key: | |
| try: | |
| from supabase import create_client | |
| sb = create_client(supabase_url, supabase_key) | |
| except Exception: | |
| pass | |
| if sb: | |
| sb.table("runs").update({"status": "running"}).eq("id", run_id).execute() | |
| all_results: list[Any] = [] | |
| pipeline_success = True | |
| try: | |
| pipeline = TrainingPipeline() | |
| async for result, context in pipeline.run_streaming( | |
| user_intent=message, | |
| dataset_path=dataset_path, | |
| hyperparameter_overrides=overrides, | |
| hf_token=hf_token, | |
| run_id=run_id, | |
| ): | |
| all_results.append(result) | |
| await write_agent_event( | |
| run_id=run_id, | |
| agent_name=result.agent_name, | |
| success=result.success, | |
| message=result.message, | |
| output=result.output, | |
| ) | |
| if result.success: | |
| await write_pipeline_checkpoint( | |
| run_id=run_id, | |
| completed_stages=list(context.completed_stages), | |
| checkpoint_data=pipeline.context_snapshot(context), | |
| ) | |
| if not result.success: | |
| pipeline_success = False | |
| break | |
| except Exception as exc: | |
| logger.error("[sweep][%s] child run error: %s", run_id, exc, exc_info=True) | |
| pipeline_success = False | |
| if sb: | |
| intent_out = next((r.output for r in all_results if r.agent_name == "Intent"), {}) | |
| model_out = next((r.output for r in all_results if r.agent_name == "Model"), {}) | |
| train_out = next((r.output for r in all_results if r.agent_name == "Train" and r.output.get("final") is not False), {}) | |
| eval_out = next((r.output for r in all_results if r.agent_name == "Eval"), {}) | |
| m_src = eval_out if eval_out else train_out | |
| try: | |
| sb.table("runs").update({ | |
| "status": "completed" if pipeline_success else "failed", | |
| "task_type": intent_out.get("task_type"), | |
| "model_id": model_out.get("base_model") or intent_out.get("base_model_hint"), | |
| "intent_spec": intent_out, | |
| "model_recipe": model_out, | |
| "metrics": { | |
| "accuracy": m_src.get("accuracy"), | |
| "f1": m_src.get("f1"), | |
| "precision": m_src.get("precision"), | |
| "recall": m_src.get("recall"), | |
| "evaluation_grade": eval_out.get("evaluation_grade"), | |
| "difficulty_tier": eval_out.get("difficulty_tier"), | |
| "grade_rationale": eval_out.get("grade_rationale"), | |
| "summary": eval_out.get("summary"), | |
| "strengths": eval_out.get("strengths"), | |
| "concerns": eval_out.get("concerns"), | |
| "next_steps": eval_out.get("next_steps"), | |
| }, | |
| "artifact_path": train_out.get("artifact_path"), | |
| "sweep_config": sweep_config_combo, | |
| "completed_at": datetime.now(timezone.utc).isoformat(), | |
| }).eq("id", run_id).execute() | |
| except Exception as exc: | |
| logger.warning("[sweep][%s] failed to update run record: %s", run_id, exc) | |
| async def launch_sweep( | |
| req: SweepRequest, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> dict[str, Any]: | |
| """ | |
| Launch N parallel training runs with different hyperparameter combos. | |
| Returns immediately with sweep_id and run_ids. | |
| Each child run updates its Supabase row when it completes. | |
| """ | |
| combos = _build_sweep_combos(req.sweep_config, req.hyperparameter_overrides) | |
| if not combos: | |
| raise HTTPException(400, "sweep_config must specify at least one parameter list (lr_values, batch_values, epoch_values, or lora_r_values)") | |
| if len(combos) > _SWEEP_MAX_RUNS: | |
| raise HTTPException( | |
| 400, | |
| f"Sweep would produce {len(combos)} runs (max {_SWEEP_MAX_RUNS}). " | |
| "Reduce the number of values per parameter." | |
| ) | |
| _enforce_quota(user, new_runs=len(combos)) | |
| sweep_id = str(uuid.uuid4()) | |
| dataset_path = _resolve_file_id(req.file_id) | |
| # Create one Supabase run row per combo | |
| supabase_url = os.getenv("SUPABASE_URL") or os.getenv("NEXT_PUBLIC_SUPABASE_URL") | |
| supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") | |
| sb = None | |
| if supabase_url and supabase_key: | |
| try: | |
| from supabase import create_client | |
| sb = create_client(supabase_url, supabase_key) | |
| except Exception: | |
| pass | |
| # runs.user_id is a NOT NULL uuid FK to auth.users — only the backend can | |
| # create rows when it knows the authenticated owner. Without a verified user | |
| # (permissive rollout phase) the frontend is responsible for creating the | |
| # rows, and the child runs below will UPDATE them by id. | |
| owner_id = user.get("id") if user else None | |
| run_ids: list[str] = [] | |
| for combo in combos: | |
| run_id = str(uuid.uuid4()) | |
| if sb and owner_id: | |
| try: | |
| result = sb.table("runs").insert({ | |
| "id": run_id, | |
| "user_id": owner_id, | |
| "status": "pending", | |
| "sweep_id": sweep_id, | |
| "parent_run_id": req.parent_run_id, | |
| "sweep_config": combo, | |
| }).execute() | |
| # Use the DB-assigned id if available | |
| if result.data: | |
| run_id = result.data[0].get("id", run_id) | |
| except Exception as exc: | |
| logger.warning("[sweep] failed to create run row: %s", exc) | |
| run_ids.append(run_id) | |
| # Fire all child runs concurrently (fire-and-forget) | |
| import asyncio as _asyncio | |
| for run_id, combo in zip(run_ids, combos): | |
| _asyncio.ensure_future( | |
| _run_sweep_child( | |
| run_id=run_id, | |
| message=req.message, | |
| dataset_path=dataset_path, | |
| overrides=combo, | |
| hf_token=req.hf_token, | |
| sweep_id=sweep_id, | |
| sweep_config_combo=combo, | |
| ) | |
| ) | |
| logger.info("[sweep:%s] launched %d child runs", sweep_id, len(run_ids)) | |
| return {"sweep_id": sweep_id, "run_ids": run_ids, "total": len(run_ids)} | |
| async def get_status(job_id: str) -> dict[str, Any]: | |
| """Deprecated — training progress is streamed via SSE on POST /chat.""" | |
| raise HTTPException( | |
| 410, | |
| "This endpoint is deprecated. Training progress is streamed in real-time " | |
| "via Server-Sent Events on POST /chat — connect to that stream instead.", | |
| ) | |
| class InferRequest(BaseModel): | |
| run_id: str | |
| text: str | |
| artifact_path: str | |
| label_names: list[str] = [] | |
| def text_not_empty(cls, v: str) -> str: | |
| if not v.strip(): | |
| raise ValueError("text cannot be empty") | |
| return v.strip() | |
| def run_id_not_empty(cls, v: str) -> str: | |
| if not v.strip(): | |
| raise ValueError("run_id cannot be empty") | |
| return v.strip() | |
| def artifact_path_not_empty(cls, v: str) -> str: | |
| if not v.strip(): | |
| raise ValueError("artifact_path cannot be empty") | |
| return v.strip() | |
| def _validate_artifact_path(artifact_path: str, run_id: str) -> Path: | |
| """ | |
| Security check: artifact_path must resolve to a path within RUNS_DIR. | |
| Prevents path traversal attacks where a user supplies an arbitrary filesystem path. | |
| """ | |
| resolved = Path(artifact_path).resolve() | |
| runs_resolved = RUNS_DIR.resolve() | |
| # Also allow paths relative to the agents directory (agent pipeline output) | |
| agents_runs = (Path(__file__).parent.parent / "agents" / "runs").resolve() | |
| def _within(child: Path, parent: Path) -> bool: | |
| # True if child is the dir itself or genuinely nested under it. | |
| # Path containment (not string prefix) so a sibling like `runs_evil` | |
| # cannot satisfy the check by sharing a name prefix. | |
| return child == parent or parent in child.parents | |
| if not (_within(resolved, runs_resolved) or _within(resolved, agents_runs)): | |
| logger.warning( | |
| "Rejected artifact_path outside RUNS_DIR: run_id=%s path=%s", | |
| run_id, artifact_path, | |
| ) | |
| raise HTTPException(422, "Invalid model artifact path.") | |
| return resolved | |
| async def run_inference( | |
| req: InferRequest, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> dict[str, Any]: | |
| """Run classification inference on a trained model.""" | |
| import asyncio | |
| from services.inference_cache import cache | |
| _assert_run_owner(req.run_id, user) | |
| if len(req.text) > 2000: | |
| raise HTTPException( | |
| 400, | |
| f"Text is too long ({len(req.text)} chars). Maximum 2000 characters.", | |
| ) | |
| # Security: validate artifact_path is within the known runs directory | |
| _validate_artifact_path(req.artifact_path, req.run_id) | |
| try: | |
| result = await cache.predict( | |
| run_id=req.run_id, | |
| text=req.text, | |
| artifact_path=req.artifact_path, | |
| label_names=req.label_names, | |
| ) | |
| return result | |
| except FileNotFoundError as exc: | |
| raise HTTPException(422, str(exc)) from exc | |
| except asyncio.TimeoutError as exc: | |
| raise HTTPException(504, str(exc)) from exc | |
| except RuntimeError as exc: | |
| logger.error("Inference runtime error for run %s: %s", req.run_id, exc, exc_info=True) | |
| raise HTTPException(500, "Model inference failed. Please try again.") from exc | |
| except Exception as exc: | |
| logger.error("Inference error for run %s: %s", req.run_id, exc, exc_info=True) | |
| raise HTTPException(500, "An unexpected error occurred during inference.") from exc | |
| class ExportRequest(BaseModel): | |
| run_id: str | |
| artifact_path: str | |
| format: Literal["onnx", "torchscript"] = "onnx" | |
| opset_version: int = 14 | |
| optimize: bool = True | |
| def not_empty(cls, v: str) -> str: | |
| if not v.strip(): | |
| raise ValueError("field cannot be empty") | |
| return v.strip() | |
| async def export_model_endpoint( | |
| req: ExportRequest, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> FileResponse: | |
| """Convert a trained model to ONNX or TorchScript and return as a download.""" | |
| import asyncio | |
| import shutil | |
| _assert_run_owner(req.run_id, user) | |
| validated_path = _validate_artifact_path(req.artifact_path, req.run_id) | |
| from services.model_exporter import export_model | |
| try: | |
| out_file: Path = await asyncio.to_thread( | |
| export_model, | |
| artifact_path=str(validated_path), | |
| export_format=req.format, | |
| opset_version=req.opset_version, | |
| optimize=req.optimize, | |
| ) | |
| except FileNotFoundError as exc: | |
| raise HTTPException(422, str(exc)) from exc | |
| except RuntimeError as exc: | |
| logger.error("Export failed for run %s: %s", req.run_id, exc, exc_info=True) | |
| raise HTTPException(500, f"Export failed: {exc}") from exc | |
| except Exception as exc: | |
| logger.error("Unexpected export error for run %s: %s", req.run_id, exc, exc_info=True) | |
| raise HTTPException(500, "An unexpected error occurred during export.") from exc | |
| suffix = ".onnx" if req.format == "onnx" else ".pt" | |
| filename = f"model_{req.run_id[:8]}{suffix}" | |
| tmp_parent = out_file.parent | |
| def cleanup() -> None: | |
| shutil.rmtree(tmp_parent, ignore_errors=True) | |
| return FileResponse( | |
| path=str(out_file), | |
| media_type="application/octet-stream", | |
| filename=filename, | |
| background=BackgroundTask(cleanup), | |
| ) | |
| async def get_models( | |
| task_type: str | None = None, | |
| category: str | None = None, | |
| max_params_m: int | None = None, | |
| provider: str | None = None, | |
| lora_only: bool = False, | |
| q: str | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Return filtered model catalog.""" | |
| _agents_import() | |
| from agents.model_catalog import filter_catalog | |
| results = filter_catalog( | |
| task_type=task_type, | |
| category=category, | |
| max_params_m=max_params_m, | |
| provider=provider, | |
| lora_only=lora_only, | |
| ) | |
| if q: | |
| ql = q.lower() | |
| results = [ | |
| m for m in results | |
| if ql in m["display_name"].lower() | |
| or ql in m["description"].lower() | |
| or ql in m.get("best_for", "").lower() | |
| or ql in m["model_id"].lower() | |
| or any(ql in tag for tag in m.get("tags", [])) | |
| ] | |
| return results | |
| async def get_leaderboard(limit: int = 50) -> list[dict[str, Any]]: | |
| """ | |
| Community leaderboard: completed runs aggregated by model_id, ranked by best F1. | |
| Uses the service-role key so it reads across all users (RLS bypassed intentionally — | |
| only anonymised aggregate stats are returned, no user_id or run_id exposed). | |
| Returns [] gracefully when Supabase is not configured. | |
| """ | |
| from collections import defaultdict | |
| supabase_url = os.getenv("SUPABASE_URL") or os.getenv("NEXT_PUBLIC_SUPABASE_URL") | |
| supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") | |
| if not supabase_url or not supabase_key: | |
| return [] | |
| try: | |
| from supabase import create_client | |
| sb = create_client(supabase_url, supabase_key) | |
| except Exception as exc: | |
| logger.warning("[leaderboard] supabase init failed: %s", exc) | |
| return [] | |
| try: | |
| result = ( | |
| sb.table("runs") | |
| .select("model_id, task_type, metrics, completed_at") | |
| .eq("status", "completed") | |
| .not_.is_("model_id", "null") | |
| .order("completed_at", desc=True) | |
| .limit(2000) | |
| .execute() | |
| ) | |
| except Exception as exc: | |
| logger.warning("[leaderboard] query failed: %s", exc) | |
| return [] | |
| runs = result.data or [] | |
| # Aggregate per model_id | |
| agg: dict[str, dict[str, Any]] = defaultdict(lambda: { | |
| "f1_scores": [], | |
| "accuracy_scores": [], | |
| "task_types": set(), | |
| "run_count": 0, | |
| "last_run_at": None, | |
| }) | |
| for run in runs: | |
| mid = run.get("model_id") | |
| if not mid: | |
| continue | |
| metrics = run.get("metrics") or {} | |
| f1 = metrics.get("f1") | |
| acc = metrics.get("accuracy") | |
| task = run.get("task_type") | |
| finished = run.get("completed_at") | |
| bucket = agg[mid] | |
| bucket["run_count"] += 1 | |
| if isinstance(f1, (int, float)): | |
| bucket["f1_scores"].append(float(f1)) | |
| if isinstance(acc, (int, float)): | |
| bucket["accuracy_scores"].append(float(acc)) | |
| if task: | |
| bucket["task_types"].add(task) | |
| if finished and (bucket["last_run_at"] is None or finished > bucket["last_run_at"]): | |
| bucket["last_run_at"] = finished | |
| # Merge with catalog metadata | |
| _agents_import() | |
| from agents.model_catalog import get_model | |
| entries: list[dict[str, Any]] = [] | |
| for model_id, stats in agg.items(): | |
| cat = get_model(model_id) or {} | |
| f1s = stats["f1_scores"] | |
| accs = stats["accuracy_scores"] | |
| entries.append({ | |
| "model_id": model_id, | |
| "display_name": cat.get("display_name", model_id), | |
| "category": cat.get("category", "unknown"), | |
| "provider": cat.get("provider", "unknown"), | |
| "param_count": cat.get("param_count", ""), | |
| "quality_tier": cat.get("quality_tier", ""), | |
| "lora_compatible": cat.get("lora_compatible", False), | |
| "run_count": stats["run_count"], | |
| "best_f1": round(max(f1s), 4) if f1s else None, | |
| "avg_f1": round(sum(f1s) / len(f1s), 4) if f1s else None, | |
| "avg_accuracy": round(sum(accs) / len(accs), 4) if accs else None, | |
| "task_types": sorted(stats["task_types"]), | |
| "last_run_at": stats["last_run_at"], | |
| }) | |
| # Sort: best_f1 desc (None last), then run_count desc as tiebreaker | |
| entries.sort(key=lambda e: (e["best_f1"] is None, -(e["best_f1"] or 0), -e["run_count"])) | |
| for i, entry in enumerate(entries[:limit], 1): | |
| entry["rank"] = i | |
| return entries[:limit] | |
| async def download_training_script( | |
| run_id: str, | |
| user: dict[str, Any] | None = Depends(get_current_user), | |
| ) -> StreamingResponse: | |
| """ | |
| Generate and download a standalone Python training script for a completed run. | |
| Loads the pipeline checkpoint from Supabase (task_spec + model_recipe + | |
| training_result), generates a copy-paste-runnable script via code_generator, | |
| and returns it as a .py file download. | |
| """ | |
| from services.run_event_writer import load_pipeline_checkpoint | |
| _assert_run_owner(run_id, user) | |
| checkpoint = await load_pipeline_checkpoint(run_id) | |
| if not checkpoint: | |
| raise HTTPException( | |
| 404, | |
| f"No pipeline checkpoint found for run_id={run_id}. " | |
| "The run may not have completed or Supabase is not configured." | |
| ) | |
| task_spec = checkpoint.get("task_spec") or {} | |
| data_profile = checkpoint.get("data_profile") or {} | |
| model_recipe = checkpoint.get("model_recipe") or {} | |
| training_result = checkpoint.get("training_result") or {} | |
| if not model_recipe or not training_result: | |
| raise HTTPException( | |
| 422, | |
| "Run did not complete training — no model recipe or training result available." | |
| ) | |
| _agents_import() | |
| from agents.services.code_generator import generate_training_script, CodeGenerationError | |
| try: | |
| script = generate_training_script( | |
| task_spec=task_spec, | |
| data_profile=data_profile, | |
| model_recipe=model_recipe, | |
| training_result=training_result, | |
| ) | |
| except CodeGenerationError as exc: | |
| logger.error("Script generation failed for run %s: %s", run_id, exc) | |
| raise HTTPException(500, f"Could not generate training script: {exc}") from exc | |
| filename = f"train_{run_id[:8]}.py" | |
| return StreamingResponse( | |
| iter([script]), | |
| media_type="text/x-python", | |
| headers={"Content-Disposition": f"attachment; filename={filename}"}, | |
| ) | |
| async def health() -> dict[str, str]: | |
| return {"status": "ok", "version": app.version} | |