provinans / frontend /src /api.ts
reversely's picture
Upload folder using huggingface_hub
9fbe4b7 verified
Raw
History Blame Contribute Delete
18.4 kB
import { getToken } from "./auth";
import type {
CaseDetail,
CaseReviewData,
ConceptRegistry,
CoverageResponse,
DashboardSummary,
DataDictionary,
DictionaryOption,
Escalation,
ExportResult,
FieldChatRequest,
FieldChatResponse,
FieldDecisionContext,
FieldSpec,
IngestionJob,
IngestionRunRequest,
IngestionRunResult,
MappingChatRequest,
MappingChatResponse,
MappingConfirmRequest,
MappingReview,
Project,
ProjectList,
StagingEditionOption,
TextSourceOption,
TraceResponse,
VisualEvidence,
WorklistRow,
} from "./types";
// Origin of the API. Two-port local dev talks cross-origin to :8000; the
// deployed build sets VITE_API_BASE="" so every call is same-origin and
// relative.
const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8000";
// The backend namespaces its JSON routes under /api because three of this
// app's client-side routes (/dashboard, /export, /cases/:barcode) would
// otherwise be the same paths the API serves.
const API_ROOT = `${API_BASE}/api`;
export class UnauthorizedError extends Error {
constructor() {
super("Your session has expired or you are signed out. Log in again to run extraction or save changes.");
this.name = "UnauthorizedError";
}
}
// The account a successful login resolves to (#128): the signed session token to send on writes, and the
// account's own name, role, and licence, which every confirmation is then attributed to.
export interface LoginResult {
token: string;
username: string;
display_name: string;
role: string;
holds_licence: boolean;
}
// Exchange a username and password for a session token (#128). A dedicated fetch, not `request`: it sends
// no prior token, and it reads the specific 401 (bad credentials) and 409 (auth disabled) apart from a
// generic failure so the login form can say which happened.
export async function login(username: string, password: string): Promise<LoginResult> {
const resp = await fetch(`${API_ROOT}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (resp.status === 401) throw new Error("Invalid username or password.");
if (resp.status === 409) throw new Error("This deployment runs without accounts; no login is needed.");
if (!resp.ok) throw new Error(`Login failed (${resp.status}).`);
return resp.json() as Promise<LoginResult>;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
// Sent on every call, including the open reads, where the backend ignores it.
const headers = new Headers(init?.headers);
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const resp = await fetch(`${API_ROOT}${path}`, { ...init, headers });
if (resp.status === 401) {
throw new UnauthorizedError();
}
if (!resp.ok) {
const body = await resp.text();
throw new Error(`${init?.method ?? "GET"} ${path} failed (${resp.status}): ${body}`);
}
return resp.json() as Promise<T>;
}
// The checklist definition, served from endopath.fields (#37). Static for the
// life of the backend process; ChecklistFieldsProvider fetches it once.
export function fetchFields(): Promise<FieldSpec[]> {
return request<FieldSpec[]>("/fields");
}
// The canonical concept list (#91) with its registry evidence, relationships,
// and decisions joined on (#35), for the registry browser (#94). An open read.
export function fetchConcepts(): Promise<ConceptRegistry> {
return request<ConceptRegistry>("/concepts");
}
// The crosswalk mapping-review surface (#95, #109): the drafted edges from a
// source dictionary's fields to the canonical concepts, the reviewer queue, and
// the confirmations so far. Omit `dictionary` for the built-in CAP reference; pass
// a submitted dictionary id for its drafted crosswalk. An open read.
export function fetchMapping(dictionary?: string): Promise<MappingReview> {
const qs = dictionary ? `?dictionary=${encodeURIComponent(dictionary)}` : "";
return request<MappingReview>(`/mapping${qs}`);
}
// The dictionaries a reviewer can open on the mapping screen (#109): the built-in
// CAP checklist always, then any submitted dictionary, each flagged with whether
// its crosswalk has been drafted. An open read.
export function fetchDictionaries(): Promise<DictionaryOption[]> {
return request<DictionaryOption[]>("/dictionaries");
}
// Record one reviewer's decision on a crosswalk edge (#103): the disposition,
// the resolved concept and relation, the confirmer and their role from the
// session, and the ledger context (how the answer was reached, the question, the
// options). Any edge is decidable, not only the queued ones. Returns the
// refreshed surface.
export function confirmMappingEdge(req: MappingConfirmRequest): Promise<MappingReview> {
return request<MappingReview>("/mapping/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
}
// Ask the engine about one crosswalk edge (#103): it answers with its reasoning
// and the evidence it read, and may suggest a concept and relation to file. A
// write (spends the Anthropic key), so it needs the reviewer token.
export function askMappingEdge(req: MappingChatRequest): Promise<MappingChatResponse> {
return request<MappingChatResponse>("/mapping/edge/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
}
// The projects ("repos") a reviewer can open (#..). An open read: the switcher renders before anyone
// signs in. `default` is the id to open when the reviewer has chosen none.
export function fetchProjects(): Promise<ProjectList> {
return request<ProjectList>("/projects");
}
// Create a project (admin only). A write, so it carries the session token `request` sets from getToken();
// the backend rejects a non-admin token. Returns the created project (case_count 0).
export function createProject(name: string): Promise<Project> {
return request<Project>("/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
}
export interface WorklistFilters {
status?: string;
fieldName?: string;
fieldStatus?: string;
maxConfidence?: number;
// Scope the worklist to one project ("repo"); absent returns every case.
project?: string;
}
export function fetchWorklist(filters: WorklistFilters = {}): Promise<WorklistRow[]> {
const params = new URLSearchParams();
if (filters.status) params.set("status", filters.status);
if (filters.fieldName) params.set("field_name", filters.fieldName);
if (filters.fieldStatus) params.set("field_status", filters.fieldStatus);
if (filters.maxConfidence !== undefined) {
params.set("max_confidence", String(filters.maxConfidence));
}
if (filters.project) params.set("project", filters.project);
const qs = params.toString();
return request<WorklistRow[]>(`/cases${qs ? `?${qs}` : ""}`);
}
export function fetchCase(caseBarcode: string): Promise<CaseDetail> {
return request<CaseDetail>(`/cases/${encodeURIComponent(caseBarcode)}`);
}
// The selectable text sources (issue #90). An open read: the Intake picker
// renders before a reviewer has a token.
export function fetchTextSources(): Promise<TextSourceOption[]> {
return request<TextSourceOption[]>("/ingestion/sources");
}
// The built-in CAP checklist as a dictionary, the default target an Intake
// submission points at.
export function fetchBuiltinDictionary(): Promise<DataDictionary> {
return request<DataDictionary>("/ingestion/dictionary");
}
// The follow-up jobs Intake has enqueued (report induction #92, dictionary
// compilation #91), awaiting their workers.
export function fetchIngestionJobs(): Promise<IngestionJob[]> {
return request<IngestionJob[]>("/ingestion/jobs");
}
// Intake action: point the tool at a report source and a target dictionary
// through the selected text source, then trigger ingestion and the follow-up
// jobs. Runs the prepared corpus against the built-in checklist when passed no
// overrides.
export function triggerIngestion(req?: IngestionRunRequest): Promise<IngestionRunResult> {
return request<IngestionRunResult>("/ingestion/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req ?? { source: { kind: "prepared_corpus" } }),
});
}
// The case-review confirmation surface (#98): the record grouped into sections as cards, each carrying
// its B1 validation block (#97), plus the decision ledger. An open read.
export function fetchCaseReview(caseBarcode: string): Promise<CaseReviewData> {
return request<CaseReviewData>(`/cases/${encodeURIComponent(caseBarcode)}/review`);
}
// Confirm one field on the fast path. `decision` carries the reviewer-session identity and ledger
// context; when present, the confirm writes a decision-ledger row (#98).
export function confirmField(
caseBarcode: string,
fieldName: string,
decision?: FieldDecisionContext,
): Promise<CaseDetail> {
return request<CaseDetail>(
`/cases/${encodeURIComponent(caseBarcode)}/fields/${encodeURIComponent(fieldName)}/confirm`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(decision ?? {}),
},
);
}
// Route a field to the licensed reviewer's queue (#161). A write, so it carries the session token.
export function escalateField(
caseBarcode: string,
fieldName: string,
decision?: FieldDecisionContext,
): Promise<CaseDetail> {
return request<CaseDetail>(
`/cases/${encodeURIComponent(caseBarcode)}/fields/${encodeURIComponent(fieldName)}/escalate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(decision ?? {}),
},
);
}
// The escalation queue: fields awaiting the licensed account, across cases (#161). An open read.
// The live model-call trace (#221): recent LLM calls plus the running aggregate, for the transparency panel.
export function fetchTrace(limit = 100): Promise<TraceResponse> {
return request<TraceResponse>(`/trace?limit=${limit}`);
}
// The field coverage view (#237): a project's target dictionary and the record types that fulfil it,
// DB-backed. An open read; the page computes live fill from the dashboard and cohort endpoints.
export function fetchCoverage(project?: string): Promise<CoverageResponse> {
const qs = project ? `?project=${encodeURIComponent(project)}` : "";
return request<CoverageResponse>(`/coverage${qs}`);
}
export function fetchEscalations(project?: string): Promise<{ escalations: Escalation[] }> {
const qs = project ? `?project=${encodeURIComponent(project)}` : "";
return request<{ escalations: Escalation[] }>(`/escalations${qs}`);
}
// One longitudinal record: a GDC clinical event around this patient's pathology report. Typed here rather
// than in types.ts to stay out of a parallel edit; the completeness panel is the only consumer.
export interface LongitudinalRecord {
record_id: string;
record_type: string;
record_index: number;
days_to_followup: number | null;
days_to_treatment: number | null;
days_to_recurrence: number | null;
therapy_type: string | null;
recurrence_type: string | null;
}
// The GDC longitudinal records for a case, an open read like the rest of the reads.
export function fetchLongitudinalRecords(
caseBarcode: string,
): Promise<{ records: LongitudinalRecord[] }> {
return request<{ records: LongitudinalRecord[] }>(
`/longitudinal/records/${encodeURIComponent(caseBarcode)}`,
);
}
// A reviewer's confirm/reject of one longitudinal record's linkage to the case (#180).
export interface LongitudinalJoin {
record_id: string;
disposition: string;
confirmed_by: string | null;
role: string | null;
record_type: string;
}
// The confirmed/rejected joins for a case, an open read.
export function fetchLongitudinalJoins(
caseBarcode: string,
): Promise<{ joins: LongitudinalJoin[] }> {
return request<{ joins: LongitudinalJoin[] }>(
`/longitudinal/joins/${encodeURIComponent(caseBarcode)}`,
);
}
// Affirm the whole patient record in one action (#180): every GDC record joined to the case is confirmed,
// attributed to the signed-in reviewer. A write, so `request` carries the session token; a flagged record
// stays rejected.
export function confirmLinkage(
caseBarcode: string,
): Promise<{ confirmed: number; rejected: number; total_records: number; role: string | null }> {
return request(`/longitudinal/confirm-linkage/${encodeURIComponent(caseBarcode)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
}
// Flag one longitudinal record as not belonging to the case (#180): a single-record reject, reusing the
// per-record join endpoint. A write, so it carries the session token.
export function flagLongitudinalRecord(
caseBarcode: string,
recordId: string,
): Promise<{ disposition: string }> {
return request(`/longitudinal/join/${encodeURIComponent(caseBarcode)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ record_id: recordId, disposition: "rejected" }),
});
}
// The cohort-level longitudinal outcomes for the dashboard module (#181): recurrence, survival, and
// adjuvant-treatment counts over the cases the GDC join reached, from the same projection the export uses.
export interface LongitudinalCohortSummary {
cases: number;
recurrence: { observed: number; none: number; unknown: number };
survival: { dead: number; alive: number; unknown: number };
treatment: { chemotherapy: number; radiation: number; hormone: number };
median_follow_up_days: number | null;
median_overall_survival_days: number | null;
follow_up_days: number[];
}
// An open read like the rest of the cohort reads.
export function fetchLongitudinalCohort(project?: string): Promise<LongitudinalCohortSummary> {
const qs = project ? `?project=${encodeURIComponent(project)}` : "";
return request<LongitudinalCohortSummary>(`/longitudinal/cohort${qs}`);
}
// Clear the integrated demo's own slice so a run starts fresh (the molecular mapping resolution, a
// case's field decisions when given, or the case's GDC linkage joins with `clearLongitudinal` so the
// patient-record beat starts from an unconfirmed panel). A demo convenience, open like the reads.
export function demoReset(
caseBarcode?: string,
seedEscalation = false,
fieldName?: string,
clearLongitudinal = false,
): Promise<{ reset: boolean }> {
const params = new URLSearchParams();
if (caseBarcode) params.set("case_barcode", caseBarcode);
if (seedEscalation) params.set("seed_escalation", "true");
if (fieldName) params.set("field_name", fieldName);
if (clearLongitudinal) params.set("clear_longitudinal", "true");
const qs = params.toString();
return request<{ reset: boolean }>(`/demo/reset${qs ? `?${qs}` : ""}`, { method: "POST" });
}
// Edit one field on the exception path: override its value, and record the edit to the decision ledger
// with the reviewer-session identity (#98).
export function editField(
caseBarcode: string,
fieldName: string,
value: unknown,
decision?: FieldDecisionContext,
): Promise<CaseDetail> {
return request<CaseDetail>(
`/cases/${encodeURIComponent(caseBarcode)}/fields/${encodeURIComponent(fieldName)}/edit`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value, decision }),
},
);
}
// Ask the engine about one extracted value (#98): it answers with its reasoning and the evidence it
// read, and may suggest a value to file. A write (spends the Anthropic key), so it needs the token.
export function askFieldChat(
caseBarcode: string,
fieldName: string,
req: FieldChatRequest,
): Promise<FieldChatResponse> {
return request<FieldChatResponse>(
`/cases/${encodeURIComponent(caseBarcode)}/fields/${encodeURIComponent(fieldName)}/chat`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
},
);
}
// Run first-pass inference (LLM extraction) on a queued case, advancing it to
// Ready for Review with extracted values.
export function processCase(caseBarcode: string): Promise<CaseDetail> {
return request<CaseDetail>(
`/cases/${encodeURIComponent(caseBarcode)}/process`,
{ method: "POST" },
);
}
// On-demand ColPali visual retrieval for one field: which report page most
// likely carries the evidence. Cheap after the first field of a case (page
// embeddings are cached server-side) but the first call embeds the case's
// pages, which is slow on a CPU-only backend.
export function fetchVisualEvidence(
caseBarcode: string,
fieldName: string,
): Promise<VisualEvidence> {
return request<VisualEvidence>(
`/cases/${encodeURIComponent(caseBarcode)}/fields/${encodeURIComponent(fieldName)}/visual_evidence`,
);
}
// Consumed as an <img src>, which cannot carry an Authorization header. That's
// why the backend leaves page images on the open side of the auth gate.
export function pageImageUrl(caseBarcode: string, pageNumber: number): string {
return `${API_ROOT}/cases/${encodeURIComponent(caseBarcode)}/pages/${pageNumber}`;
}
export function fetchDashboard(project?: string): Promise<DashboardSummary> {
const qs = project ? `?project=${encodeURIComponent(project)}` : "";
return request<DashboardSummary>(`/dashboard${qs}`);
}
// The cohort export (#101), with the FIGO stage re-projected under a chosen edition (#40). Omit
// `stagingEdition` to project each case under its own recorded edition. Scope to a project (#149).
export function fetchExport(stagingEdition?: string, project?: string): Promise<ExportResult> {
const params = new URLSearchParams();
if (stagingEdition) params.set("staging_edition", stagingEdition);
if (project) params.set("project", project);
const qs = params.toString();
return request<ExportResult>(`/export${qs ? `?${qs}` : ""}`);
}
// The FIGO editions the Export target-schema selector offers (#40). An open read.
export function fetchStagingEditions(): Promise<StagingEditionOption[]> {
return request<StagingEditionOption[]>("/staging_editions");
}