williyam's picture
deploy: sync from talentry-ai @ 22f3ac3
2882028
Raw
History Blame Contribute Delete
6.02 kB
/** 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<Response> {
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<never> {
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<string, unknown>;
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<HealthResponse> {
const res = await _fetch(`${BASE}/api/health`);
if (!res.ok) await _raise(res);
return res.json();
}
export async function getSchema(): Promise<unknown> {
const res = await _fetch(`${BASE}/api/schema`);
if (!res.ok) await _raise(res);
return res.json();
}
export async function getSampleCandidates(limit = 10): Promise<unknown[]> {
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<RankResponse> {
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<ValidateResponse> {
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<ParseResumesResponse> {
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)}`;
}