From 8a16b596170daeb2c4cbad25634eeb29cd4eb12a Mon Sep 17 00:00:00 2001 From: David Meijer Date: Tue, 28 Jul 2026 18:23:21 -0400 Subject: [PATCH 1/2] WIP: re-adding BGC parsing --- .../workspace/ClusterReadoutRows.tsx | 118 ++++++++++++++++++ .../components/workspace/DialogViewItem.tsx | 40 +++++- .../workspace/WorkspaceDiscovery.tsx | 2 +- .../workspace/WorkspaceItemCard.tsx | 51 +++++++- .../components/workspace/WorkspaceUpload.tsx | 2 +- gui/src/client/src/features/clusters/api.ts | 22 ++++ gui/src/client/src/features/clusters/types.ts | 71 +++++++++++ gui/src/server/app.py | 9 +- gui/src/server/requirements.backend.txt | 4 +- gui/src/server/routes/jobs.py | 100 ++++++++++++++- 10 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 gui/src/client/src/components/workspace/ClusterReadoutRows.tsx create mode 100644 gui/src/client/src/features/clusters/api.ts create mode 100644 gui/src/client/src/features/clusters/types.ts diff --git a/gui/src/client/src/components/workspace/ClusterReadoutRows.tsx b/gui/src/client/src/components/workspace/ClusterReadoutRows.tsx new file mode 100644 index 0000000..2255cc3 --- /dev/null +++ b/gui/src/client/src/components/workspace/ClusterReadoutRows.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import { horizontalScrollSx } from "../../theme/scrollbarSx"; +import type { ClusterModule, ClusterReadout } from "../../features/clusters/types"; + +// Mirrors the PKS extender-unit classification in retromol_antismash/modules.py +// (PKSModule.substrate): that logic isn't serialized server-side, so it's +// recomputed here from the same anatomy flags for display purposes only. +function pksExtenderLabel(anatomy: Extract["anatomy"]): string { + const KR = anatomy.has_active_KR; + const DH = anatomy.has_active_DH; + const ER = anatomy.has_active_ER; + const effDH = DH && KR; + const effER = ER && KR && DH; + + if (!KR) return "PKS_A"; + if (!effDH) return "PKS_B"; + if (!effER) return "PKS_C"; + return "PKS_D"; +} + +function ModuleChip({ module }: { module: ClusterModule }) { + const isNRPS = module.type === "NRPS"; + const substrateName = isNRPS ? module.predicted_substrate?.name ?? null : null; + const hasConfidentSubstrate = isNRPS && !!substrateName && substrateName !== "unknown"; + + const label = isNRPS + ? (hasConfidentSubstrate ? substrateName! : "unknown substrate") + : pksExtenderLabel(module.anatomy); + + const tooltipLines = [ + `${module.type} module ${module.module_index_in_gene + 1} in ${module.gene_id} (${module.gene_strand} strand)`, + `domains: ${module.present_domains.join(", ") || "none"}`, + isNRPS && module.predicted_substrate?.score != null + ? `prediction confidence: ${(module.predicted_substrate.score * 100).toFixed(1)}%` + : null, + ].filter(Boolean).join("\n"); + + return ( + {tooltipLines}} arrow> + + + {module.type} · {module.gene_id} + + + {label} + + + + ); +} + +// Read-only view of a gene cluster's parsed linear module readout(s) -- one row +// per antiSMASH region, each a horizontal chain of NRPS/PKS module chips. Unlike +// compounds, there's no "reconstruction" step here: the payload already IS the +// final module chain, so it's rendered directly rather than fetched separately. +export function ClusterReadoutRows({ readouts }: { readouts: ClusterReadout[] }) { + if (readouts.length === 0) { + return ( + + No antiSMASH regions were found in this file. + + ); + } + + return ( + + {readouts.map((readout, idx) => ( + + + {readout.id} ({readout.modules.length} module{readout.modules.length === 1 ? "" : "s"}) + + + {readout.modules.length === 0 ? ( + + No NRPS/PKS modules were detected in this region. + + ) : ( + + {readout.modules.map((module, mIdx) => ( + + ))} + + )} + + ))} + + ); +} diff --git a/gui/src/client/src/components/workspace/DialogViewItem.tsx b/gui/src/client/src/components/workspace/DialogViewItem.tsx index 8562d5e..72a390e 100644 --- a/gui/src/client/src/components/workspace/DialogViewItem.tsx +++ b/gui/src/client/src/components/workspace/DialogViewItem.tsx @@ -6,10 +6,12 @@ import Typography from "@mui/material/Typography"; import { useQuery } from "@tanstack/react-query"; import { Session, SessionItem } from "../../features/session/types"; import { reconstructCompound } from "../../features/reconstruction/api"; +import { getClusterReadout } from "../../features/clusters/api"; import { DialogWindow } from "../DialogWindow"; import { ErrorBoundary } from "../ErrorBoundary"; import SmilesDrawerContainer from "../SmilesDrawerContainer.js"; import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor"; +import { ClusterReadoutRows } from "./ClusterReadoutRows"; type HighlightAtom = [number, string]; @@ -122,6 +124,12 @@ export const DialogViewItem: React.FC = ({ ? (reconstructionQuery.error as Error).message || "Unknown error" : null; + const clusterReadoutQuery = useQuery({ + queryKey: ["getClusterReadout", sessionId, item.id], + queryFn: ({ signal }) => getClusterReadout(sessionId, item.id, signal), + enabled: open && !isCompound && item.status === "done", + }); + // resetSignal is `open` -- re-seeding on every open (even for the same item) // matches the dialog's existing "fresh state each time" behavior for selectedTags. const editor = usePrimarySequenceEditor(session, setSession, item, data, open); @@ -165,10 +173,34 @@ export const DialogViewItem: React.FC = ({ ]} maxWidth={"lg"} > - {!isCompound && ( - - Viewing is only available for compounds. - + {!isCompound && item.kind === "cluster" && ( + <> + {item.status === "queued" && ( + + Waiting to be parsed... + + )} + + {item.status === "processing" && } + + {item.status === "error" && ( + + {item.errorMessage || "Parsing failed."} + + )} + + {item.status === "done" && clusterReadoutQuery.isLoading && } + + {item.status === "done" && clusterReadoutQuery.error && ( + + {(clusterReadoutQuery.error as Error).message || "Failed to load parsed gene cluster."} + + )} + + {item.status === "done" && clusterReadoutQuery.data && ( + + )} + )} {loading && ( diff --git a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx index d682290..be015e3 100644 --- a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx @@ -448,7 +448,7 @@ export const WorkspaceDiscovery: React.FC = ({ session {clusterCount > 0 && ( - {clusterCount} gene cluster upload(s) aren't shown here — primary-sequence picking isn't available for BGCs yet. + {clusterCount} gene cluster upload(s) aren't shown here: primary-sequence picking isn't available for BGCs yet. )} diff --git a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx index fd8057f..214bef5 100644 --- a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx @@ -22,7 +22,9 @@ import { alpha } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import { DialogViewItem } from "./DialogViewItem"; import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor"; +import { ClusterReadoutRows } from "./ClusterReadoutRows"; import { reconstructCompound } from "../../features/reconstruction/api"; +import { getClusterReadout } from "../../features/clusters/api"; import { useTick } from "../../hooks/useTick"; function getScoreColor(theme: Theme, value: number): string { @@ -116,6 +118,15 @@ export const WorkspaceItemCard: React.FC = ({ const reconstructions = reconstructionQuery.data ?? null; const editor = usePrimarySequenceEditor(session, setSession, item, reconstructions); + // Same idea as reconstructionQuery, but for gene clusters -- the parsed module + // readout is computed server-side at submit time and only ever handed over via + // this dedicated endpoint (item.payload is stripped from the session itself). + const clusterReadoutQuery = useQuery({ + queryKey: ["getClusterReadout", session.sessionId, item.id], + queryFn: ({ signal }) => getClusterReadout(session.sessionId, item.id, signal), + enabled: expanded && !isCompound && isDone, + }); + return ( <> = ({ )} { event.stopPropagation(); if (disabled) return; @@ -313,11 +324,11 @@ export const WorkspaceItemCard: React.FC = ({ > - + { e.stopPropagation(); if (disabled) return; @@ -390,6 +401,40 @@ export const WorkspaceItemCard: React.FC = ({ )} + + {!isCompound && ( + + e.stopPropagation()} sx={{ pt: 0.5 }}> + + + {isQueued && ( + + Waiting to be parsed... + + )} + + {showSpinner && } + + {isError && ( + + Parsing failed -- see the error above for details. + + )} + + {isDone && clusterReadoutQuery.isLoading && } + + {isDone && clusterReadoutQuery.error && ( + + {(clusterReadoutQuery.error as Error).message || "Failed to load parsed gene cluster."} + + )} + + {isDone && clusterReadoutQuery.data && ( + + )} + + + )} = ({ session, setSe Import compounds - diff --git a/gui/src/client/src/features/clusters/api.ts b/gui/src/client/src/features/clusters/api.ts new file mode 100644 index 0000000..c739a98 --- /dev/null +++ b/gui/src/client/src/features/clusters/api.ts @@ -0,0 +1,22 @@ +import { postJson } from "../http"; +import { ClusterPayload, GetClusterReadoutRespSchema } from "./types"; + +// A gene cluster's parsed linear module readout(s) are computed once at submit +// time and stashed server-side on the item -- but `payload` is deliberately +// stripped from items before they reach the client as part of the session (see +// strip_property_from_dict in session_store.py), same as a compound's `payload` +// only ever reaching the client via /api/reconstructCompound. This is the +// gene-cluster equivalent of that endpoint. +export async function getClusterReadout( + sessionId: string, + itemId: string, + signal?: AbortSignal +): Promise { + const resp = await postJson( + "/api/getClusterReadout", + { sessionId, itemId }, + GetClusterReadoutRespSchema, + signal + ); + return resp.data; +} diff --git a/gui/src/client/src/features/clusters/types.ts b/gui/src/client/src/features/clusters/types.ts new file mode 100644 index 0000000..16035a9 --- /dev/null +++ b/gui/src/client/src/features/clusters/types.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +// Predicted substrate for an NRPS (A-domain) module, as returned by the PARAS model. +export const ClusterSubstrateSchema = z.object({ + name: z.string().nullable(), + smiles: z.string().nullable(), + score: z.number().nullable(), +}); + +export const ClusterNRPSAnatomySchema = z.object({ + has_C: z.boolean(), + has_T: z.boolean(), + has_E: z.boolean(), + has_MT: z.boolean(), + has_Ox: z.boolean(), + has_R: z.boolean(), + has_TE: z.boolean(), +}); + +export const ClusterPKSAnatomySchema = z.object({ + AT_loading_mode: z.enum(["cis", "trans", "unknown"]), + has_active_KR: z.boolean(), + has_active_DH: z.boolean(), + has_active_ER: z.boolean(), +}); + +const ClusterModuleBaseSchema = z.object({ + module_index_in_gene: z.number(), + start: z.number(), + end: z.number(), + gene_id: z.string(), + gene_strand: z.enum(["+", "-"]), + present_domains: z.array(z.string()), +}); + +export const ClusterModuleSchema = z.discriminatedUnion("type", [ + ClusterModuleBaseSchema.extend({ + type: z.literal("NRPS"), + anatomy: ClusterNRPSAnatomySchema, + predicted_substrate: ClusterSubstrateSchema.nullable().optional(), + }), + ClusterModuleBaseSchema.extend({ + type: z.literal("PKS"), + anatomy: ClusterPKSAnatomySchema, + }), +]); + +// One antiSMASH region's linear module readout. +export const ClusterReadoutSchema = z.object({ + id: z.string(), + file_name: z.string(), + start: z.number(), + end: z.number(), + qualifiers: z.record(z.string(), z.any()).optional(), + modules: z.array(ClusterModuleSchema), + modifiers: z.array(z.string()), +}); + +export const ClusterPayloadSchema = z.object({ + readouts: z.array(ClusterReadoutSchema).default([]), +}); + +export const GetClusterReadoutRespSchema = z.object({ + ok: z.boolean().optional(), + status: z.string().optional(), + data: ClusterPayloadSchema, +}); + +export type ClusterModule = z.output; +export type ClusterReadout = z.output; +export type ClusterPayload = z.output; diff --git a/gui/src/server/app.py b/gui/src/server/app.py index 4215d1a..d4bd36d 100644 --- a/gui/src/server/app.py +++ b/gui/src/server/app.py @@ -16,7 +16,13 @@ ) from routes.session_store import get_or_init_app_start_epoch from routes.database import check_database_ready, duckdb_path_from_env -from routes.jobs import blp_search_compound, blp_submit_compound, blp_reconstruct_compound, blp_submit_gene_cluster +from routes.jobs import ( + blp_search_compound, + blp_submit_compound, + blp_reconstruct_compound, + blp_submit_gene_cluster, + blp_get_cluster_readout, +) from routes.events import blp_events, blp_sse_ticket from routes.discovery import blp_discovery_monomer_names, blp_discovery_query, blp_discovery_msa, get_discovery_context @@ -144,6 +150,7 @@ def ready() -> tuple[dict[str, str], int]: app.register_blueprint(blp_submit_compound) app.register_blueprint(blp_reconstruct_compound) app.register_blueprint(blp_submit_gene_cluster) +app.register_blueprint(blp_get_cluster_readout) app.register_blueprint(blp_events) app.register_blueprint(blp_sse_ticket) diff --git a/gui/src/server/requirements.backend.txt b/gui/src/server/requirements.backend.txt index 8f8cb8a..36cd801 100644 --- a/gui/src/server/requirements.backend.txt +++ b/gui/src/server/requirements.backend.txt @@ -7,4 +7,6 @@ redis>=5.0 scikit-learn umap-learn tqdm -duckdb \ No newline at end of file +duckdb +biopython +pyhmmer \ No newline at end of file diff --git a/gui/src/server/routes/jobs.py b/gui/src/server/routes/jobs.py index 66b56b1..68578fa 100644 --- a/gui/src/server/routes/jobs.py +++ b/gui/src/server/routes/jobs.py @@ -1,6 +1,9 @@ """Module for defining job endpoints.""" +import os +import tempfile import time +from pathlib import Path from flask import Response, Blueprint, current_app, request, jsonify @@ -13,16 +16,39 @@ from retromol.pipelines.parsing import run_retromol from retromol_synthesis.reconstruction import reconstruct_linear_readout +from retromol_antismash.io import parse_antismash_gbk, AntiSmashOptions +from retromol_antismash.modules import linear_readout, ModuleType +from retromol_antismash.inference.registry import annotate_region, register_domain_model +from retromol_antismash.inference.model_paras import ParasModel + blp_search_compound = Blueprint("search_compound", __name__) blp_submit_compound = Blueprint("submit_compound", __name__) blp_reconstruct_compound = Blueprint("reconstruct_compound", __name__) blp_submit_gene_cluster = Blueprint("submit_gene_cluster", __name__) +blp_get_cluster_readout = Blueprint("get_cluster_readout", __name__) DEFAULT_LIMIT = 10 MAX_LIMIT = 50 +def _ensure_paras_model_registered() -> None: + """ + Register the PARAS A-domain substrate specificity model, if not already registered. + + .. note:: + `register_domain_model` no-ops if a model with the same name is already + registered, so this is safe to call on every request. Constructing + `ParasModel` here is cheap -- the actual model file is only downloaded/loaded + lazily, the first time `.predict()` runs. No gene-level (PFAM/HMM) model is + registered here, since gene-level classification isn't part of this feature. + """ + register_domain_model(ParasModel( + model_path=os.getenv("PARAS_MODEL_PATH"), + cache_dir=os.getenv("PARAS_CACHE_DIR", "paras_cache"), + )) + + @blp_search_compound.get("/api/searchCompound") def search_compound_by_name(): """ @@ -283,9 +309,45 @@ def mark_processing(it: dict) -> None: return jsonify({"error": "Item not found during update"}), 404 try: - # Fill this in with your BGC parsing / fingerprinting / scoring logic. - result_payload = {} - score = None + _ensure_paras_model_registered() + + tmp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile(mode="w", suffix=".gbk", delete=False) as tmp: + tmp.write(file_content) + tmp_path = Path(tmp.name) + + regions = parse_antismash_gbk(tmp_path, AntiSmashOptions()) + finally: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + + if not regions: + raise ValueError( + "No antiSMASH regions found in this GenBank file. " + "Make sure it is a GenBank file annotated by antiSMASH." + ) + + readouts = [] + for region in regions: + annotate_region(region) + readouts.append(linear_readout(region)) + + # Rough analog of a compound's parse "coverage" score: fraction of modules + # with a confident identification. PKS modules are classified directly from + # domain anatomy (no ML model involved), so they always count as confident; + # NRPS (A-domain) modules only count if PARAS resolved a non-"unknown" + # substrate. Without this, a PKS-only cluster would score 0%/blank even + # though every module parsed correctly, since PARAS never runs on PKS. + all_modules = [m for r in readouts for m in r.modules] + confident_modules = [ + m for m in all_modules + if m.type == ModuleType.PKS + or (m.predicted_substrate and m.predicted_substrate.name != "unknown") + ] + score = (len(confident_modules) / len(all_modules)) if all_modules else None + + result_payload = {"readouts": [r.to_dict() for r in readouts]} def mark_done(it: dict) -> None: it["name"] = name or it.get("name") @@ -310,3 +372,35 @@ def mark_error(it: dict) -> None: elapsed = int((time.time() - t0) * 1000) return jsonify({"ok": True, "status": "done", "elapsed_ms": elapsed}), 200 + + +@blp_get_cluster_readout.post("/api/getClusterReadout") +def get_cluster_readout() -> tuple[Response, int]: + """ + Endpoint for fetching a gene cluster's parsed linear module readout(s). + + .. note:: + `payload` is deliberately stripped from items before they're sent to the + client as part of the session (see `strip_property_from_dict` in + session_store.py), the same way a compound's `payload` never reaches the + client directly -- only via `/api/reconstructCompound`. This is the + gene-cluster equivalent of that endpoint. + """ + payload = request.get_json(force=True) or {} + + session_id = payload.get("sessionId") + item_id = payload.get("itemId") + + full_sess = load_session_with_items(session_id) + if full_sess is None: + return jsonify({"error": "Session not found"}), 404 + + item = next((it for it in full_sess.get("items", []) if it.get("id") == item_id), None) + if item is None: + return jsonify({"error": "Item not found"}), 404 + + if item.get("kind") != "cluster": + return jsonify({"error": "Item is not a gene cluster"}), 400 + + data = item.get("payload") or {"readouts": []} + return jsonify({"ok": True, "status": "done", "data": data}), 200 From e13a95950bbdf27700f175258b06acf154d7f0e5 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Tue, 28 Jul 2026 23:02:15 -0400 Subject: [PATCH 2/2] ENH: upload and parse GenBank files --- .../workspace/DialogImportGeneClusters.tsx | 38 ++- .../workspace/WorkspaceDiscovery.tsx | 128 +++++++--- .../workspace/WorkspaceItemCard.tsx | 9 + .../components/workspace/WorkspaceUpload.tsx | 5 +- gui/src/client/src/features/clusters/api.ts | 19 +- gui/src/client/src/features/clusters/types.ts | 17 ++ gui/src/client/src/features/jobs/api.ts | 6 +- gui/src/client/src/features/session/types.ts | 4 + gui/src/server/app.py | 2 + gui/src/server/routes/discovery.py | 232 +++++++++++++----- gui/src/server/routes/jobs.py | 112 +++++++-- src/retromol/model/rules.py | 21 ++ src/retromol_antismash/inference/registry.py | 25 +- src/retromol_antismash/modules.py | 89 ++++++- 14 files changed, 587 insertions(+), 120 deletions(-) diff --git a/gui/src/client/src/components/workspace/DialogImportGeneClusters.tsx b/gui/src/client/src/components/workspace/DialogImportGeneClusters.tsx index 416026f..daee2ac 100644 --- a/gui/src/client/src/components/workspace/DialogImportGeneClusters.tsx +++ b/gui/src/client/src/components/workspace/DialogImportGeneClusters.tsx @@ -3,12 +3,16 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Button from "@mui/material/Button"; import MuiLink from "@mui/material/Link"; +import Slider from "@mui/material/Slider"; import { DialogWindow } from "../DialogWindow"; +// Matches ParasModel's own default (retromol_antismash.inference.model_paras). +const DEFAULT_PARAS_THRESHOLD = 0.1; + type DialogImportGeneClusterProps = { open: boolean; onClose: () => void; - onImport: (files: File[]) => void; + onImport: (files: File[], parasThreshold: number) => void; } export const DialogImportGeneCluster: React.FC = ({ @@ -17,12 +21,16 @@ export const DialogImportGeneCluster: React.FC = ( onImport, }) => { const [gbkFiles, setGbkFiles] = React.useState([]); + const [parasThreshold, setParasThreshold] = React.useState(DEFAULT_PARAS_THRESHOLD); const canImport = gbkFiles.length > 0; - const reset = () => setGbkFiles([]); + const reset = () => { + setGbkFiles([]); + setParasThreshold(DEFAULT_PARAS_THRESHOLD); + }; const handleImport = () => { - onImport(gbkFiles); + onImport(gbkFiles, parasThreshold); reset(); onClose(); } @@ -62,6 +70,30 @@ export const DialogImportGeneCluster: React.FC = ( {gbkFiles.length} file(s) selected )} + + + + PARAS substrate confidence threshold: {parasThreshold.toFixed(2)} + + + Minimum prediction probability required to call a substrate for an NRPS module. Lower surfaces more (lower-confidence) predictions; higher keeps only the most confident ones. Doesn't affect PKS modules, which are classified directly from domain anatomy. + + setParasThreshold(value as number)} + min={0} + max={1} + step={0.01} + marks={[ + { value: 0, label: "0" }, + { value: DEFAULT_PARAS_THRESHOLD, label: "default" }, + { value: 1, label: "1" }, + ]} + valueLabelDisplay="auto" + valueLabelFormat={(value) => value.toFixed(2)} + sx={{ maxWidth: 360 }} + /> + ) diff --git a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx index be015e3..6ff4b17 100644 --- a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx @@ -12,6 +12,7 @@ import CircularProgress from "@mui/material/CircularProgress"; import Collapse from "@mui/material/Collapse"; import Divider from "@mui/material/Divider"; import FormControlLabel from "@mui/material/FormControlLabel"; +import ListSubheader from "@mui/material/ListSubheader"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import ToggleButton from "@mui/material/ToggleButton"; @@ -26,6 +27,8 @@ import { useTheme, alpha, type Theme } from "@mui/material/styles"; import { Session, SessionItem } from "../../features/session/types"; import { reconstructCompound } from "../../features/reconstruction/api"; import type { PrimarySequenceItem } from "../../features/reconstruction/types"; +import { reconstructGeneCluster } from "../../features/clusters/api"; +import type { ClusterPrimarySequence } from "../../features/clusters/types"; import { runDiscoveryQuery, runDiscoveryMsa } from "../../features/discovery/api"; import { DISCOVERY_SCORE_MODE_OPTIONS, @@ -52,9 +55,16 @@ function blocksFromNames(names: string[]): SequenceBlock[] { // Read-only preview of a candidate reconstruction path, for picking which one seeds the editor. function ReconstructionPreview({ sequence }: { sequence: PrimarySequenceItem[] }) { + return name)} />; +} + +// Read-only preview of one BGC region's primary sequence, for picking which one seeds +// the editor. Unlike a compound reconstruction there are no per-block tags to carry, +// just names -- see retromol_antismash.modules.bgc_primary_sequence. +function NamesPreview({ names }: { names: string[] }) { return ( - {sequence.map(([name], idx) => ( + {names.map((name, idx) => ( = ({ session const { pushNotification } = useNotifications(); const compoundItems = session.items.filter((item): item is SessionItem & { kind: "compound" } => item.kind === "compound"); - const clusterCount = session.items.length - compoundItems.length; + const clusterItems = session.items.filter((item): item is SessionItem & { kind: "cluster" } => item.kind === "cluster"); const [selectedItemId, setSelectedItemId] = React.useState(""); - const selectedItem = compoundItems.find((item) => item.id === selectedItemId); + const selectedItem = session.items.find((item) => item.id === selectedItemId); const [blocks, setBlocks] = React.useState([]); const [entryType, setEntryType] = React.useState("compound"); @@ -346,7 +356,13 @@ export const WorkspaceDiscovery: React.FC = ({ session const reconstructionQuery = useQuery({ queryKey: ["reconstructCompound", session.sessionId, selectedItemId], queryFn: ({ signal }) => reconstructCompound(session.sessionId, selectedItemId, signal), - enabled: selectedItemId.length > 0, + enabled: selectedItem?.kind === "compound", + }); + + const clusterReconstructionQuery = useQuery({ + queryKey: ["reconstructGeneCluster", session.sessionId, selectedItemId], + queryFn: ({ signal }) => reconstructGeneCluster(session.sessionId, selectedItemId, signal), + enabled: selectedItem?.kind === "cluster", }); const discoveryMutation = useMutation({ @@ -357,8 +373,8 @@ export const WorkspaceDiscovery: React.FC = ({ session scoreMode, n, topX, - includeUserUploads: (includeUserUploads || onlyUserUploads) && entryType !== "bgc", - onlyUserUploads: onlyUserUploads && entryType !== "bgc", + includeUserUploads: includeUserUploads || onlyUserUploads, + onlyUserUploads, sessionId: session.sessionId, }), onError: (err) => { @@ -427,8 +443,8 @@ export const WorkspaceDiscovery: React.FC = ({ session }); }, [msaQuery.data, discoveryMutation.data]); - const handlePickReconstruction = (sequence: PrimarySequenceItem[]) => { - setBlocks(blocksFromNames(sequence.map(([name]) => name))); + const handlePickNames = (names: string[]) => { + setBlocks(blocksFromNames(names)); }; const maxTopX = Math.max(1, Math.min(n, MAX_TOP_X)); @@ -443,37 +459,40 @@ export const WorkspaceDiscovery: React.FC = ({ session Pick a starting sequence - Seed the editor below from one of your uploaded compounds, or build a sequence from scratch by adding blocks directly. + Seed the editor below from one of your uploaded compounds or gene clusters, or build a sequence from scratch by adding blocks directly. - {clusterCount > 0 && ( - - {clusterCount} gene cluster upload(s) aren't shown here: primary-sequence picking isn't available for BGCs yet. - - )} - - {compoundItems.length === 0 ? ( + {compoundItems.length === 0 && clusterItems.length === 0 ? ( - No compounds uploaded yet. Import one from the Upload tab first. + Nothing uploaded yet. Import a compound or gene cluster from the Upload tab first. ) : ( setSelectedItemId(e.target.value)} sx={{ minWidth: 260 }} > - {compoundItems.map((item) => ( - - {item.name} {item.status !== "done" ? `(${item.status})` : ""} - - ))} + {[ + ...(compoundItems.length > 0 ? [Compounds] : []), + ...compoundItems.map((item) => ( + + {item.name} {item.status !== "done" ? `(${item.status})` : ""} + + )), + ...(clusterItems.length > 0 ? [Gene clusters] : []), + ...clusterItems.map((item) => ( + + {item.name} {item.status !== "done" ? `(${item.status})` : ""} + + )), + ]} )} - {selectedItemId && ( + {selectedItem?.kind === "compound" && ( {reconstructionQuery.isLoading && } {reconstructionQuery.error && ( @@ -491,7 +510,7 @@ export const WorkspaceDiscovery: React.FC = ({ session // Prefer whatever was saved for this reconstruction in the Upload // tab's viewer over the raw algorithm output, so a correction made // there is what actually gets queried here. - const override = selectedItem?.editedPrimarySequences?.[String(idx)]; + const override = selectedItem.editedPrimarySequences?.[String(idx)]; const effectiveSequence = override ?? reconstruction.primary_sequence; return ( @@ -512,7 +531,11 @@ export const WorkspaceDiscovery: React.FC = ({ session {override && ( )} - @@ -521,6 +544,47 @@ export const WorkspaceDiscovery: React.FC = ({ session )} + + {selectedItem?.kind === "cluster" && ( + + {clusterReconstructionQuery.isLoading && } + {clusterReconstructionQuery.error && ( + + {(clusterReconstructionQuery.error as Error).message || "Failed to load gene cluster readout."} + + )} + {clusterReconstructionQuery.data && clusterReconstructionQuery.data.length === 0 && ( + + No antiSMASH regions found for this gene cluster. + + )} + + {(clusterReconstructionQuery.data ?? []).map((region: ClusterPrimarySequence) => ( + + + {region.id} + + + + + ))} + + + )} @@ -610,13 +674,11 @@ export const WorkspaceDiscovery: React.FC = ({ session size="small" checked={includeUserUploads || onlyUserUploads} onChange={(e) => setIncludeUserUploads(e.target.checked)} - disabled={ - discoveryMutation.isPending || entryType === "bgc" || compoundItems.length === 0 || onlyUserUploads - } + disabled={discoveryMutation.isPending || compoundItems.length === 0 || onlyUserUploads} /> } - label="Include my uploaded compounds" - title="Uploaded compounds compete for a spot among the nearest N candidates, then follow the usual top-X ranking. BGC uploads aren't supported yet." + label="Include my uploads" + title="Uploaded compounds and gene clusters compete for a spot among the nearest N candidates, then follow the usual top-X ranking." /> = ({ session size="small" checked={onlyUserUploads} onChange={(e) => setOnlyUserUploads(e.target.checked)} - disabled={discoveryMutation.isPending || entryType === "bgc" || compoundItems.length === 0} + disabled={discoveryMutation.isPending || compoundItems.length === 0} /> } label="Only use my uploads" - title="Skip the shared database entirely and align only against your own uploaded compounds." + title="Skip the shared database entirely and align only against your own uploaded compounds and gene clusters." />