/** Talentry API client. * * All network calls go through this module so we can: * • centralise base-URL configuration (Vite proxy in dev, same-origin in * production behind the FastAPI static mount); * • parse `application/json` *and* the FastAPI `{detail: ...}` error shape; * • surface schema-validation failures (HTTP 422 from /api/rank) as a * structured object - the UI uses this to render a git diff style * report instead of a generic "request failed" toast. */ import type { ParseResumesResponse, RankErrorResponse, RankResponse, ValidateResponse, } from "./types"; const BASE = import.meta.env.VITE_API_BASE ?? ""; const DEFAULT_TIMEOUT_MS = 60_000; // ── Internal helpers ─────────────────────────────────────────────────────── async function _fetch( url: string, init: RequestInit & { timeoutMs?: number } = {} ): Promise { const { timeoutMs = DEFAULT_TIMEOUT_MS, ...rest } = init; const ctrl = new AbortController(); const timer = window.setTimeout(() => ctrl.abort(), timeoutMs); try { return await fetch(url, { ...rest, signal: ctrl.signal }); } finally { window.clearTimeout(timer); } } export class ApiError extends Error { status: number; body?: unknown; constructor(message: string, status: number, body?: unknown) { super(message); this.name = "ApiError"; this.status = status; this.body = body; } } /** Parse a possibly-JSON error body and throw a typed ApiError. */ async function _raise(res: Response): Promise { let body: unknown; const ct = res.headers.get("content-type") || ""; try { body = ct.includes("application/json") ? await res.json() : await res.text(); } catch { body = undefined; } // FastAPI default: { detail: "..." } let message = res.statusText || `HTTP ${res.status}`; if (body && typeof body === "object") { const obj = body as Record; if (typeof obj.detail === "string") message = obj.detail; else if (typeof obj.message === "string") message = obj.message as string; } else if (typeof body === "string" && body) { message = body; } throw new ApiError(message, res.status, body); } // ── Public endpoints ─────────────────────────────────────────────────────── export interface HealthResponse { status: string; version: string; max_upload_mb: number; cached_sessions: number; rank_cache_size: number; } export async function getHealth(): Promise { const res = await _fetch(`${BASE}/api/health`); if (!res.ok) await _raise(res); return res.json(); } export async function getSchema(): Promise { const res = await _fetch(`${BASE}/api/schema`); if (!res.ok) await _raise(res); return res.json(); } export async function getSampleCandidates(limit = 10): Promise { const res = await _fetch(`${BASE}/api/sample?limit=${limit}`); if (!res.ok) await _raise(res); return res.json(); } export function sampleDownloadUrl(): string { return `${BASE}/api/sample/download`; } export interface RankOptions { candidates?: File | null; jd?: File | null; useSample?: boolean; topK?: number; skipValidation?: boolean; } /** Returned by `postRank` when the backend rejected the upload for schema. */ export class SchemaValidationError extends ApiError { payload: RankErrorResponse; constructor(payload: RankErrorResponse) { super(payload.message || "Schema validation failed", 422, payload); this.name = "SchemaValidationError"; this.payload = payload; } } export async function postRank(opts: RankOptions): Promise { const fd = new FormData(); if (opts.candidates) fd.append("candidates", opts.candidates); if (opts.jd) fd.append("jd", opts.jd); const params = new URLSearchParams(); if (opts.useSample) params.set("use_sample", "true"); if (opts.topK) params.set("top_k", String(opts.topK)); if (opts.skipValidation) params.set("skip_validation", "true"); const url = `${BASE}/api/rank${params.toString() ? `?${params}` : ""}`; const res = await _fetch(url, { method: "POST", body: fd, timeoutMs: 15 * 60_000, // ranking the full 100k pool can take ~90 s }); if (res.status === 422) { const body = (await res.json().catch(() => null)) as RankErrorResponse | null; if (body && body.error === "schema_validation_failed") { throw new SchemaValidationError(body); } throw new ApiError(body?.message || "Validation failed", 422, body); } if (!res.ok) await _raise(res); return res.json(); } export async function postValidate(opts: { candidates?: File | null; useSample?: boolean; }): Promise { const fd = new FormData(); if (opts.candidates) fd.append("candidates", opts.candidates); const params = new URLSearchParams(); if (opts.useSample) params.set("use_sample", "true"); const url = `${BASE}/api/validate${params.toString() ? `?${params}` : ""}`; const res = await _fetch(url, { method: "POST", body: fd }); if (!res.ok) await _raise(res); return res.json(); } export async function postParseResumes(files: File[]): Promise { if (!files.length) throw new ApiError("no resume files supplied", 400); const fd = new FormData(); for (const f of files) fd.append("files", f); const res = await _fetch(`${BASE}/api/parse-resumes`, { method: "POST", body: fd, timeoutMs: 2 * 60_000, }); if (!res.ok) await _raise(res); return res.json(); } export function csvDownloadUrl(session: string): string { return `${BASE}/api/submission.csv?session=${encodeURIComponent(session)}`; } export function xlsxDownloadUrl(session: string): string { return `${BASE}/api/submission.xlsx?session=${encodeURIComponent(session)}`; }