// Backend client for Milestone 2 real inference. Falls back to the labeled // client-side mock (mockData.runDetectability) when the backend is unreachable, // so the static site still works offline / in a pure-demo deployment. import { parseSequences } from './parseSequences' import { runDetectability, runTraining, runBioactivityPeptides, runAllergenOrigin, type DetectabilityRow, type BioactivityRow, type AllergenRow, type TrainingConfig, type TrainingResult, } from '../data/mockData' import type { RealModel } from '../data/models' const API_BASE = import.meta.env.VITE_API_BASE ?? '/api' export interface PredictOutcome { rows: DetectabilityRow[] live: boolean // true = real backend inference; false = offline mock fallback calibrated: boolean // backend-reported; only meaningful when live } interface PredictResponse { model: string calibrated: boolean results: { peptide: string length: number score: number prediction: string inTraining?: 'positive' | 'negative' | null }[] flags: string[] } /** * Score peptides with a model. Tries the backend first; on any network/HTTP * error returns the deterministic mock so the UI degrades gracefully. */ export async function predictDetectability( input: string, model: RealModel, ): Promise { const peptides = parseSequences(input).map((p) => p.seq) const label = `${model.code} · ${model.species}` try { const res = await fetch(`${API_BASE}/predict`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ peptides, modelCode: model.code }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data: PredictResponse = await res.json() const rows: DetectabilityRow[] = data.results.map((r) => ({ peptide: r.peptide, length: r.length, score: r.score, prediction: r.prediction === 'Detectable' ? 'Detectable' : 'Not detectable', model: label, inTraining: r.inTraining ?? null, })) return { rows, live: true, calibrated: data.calibrated } } catch { // Backend down / not deployed → labeled illustrative mock. return { rows: runDetectability(input, model), live: false, calibrated: false } } } // ---------------------------------------------------------------- bioactivity screening export interface BioactivityOutcome { rows: BioactivityRow[] live: boolean } export interface AllergenOutcome { rows: AllergenRow[] live: boolean } interface BioRespPeptide { peptide: string bioactivities: string sources: string ige_epitope: string } interface BioRespProtein { protein: string is_recognized_allergen: string allergen_name: string organism: string source: string } /** Screen peptides for bioactivity/cytotoxicity + IgE-epitope retention (Q2). */ export async function screenBioactivityPeptides(input: string): Promise { const sequences = parseSequences(input).map((p) => p.seq) try { const res = await fetch(`${API_BASE}/bioactivity`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'peptides', sequences }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data: BioRespPeptide[] = await res.json() const rows: BioactivityRow[] = data.map((r) => ({ peptide: r.peptide, bioactivities: r.bioactivities, sources: r.sources, igeEpitope: r.ige_epitope === 'yes', })) return { rows, live: true } } catch { return { rows: runBioactivityPeptides(input), live: false } } } /** Screen proteins for allergen origin, recognized-allergen protein match (Q1). */ export async function screenAllergenOrigin(input: string): Promise { const sequences = parseSequences(input).map((p) => p.seq) try { const res = await fetch(`${API_BASE}/bioactivity`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'proteins', sequences }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data: BioRespProtein[] = await res.json() const rows: AllergenRow[] = data.map((r) => ({ protein: r.protein, isAllergen: r.is_recognized_allergen === 'yes', allergenName: r.allergen_name, organism: r.organism, source: r.source, })) return { rows, live: true } } catch { return { rows: runAllergenOrigin(input), live: false } } } export interface TrainOutcome { result: TrainingResult live: boolean // true = real backend training; false = offline mock fallback downloadUrl: string | null // GET to fetch the trained joblib (live only) dataUrl: string | null // GET to fetch the processed dataset CSV (live only) species?: string error?: string // set when the backend rejected the request (e.g. unsupported tax id) } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) /** * Train a detectability model from a peptide list. Submits an async job, polls to * completion, and maps to TrainingResult. On any network/HTTP error falls back to the * deterministic client-side mock so the static demo still works offline. */ export async function trainDetectability( input: string, taxId: string, cfg: TrainingConfig, ): Promise { const peptides = parseSequences(input).map((p) => p.seq) try { const res = await fetch(`${API_BASE}/train`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ peptides, taxId, config: cfg }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const { jobId } = (await res.json()) as { jobId: string } // poll (cap ~2 min at 1.5 s intervals) for (let i = 0; i < 80; i++) { await sleep(1500) const s = await fetch(`${API_BASE}/train/${jobId}`) if (!s.ok) throw new Error(`HTTP ${s.status}`) const job = await s.json() if (job.status === 'done') { return { result: job.result as TrainingResult, live: true, downloadUrl: `${API_BASE}/train/${jobId}/model`, dataUrl: `${API_BASE}/train/${jobId}/data`, species: job.result.species, } } if (job.status === 'error') { // A rejected request (bad tax id, unmapped peptides) is a real answer, not a // reason to silently show fake numbers, surface it with the mock as preview. return { result: runTraining(input, taxId + JSON.stringify(cfg)), live: false, downloadUrl: null, dataUrl: null, error: job.error as string, } } } throw new Error('training timed out') } catch { // Backend down / not deployed → labeled illustrative mock. return { result: runTraining(input, taxId + JSON.stringify(cfg)), live: false, downloadUrl: null, dataUrl: null, } } } // ---------------------------------------------------------------- protein digestibility export interface Enzyme { key: string label: string } // Fallback enzyme list so the page still renders if the backend is unreachable (the real // list comes from GET /api/enzymes, sourced from hydropd.api.available_enzymes()). const FALLBACK_ENZYMES: Enzyme[] = [ { key: 'trypsin', label: 'Trypsin' }, { key: 'chymotrypsin', label: 'Chymotrypsin' }, { key: 'pepsin_ph1.3', label: 'Pepsin pH1.3' }, { key: 'pepsin_ph>2', label: 'Pepsin pH >2' }, { key: 'subtilisin', label: 'Subtilisin' }, { key: 'papain', label: 'Papain' }, { key: 'thermolysin', label: 'Thermolysin' }, { key: 'proteinase_k', label: 'Proteinase K' }, ] export async function fetchEnzymes(): Promise { try { const res = await fetch(`${API_BASE}/enzymes`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as Enzyme[] } catch { return FALLBACK_ENZYMES } } export interface DigestSummaryRow { enzyme: string enzyme_key: string candidate_set: number functional_set: number detected_functional_set?: number combined_all_models?: number } export interface DigestTables { candidate: Record[] candidate_total: number functional: Record[] functional_total: number detected: Record[] detected_total: number det_cols: string[] } export interface DigestResult { summary: DigestSummaryRow[] summaryColumns: string[] models: string[] calibrated: Record multi: boolean threshold: number display: Record nProteins: number zipName: string } export interface DigestOutcome { result: DigestResult | null live: boolean zipUrl: string | null error?: string } /** * Run a protein-digestibility screen. Submits an async job, polls to completion (heavy: digestion * + functionality lookup + per-model detectability), and returns the ranked summary + a ZIP URL. * On any network/HTTP error returns a null result flagged offline so the page degrades gracefully. */ export async function computeDigestibility( proteins: string, enzymes: string[], models: string[], threshold: number, ): Promise { try { const res = await fetch(`${API_BASE}/digestibility`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ proteins, enzymes, models, threshold }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const { jobId } = (await res.json()) as { jobId: string } // poll up to ~10 min at 2.5 s intervals (a full all-enzyme, multi-model run is slow) for (let i = 0; i < 240; i++) { await sleep(2500) const s = await fetch(`${API_BASE}/digestibility/${jobId}`) if (!s.ok) throw new Error(`HTTP ${s.status}`) const job = await s.json() if (job.status === 'done') { return { result: job.result as DigestResult, live: true, zipUrl: `${API_BASE}/digestibility/${jobId}/zip`, } } if (job.status === 'error') { return { result: null, live: false, zipUrl: null, error: job.error as string } } } throw new Error('digestibility timed out') } catch (e) { return { result: null, live: false, zipUrl: null, error: (e as Error).message } } }