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 { 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; } async function request(path: string, init?: RequestInit): Promise { // 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; } // The checklist definition, served from endopath.fields (#37). Static for the // life of the backend process; ChecklistFieldsProvider fetches it once. export function fetchFields(): Promise { return request("/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 { return request("/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 { const qs = dictionary ? `?dictionary=${encodeURIComponent(dictionary)}` : ""; return request(`/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 { return request("/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 { return request("/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 { return request("/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 { return request("/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 { return request("/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 { 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(`/cases${qs ? `?${qs}` : ""}`); } export function fetchCase(caseBarcode: string): Promise { return request(`/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 { return request("/ingestion/sources"); } // The built-in CAP checklist as a dictionary, the default target an Intake // submission points at. export function fetchBuiltinDictionary(): Promise { return request("/ingestion/dictionary"); } // The follow-up jobs Intake has enqueued (report induction #92, dictionary // compilation #91), awaiting their workers. export function fetchIngestionJobs(): Promise { return request("/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 { return request("/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 { return request(`/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 { return request( `/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 { return request( `/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 { return request(`/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 { const qs = project ? `?project=${encodeURIComponent(project)}` : ""; return request(`/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 { const qs = project ? `?project=${encodeURIComponent(project)}` : ""; return request(`/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 { return request( `/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 { return request( `/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 { return request( `/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 { return request( `/cases/${encodeURIComponent(caseBarcode)}/fields/${encodeURIComponent(fieldName)}/visual_evidence`, ); } // Consumed as an , 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 { const qs = project ? `?project=${encodeURIComponent(project)}` : ""; return request(`/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 { const params = new URLSearchParams(); if (stagingEdition) params.set("staging_edition", stagingEdition); if (project) params.set("project", project); const qs = params.toString(); return request(`/export${qs ? `?${qs}` : ""}`); } // The FIGO editions the Export target-schema selector offers (#40). An open read. export function fetchStagingEditions(): Promise { return request("/staging_editions"); }