File size: 5,899 Bytes
041816c 729ee5e 041816c 729ee5e 041816c 729ee5e 041816c 729ee5e 041816c cdcf306 041816c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | /** Backend-agnostic diagnose contract.
*
* Two providers, one shape:
* - "zerogpu": Gradio Space endpoint /diagnose via @gradio/client.
* - "vastai": POST ${url}/score multipart (ct_files) -> {scores, rows}.
* No weights or API keys here; the Vast key never leaves the server.
*/
import { Client } from "@gradio/client";
export type BackendKind = "zerogpu" | "vastai";
export interface FindingRow {
finding: string;
score: number;
}
export interface DiagnoseResult {
scores: Record<string, number>;
rows: FindingRow[];
}
export function backendKind(): BackendKind {
const raw = (import.meta.env.PUBLIC_BACKEND_KIND ?? "zerogpu").toLowerCase();
if (raw !== "zerogpu" && raw !== "vastai") throw new Error("Unknown backend");
return raw;
}
export function backendLabel(): string {
return backendKind() === "vastai" ? "Vast.ai" : "ZeroGPU";
}
/** Gradio Dataframe payload varies by version: accept {data}, {headers,data}, or raw rows. */
export function parseDataframe(payload: unknown): FindingRow[] {
let data: unknown = payload;
if (payload !== null && typeof payload === "object" && "data" in payload) {
data = (payload as { data: unknown }).data;
}
if (!Array.isArray(data)) return [];
const rows: FindingRow[] = [];
for (const entry of data) {
if (Array.isArray(entry) && entry.length >= 2) {
// Null/empty scores must be skipped BEFORE Number(): Number(null) is
// 0, which would fabricate a zero-scored finding (NaN cells come back
// null over the API for empty CSV cells).
if (entry[1] === null || entry[1] === undefined || entry[1] === "") continue;
const score = Number(entry[1]);
if (entry[0] != null && entry[0] !== "" && Number.isFinite(score)) {
rows.push({ finding: String(entry[0]), score });
}
}
}
return rows;
}
/** gr.Label arrives as {label, confidences}; older clients may send a flat dict. */
export function parseLabel(payload: unknown): Record<string, number> {
if (payload === null || typeof payload !== "object") return {};
const confidences = (payload as { confidences?: unknown }).confidences;
if (Array.isArray(confidences)) {
const out: Record<string, number> = {};
for (const entry of confidences) {
const label = (entry as { label?: unknown }).label;
const raw = (entry as { confidence?: unknown }).confidence;
if (raw === null || raw === undefined || raw === "") continue;
const confidence = Number(raw);
if (typeof label === "string" && label !== "" && Number.isFinite(confidence)) {
out[label] = confidence;
}
}
return out;
}
const rec = payload as Record<string, unknown>;
const vals = Object.values(rec);
if (
vals.length > 0 &&
vals.every((v) => v !== null && v !== "" && Number.isFinite(Number(v)))
) {
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(rec)) out[k] = Number(v);
return out;
}
return {};
}
export function normalize(
scores: Record<string, number>,
rows: FindingRow[],
): DiagnoseResult {
const clean: Record<string, number> = {};
for (const [k, raw] of Object.entries(scores ?? {})) {
if (raw === null || raw === undefined) continue;
const v: unknown = raw;
if (typeof v === "string" && v.trim() === "") continue;
const n = Number(v);
if (Number.isFinite(n)) clean[k] = n;
}
const sorted = [...rows].sort((a, b) => b.score - a.score);
return { scores: clean, rows: sorted };
}
export async function diagnoseRemote(files: File[]): Promise<DiagnoseResult> {
const kind = backendKind();
const url = import.meta.env.PUBLIC_BACKEND_URL ?? "scomb2/Radar";
if (kind === "vastai") {
const form = new FormData();
for (const f of files) form.append("ct_files", f);
let res: Response;
try {
res = await fetch(`${url}/score`, { method: "POST", body: form });
} catch {
throw new Error("GPU busy or unreachable — Retry");
}
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { detail?: string } | null;
throw new Error(body?.detail ?? "GPU busy or unreachable — Retry");
}
const json = (await res.json()) as { scores: Record<string, number>; rows: FindingRow[] };
return normalize(json.scores, json.rows ?? []);
}
// zerogpu: stable /diagnose endpoint on the headless Space.
let client: Client;
try {
client = await Client.connect(url);
} catch {
throw new Error("GPU busy or unreachable — Retry");
}
let data: unknown[];
try {
// Upload the original Files first: the client's payload walker rewraps
// every File as a nameless Blob, and server intake routes on the file
// extension — so predict() receives explicit FileData with orig_name.
// (upload_files/config are untyped in this client version; the lockfile
// pins the exact build this was verified against.)
const root = client.config?.root ?? url;
const uploaded = await client.upload_files(root, files);
const paths: string[] = uploaded?.files ?? [];
if (paths.length !== files.length) {
throw new Error("GPU busy or unreachable — Retry");
}
const result = await client.predict("/diagnose", {
ct_files: paths.map((path, i) => ({
path,
orig_name: files[i].name,
meta: { _type: "gradio.FileData" },
})),
});
data = result.data as unknown[];
} catch (e) {
// @gradio/client rejects with a status object, not always an Error —
// read message structurally so gr.Error text still renders verbatim.
const message = (e as { message?: unknown } | null | undefined)?.message;
if (typeof message === "string" && message) throw new Error(message);
throw new Error("GPU busy or unreachable — Retry");
}
const rows = parseDataframe(data[1]);
return normalize(parseLabel(data[0]), rows);
}
|