diff --git a/gui/src/client/src/components/MenuContent.tsx b/gui/src/client/src/components/MenuContent.tsx index af55d2e..725483e 100644 --- a/gui/src/client/src/components/MenuContent.tsx +++ b/gui/src/client/src/components/MenuContent.tsx @@ -10,6 +10,7 @@ import BarChartIcon from "@mui/icons-material/BarChart"; import HomeRoundedIcon from "@mui/icons-material/HomeRounded"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import RuleIcon from "@mui/icons-material/Rule"; +import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import { useNavigate, useLocation } from "react-router-dom"; const mainListItems = [ @@ -33,6 +34,11 @@ const mainListItems = [ icon: , to: `/dashboard/enrichment` }, + { + text: "Generate", + icon: , + to: `/dashboard/generate` + }, { text: "Rules", icon: , diff --git a/gui/src/client/src/components/SmilesDrawerContainer.js b/gui/src/client/src/components/SmilesDrawerContainer.js index 251a72f..6a0700e 100644 --- a/gui/src/client/src/components/SmilesDrawerContainer.js +++ b/gui/src/client/src/components/SmilesDrawerContainer.js @@ -5,9 +5,14 @@ import { useColorScheme } from '@mui/material/styles'; class CustomSvgDrawer extends SmilesDrawer.SvgDrawer { - constructor(options, showIsotopes = false) { + constructor(options, showIsotopes = false, orientationTags = null) { super(options); this.showIsotopes = showIsotopes; + // { startTags, endTags }: isotope tags (see retromol.chem.tagging) of the + // first and last blocks in a generated sequence -- when given, the drawing + // is mirrored horizontally (if needed) so the start block ends up on the + // left, matching reading order of the primary sequence shown alongside it. + this.orientationTags = orientationTags; const themeOverrides = { light: { @@ -86,8 +91,35 @@ class CustomSvgDrawer extends SmilesDrawer.SvgDrawer { atom.bracket = null; } + orientSequenceLeftToRight(startTags, endTags) { + const graph = this.preprocessor?.graph; + if (!graph || !startTags?.length || !endTags?.length) return; + + const vertices = graph.vertices.filter((v) => v.position); + if (!vertices.length) return; + + const avgX = (tags) => { + const matched = vertices.filter((v) => v.value?.bracket && tags.includes(v.value.bracket.isotope)); + if (!matched.length) return null; + return matched.reduce((sum, v) => sum + v.position.x, 0) / matched.length; + }; + + const startX = avgX(startTags); + const endX = avgX(endTags); + if (startX === null || endX === null || startX <= endX) return; + + const xs = vertices.map((v) => v.position.x); + const centerX = (Math.min(...xs) + Math.max(...xs)) / 2; + for (const vertex of vertices) { + vertex.position.x = 2 * centerX - vertex.position.x; + } + } + drawAtomHighlights(highlights) { this.prepareTinAsRightWildcard('Sn'); + if (this.orientationTags) { + this.orientSequenceLeftToRight(this.orientationTags.startTags, this.orientationTags.endTags); + } let preprocessor = this.preprocessor; let graph = preprocessor.graph; @@ -177,12 +209,17 @@ class CustomSvgDrawer extends SmilesDrawer.SvgDrawer { * @property {string} [themeOverride] force “light” or “dark” drawing theme * @property {boolean} [showIsotopes] draw isotope numbers (e.g. R-group * tags like [1*]/[2*]) instead of hiding them + * @property {{startTags: number[], endTags: number[]}} [orientationTags] + * isotope tags of the first/last blocks in a + * generated sequence -- mirrors the drawing + * horizontally (if needed) so the start block + * ends up on the left */ /** * @param {Props} props */ -const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [], themeOverride = '', showIsotopes = false }) => { +const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [], themeOverride = '', showIsotopes = false, orientationTags = null }) => { const { mode, systemMode } = useColorScheme(); const [error, setError] = useState(null); @@ -198,7 +235,15 @@ const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [], // A fresh drawer instance per draw call, since it isn't safe to reuse // once it holds a graph for a previous (possibly differently-sized) SMILES. - let drawer = new CustomSvgDrawer({ width: size, height: size }, showIsotopes); + // + // padding is bumped above the library default (10) because that default + // only leaves room for atom labels -- it doesn't account for the extra + // radius a highlight circle (customDrawAtomHighlight, r = bondLength / 3) + // draws around a highlighted atom, so a highlighted atom sitting at the + // very edge of the layout (as the first/last block often does once + // orientationTags pins it there) gets its highlight clipped by the SVG's + // own viewBox. + let drawer = new CustomSvgDrawer({ width: size, height: size, padding: 24 }, showIsotopes, orientationTags); try { SmilesDrawer.parse( @@ -221,7 +266,7 @@ const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [], console.error('SmilesDrawerContainer: unexpected error', err); setError('Could not render this structure.'); } - }, [identifier, smiles, highlightAtoms, size, themeOverride, showIsotopes, mode, systemMode]); + }, [identifier, smiles, highlightAtoms, size, themeOverride, showIsotopes, orientationTags, mode, systemMode]); if (error) { return ( diff --git a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx index f743b47..ca4c0cc 100644 --- a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx @@ -143,7 +143,7 @@ export function usePrimarySequenceEditor( export type PrimarySequenceEditorState = ReturnType; -function PrimarySequenceChips({ +export function PrimarySequenceChips({ sequence, selectedTags, onToggleMotif, diff --git a/gui/src/client/src/components/workspace/Workspace.tsx b/gui/src/client/src/components/workspace/Workspace.tsx index 489087b..feecb4b 100644 --- a/gui/src/client/src/components/workspace/Workspace.tsx +++ b/gui/src/client/src/components/workspace/Workspace.tsx @@ -15,6 +15,7 @@ import { WorkspaceHome } from "./WorkspaceHome"; import { WorkspaceUpload } from "./WorkspaceUpload"; import { WorkspaceDiscovery } from "./WorkspaceDiscovery"; import { WorkspaceRules } from "./WorkspaceRules"; +import { WorkspaceGenerate } from "./WorkspaceGenerate"; // import { WorkspaceEnrichment } from "./tabs/enrichment/WorkspaceEnrichment"; export const Workspace: React.FC = () => { @@ -173,6 +174,7 @@ export const Workspace: React.FC = () => { } /> {/*} />*/} Analysis currently available. Check back later.} /> + } /> } /> diff --git a/gui/src/client/src/components/workspace/WorkspaceGenerate.tsx b/gui/src/client/src/components/workspace/WorkspaceGenerate.tsx new file mode 100644 index 0000000..f76dd25 --- /dev/null +++ b/gui/src/client/src/components/workspace/WorkspaceGenerate.tsx @@ -0,0 +1,194 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { generateBackbone } from "../../features/reconstruction/api"; +import type { GeneratedBackbone } from "../../features/reconstruction/types"; +import { useNotifications } from "../NotificationProvider"; +import { ErrorBoundary } from "../ErrorBoundary"; +import { ExportImageButton } from "../ExportImageButton"; +import SmilesDrawerContainer from "../SmilesDrawerContainer.js"; +import { DrawingAttribution } from "../DrawingAttribution"; +import { SequenceEditor, type SequenceBlock } from "./SequenceEditor"; +import { PrimarySequenceChips } from "./PrimarySequenceEditor"; + +type HighlightAtom = [number, string]; + +export const WorkspaceGenerate: React.FC = () => { + const { pushNotification } = useNotifications(); + + const [blocks, setBlocks] = React.useState([]); + const [generating, setGenerating] = React.useState(false); + const [result, setResult] = React.useState(null); + const [error, setError] = React.useState(null); + const [selectedTags, setSelectedTags] = React.useState([]); + const diagramRef = React.useRef(null); + + const canGenerate = blocks.length > 0 && !generating; + + const handleGenerate = async () => { + if (!canGenerate) return; + setGenerating(true); + setError(null); + setSelectedTags([]); + + try { + const reconstruction = await generateBackbone(blocks.map((b) => b.name)); + setResult(reconstruction); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setResult(null); + setError(msg); + pushNotification(`Failed to generate backbone: ${msg}`, "error"); + } finally { + setGenerating(false); + } + }; + + const handleToggleMotif = (tags: number[]) => { + setSelectedTags((prev) => { + const allSelected = tags.every((tag) => prev.includes(tag)); + if (allSelected) return prev.filter((tag) => !tags.includes(tag)); + return Array.from(new Set([...prev, ...tags])); + }); + }; + + const handleCopySmiles = async () => { + if (!result?.backboneSmiles) return; + try { + await navigator.clipboard.writeText(result.backboneSmiles); + pushNotification("Copied SMILES to clipboard", "success"); + } catch { + pushNotification("Failed to copy SMILES", "error"); + } + }; + + // Memoized so it's a stable reference across re-renders that don't touch + // selectedTags -- otherwise SmilesDrawerContainer redraws on every unrelated + // parent re-render (same reasoning as DialogViewItem's highlightAtoms). + const highlightAtoms = React.useMemo( + () => selectedTags.map((tag) => [tag, "#027bf3"]), + [selectedTags] + ); + + const orientationTags = React.useMemo(() => { + if (!result || result.primary_sequence.length < 2) return undefined; + const [, startTags] = result.primary_sequence[0]; + const [, endTags] = result.primary_sequence[result.primary_sequence.length - 1]; + if (!startTags.length || !endTags.length) return undefined; + return { startTags, endTags }; + }, [result]); + + return ( + + + + + Build a primary sequence + + + Add building blocks in biosynthetic order, then generate the linear backbone + structure RetroMol's fusion chemistry would assemble from them. + + + {blocks.length === 0 ? ( + + No blocks yet. + + ) : null} + + + + + + {generating && } + + + + + {(result || error) && ( + + + + Generated backbone + + + {error && {error}} + + {result && ( + + {result.backbone_warning && ( + + {result.backbone_warning} + + )} + + {result.tagged_backbone_smiles ? ( + <> + + + b.name).join("-").replace(/[^a-z0-9]+/gi, "-")}`} + label="Download the diagram and sequence below as a PNG" + /> + {result.backboneSmiles && ( + + )} + + + + + Could not render this structure. + + } + > + + + + + + + + + ) : ( + + + + )} + + )} + + + )} + + ); +}; diff --git a/gui/src/client/src/features/reconstruction/api.ts b/gui/src/client/src/features/reconstruction/api.ts index 1ceae8e..7f963db 100644 --- a/gui/src/client/src/features/reconstruction/api.ts +++ b/gui/src/client/src/features/reconstruction/api.ts @@ -1,7 +1,15 @@ import { postJson } from "../http"; import { saveSession } from "../session/api"; import type { Session } from "../session/types"; -import { ReconstructCompoundRespSchema, Reconstruction, PrimarySequenceItem } from "./types"; +import { ReconstructCompoundRespSchema, GenerateBackboneRespSchema, GeneratedBackbone, Reconstruction, PrimarySequenceItem } from "./types"; + +// Generates a linear backbone from a hand-typed primary sequence (block names, +// e.g. from a from-scratch SequenceEditor) rather than an already-parsed +// compound -- see routes/rules.py's generate_backbone / reconstruct_named_sequence. +export async function generateBackbone(sequence: string[], signal?: AbortSignal): Promise { + const data = await postJson("/api/generateBackbone", { sequence }, GenerateBackboneRespSchema, signal); + return data.data; +} export interface ReconstructCompoundResult { reconstructions: Reconstruction[]; diff --git a/gui/src/client/src/features/reconstruction/types.ts b/gui/src/client/src/features/reconstruction/types.ts index 90b2019..559b026 100644 --- a/gui/src/client/src/features/reconstruction/types.ts +++ b/gui/src/client/src/features/reconstruction/types.ts @@ -27,6 +27,19 @@ export const ReconstructionSchema = z.object({ }); export type Reconstruction = z.output; +export const GeneratedBackboneSchema = ReconstructionSchema.extend({ + // Same structure as tagged_backbone_smiles but with RetroMol's internal atom + // tags stripped -- what a "copy SMILES" affordance should hand the user, + // rather than leaking isotope tags into a SMILES they'd paste elsewhere. Null + // under the same conditions tagged_backbone_smiles is. + backboneSmiles: z.string().nullable().default(null), +}); +export type GeneratedBackbone = z.output; + +export const GenerateBackboneRespSchema = z.object({ + data: GeneratedBackboneSchema, +}); + export const ReconstructCompoundRespSchema = z.object({ ok: z.boolean().optional(), status: z.string().optional(), diff --git a/gui/src/server/app.py b/gui/src/server/app.py index 6bf9b23..10bb52c 100644 --- a/gui/src/server/app.py +++ b/gui/src/server/app.py @@ -39,7 +39,7 @@ get_discovery_context, ) from routes.rate_limit import limiter, RATE_LIMIT_REJECTIONS -from routes.rules import blp_rule_set +from routes.rules import blp_rule_set, blp_generate_backbone # Initialize the Flask app @@ -254,6 +254,7 @@ def ready() -> tuple[dict[str, str], int]: app.register_blueprint(blp_submit_discovery_query) app.register_blueprint(blp_get_discovery_query_result) app.register_blueprint(blp_rule_set) +app.register_blueprint(blp_generate_backbone) # The two rate-limit tiers on top of the app-wide default (see routes/rate_limit.py) # are applied as @limiter.limit(...) decorators directly on each route function, in diff --git a/gui/src/server/routes/rules.py b/gui/src/server/routes/rules.py index 6917d86..b08bc68 100644 --- a/gui/src/server/routes/rules.py +++ b/gui/src/server/routes/rules.py @@ -6,9 +6,14 @@ from flask import Blueprint, Response, jsonify, request from rdkit.Chem.Draw import rdMolDraw2D +from retromol.chem.mol import smiles_to_mol, mol_to_smiles from retromol.model.rules import ReactionRule, RuleSet +from retromol_synthesis.reconstruction import BackboneReconstructionError, reconstruct_named_sequence + +from routes.rate_limit import limiter blp_rule_set = Blueprint("rule_set", __name__) +blp_generate_backbone = Blueprint("generate_backbone", __name__) _rule_set: RuleSet | None = None _reaction_rules_by_id: dict[str, ReactionRule] | None = None @@ -156,3 +161,46 @@ def reaction_scheme_svg(rule_id: str) -> tuple[Response, int]: svg = _get_reaction_svg(rule, theme) return jsonify({"svg": svg, "rdkitVersion": rdkit.__version__}), 200 + + +MAX_GENERATE_BACKBONE_BLOCKS = 100 + + +@blp_generate_backbone.post("/api/generateBackbone") +@limiter.limit("60 per minute") +def generate_backbone() -> tuple[Response, int]: + """ + Generate a linear backbone structure from a hand-typed primary sequence (a list + of matching-rule names, e.g. from a `SequenceEditor` built from scratch), using + the same fusion chemistry as a parsed compound's "View item" reconstruction. + + Alongside the Reconstruction's own `tagged_backbone_smiles` (isotope-tagged, for + atom highlighting), the response also includes `backboneSmiles` -- the same + structure with tags stripped, for a "copy SMILES" affordance that shouldn't leak + RetroMol's internal atom-tagging into a SMILES the user pastes elsewhere. + + :return: a tuple containing the generated Reconstruction (or an error) and an HTTP status code + """ + payload = request.get_json(force=True) or {} + sequence = payload.get("sequence") + + if not isinstance(sequence, list) or not all(isinstance(name, str) for name in sequence): + return jsonify({"error": "'sequence' must be a list of strings"}), 400 + if not sequence: + return jsonify({"error": "'sequence' must not be empty"}), 400 + if len(sequence) > MAX_GENERATE_BACKBONE_BLOCKS: + return jsonify({"error": f"'sequence' must have at most {MAX_GENERATE_BACKBONE_BLOCKS} blocks"}), 400 + + try: + reconstruction = reconstruct_named_sequence(_get_rule_set(), sequence) + except BackboneReconstructionError as e: + return jsonify({"error": str(e)}), 400 + + data = reconstruction.to_dict() + data["backboneSmiles"] = ( + mol_to_smiles(smiles_to_mol(reconstruction.tagged_backbone_smiles), include_tags=False) + if reconstruction.tagged_backbone_smiles + else None + ) + + return jsonify({"data": data}), 200 diff --git a/src/retromol/data/mxn.yml b/src/retromol/data/mxn.yml index 3f67042..275bc22 100644 --- a/src/retromol/data/mxn.yml +++ b/src/retromol/data/mxn.yml @@ -425,7 +425,7 @@ pseudonyms: ["PK_C", "PK"] - name: C^Z1 smiles: 'O=C(/C=C\SO)O' - display_smiles: 'O=C(/C=C\[1*)S[2*]' + display_smiles: 'O=C(/C=C\[1*])S[2*]' stereochemistry: true pseudonyms: ["PK_C", "PK"] - name: C1 diff --git a/src/retromol_synthesis/reconstruction.py b/src/retromol_synthesis/reconstruction.py index 506d519..c913788 100644 --- a/src/retromol_synthesis/reconstruction.py +++ b/src/retromol_synthesis/reconstruction.py @@ -15,6 +15,7 @@ from retromol.chem.stereo import BondStereoRecord from retromol.model.readout import LinearReadout from retromol.model.result import Result +from retromol.model.rules import RuleSet logger = logging.getLogger(__name__) @@ -204,10 +205,10 @@ class BackboneReconstructionError(RuntimeError): rxn_pk_double = smarts_to_reaction(r"OS[C:1]=[C:2][C:3](=[O:4])[OH:5]>>[PbH][C:1]=[C:2][C:3](=[O:4])[O:5]-[SnH]") rxn_fuse_starter_pk = smarts_to_reaction(r"[*:1]C(=O)O[SnH].[PbH][C:2]~[C:3][C:4](=[O:5])[O:6][SnH]>>[*:1][C:2]~[C:3][C:4](=[O:5])[O:6][SnH]") -rxn_fuse_starter_aa_alpha = smarts_to_reaction(r"[*:1][C:2](=[O:3])O[SnH].[N:4][C:5]C(=O)[OH]>>[*:1][C:2](=[O:3])[N:4][C:5]C(=O)[OH]") +rxn_fuse_starter_aa_alpha = smarts_to_reaction(r"[*:1][C:2](=[O:3])O[SnH].[N:4][C:5][C:6](=[O:7])[OH:8]>>[*:1][C:2](=[O:3])[N:4][C:5][C:6](=[O:7])[OH:8]") rxn_fuse_pk_pk = smarts_to_reaction(r"[*:1][C:2]~[C:3]C(=O)O[SnH].[PbH][C:4]~[C:5][C:6](=[O:7])[O:8][SnH]>>[*:1][C:2]~[C:3][C:4]~[C:5][C:6](=[O:7])[O:8][SnH]") rxn_fuse_aa_alpha_pk = smarts_to_reaction(r"[N:1][C:2]C(=O)[OH].[PbH][C:3]~[C:4][C:5](=[O:6])[O:7][SnH:8]>>[N:1][C:2][C:3]~[C:4][C:5](=[O:6])[O:7][SnH:8]") -rxn_fuse_pk_aa_alpha = smarts_to_reaction(r"[*:1][C:2]~[C:3][C:4](=[O:5])O[SnH].[N:6][C:7]C(=O)[OH]>>[*:1][C:2]~[C:3][C:4](=[O:5])[N:6][C:7]C(=O)[OH]") +rxn_fuse_pk_aa_alpha = smarts_to_reaction(r"[*:1][C:2]~[C:3][C:4](=[O:5])O[SnH].[N:6][C:7][C:8](=[O:9])[OH:10]>>[*:1][C:2]~[C:3][C:4](=[O:5])[N:6][C:7][C:8](=[O:9])[OH:10]") rxn_fuse_aa_alpha_aa_alpha = smarts_to_reaction(r"[N:1][C:2][C:8](=[O:9])[OH].[N:3][C:4]-,=[C:5](=[O:6])[OH:7]>>[N:1][C:2][C:8](=[O:9])[N:3][C:4]-,=[C:5](=[O:6])[OH:7]") @@ -581,3 +582,107 @@ def reconstruct_linear_readout(result: Result) -> list[Reconstruction]: ) return reconstructions + + +def reconstruct_named_sequence(rule_set: RuleSet, names: list[str]) -> Reconstruction: + """ + Build a linear backbone reconstruction from a hand-typed list of matching-rule + names, rather than from an already-parsed compound's `Result`. + + Applies the same eligibility/orientation/fusion logic as one path's worth of + `reconstruct_linear_readout`, but starting from each rule's own canonical + SMILES instead of a tagged mol pulled out of a parsed structure. Each block is + still given its own globally-unique isotope tags (see `tag_mol`) before fusion, + purely so `_reconstruct_backbone`'s E/Z restoration -- which is keyed by atom + tag pairs -- can tell one block's double bond from another's; two untagged + (isotope 0) double bonds anywhere in the sequence would otherwise collide on + the same registry key and silently lose or mix up their stereo. + + :param rule_set: RuleSet to resolve `names` against (see `RuleSet.load_default`). + :param names: Ordered building-block names, e.g. as typed into a `SequenceEditor`. + :return: The reconstructed candidate. `tagged_backbone_smiles` is None (with + `backbone_warning` set) if the fusion chemistry couldn't combine this + particular sequence. + :raises BackboneReconstructionError: If `names` is empty, or contains a name + this rule set has no matching rule for. + """ + if not names: + raise BackboneReconstructionError("No building blocks given.") + + name_to_rule = {rule.name: rule for rule in rule_set.matching_rules} + + building_blocks: list[str] = [] + primary_sequence: list[tuple[str, set[int]]] = [] + eligible: list[bool] = [] + next_tag = 0 + + for name in names: + rule = name_to_rule.get(name) + if rule is None: + raise BackboneReconstructionError(f"Unknown building block name: {name!r}.") + + mol = smiles_to_mol(rule.smiles) + eligible.append(any(mol.HasSubstructMatch(pattern) for pattern in eligible_patterns)) + + for atom in mol.GetAtoms(): + next_tag += 1 + atom.SetIsotope(next_tag) + + primary_sequence.append((rule.name, get_tags_mol(mol))) + building_blocks.append(mol_to_smiles(mol, include_tags=True)) + + # Mirrors reconstruct_linear_readout's per-path orientation logic: a valid + # arrangement has every item eligible, or every item but the first/last + # eligible (that one is the non-eligible starter). Anything else -- a + # non-eligible item stuck in the middle, or more than one non-eligible item -- + # isn't a biosynthetic order the fusion chemistry can ever assemble, no matter + # which reactions it has; report that plainly rather than attempting fusion and + # surfacing BACKBONE_WARNING's "chemistry doesn't cover this" framing, which + # would misattribute a structurally invalid sequence to a chemistry gap. + non_eligible_idxs = [i for i, e in enumerate(eligible) if not e] + valid_arrangement = not non_eligible_idxs or ( + len(non_eligible_idxs) == 1 and non_eligible_idxs[0] in (0, len(eligible) - 1) + ) + + if not valid_arrangement: + offending = ", ".join(f"'{names[i]}'" for i in non_eligible_idxs) + return Reconstruction( + tagged_input_smiles="", + tagged_backbone_smiles=None, + primary_sequence=primary_sequence, + backbone_warning=( + f"{offending} {'is' if len(non_eligible_idxs) == 1 else 'are'} not a polyketide- or " + "amino-acid-type building block that RetroMol's fusion chemistry can extend a chain " + "through, so it can only appear as the very first or very last block (the starter " + "unit). Move it to one end, or remove it, to generate a backbone." + ), + ) + + starter: str | None + if not non_eligible_idxs: + starter = None + elif non_eligible_idxs[0] == 0: + starter = building_blocks[0] + building_blocks = building_blocks[1:] + else: + # The non-eligible starter is at the end -- flip so it's first. + building_blocks = list(reversed(building_blocks)) + primary_sequence = list(reversed(primary_sequence)) + starter = building_blocks[0] + building_blocks = building_blocks[1:] + + tagged_backbone_smiles: str | None = None + backbone_warning: str | None = None + try: + backbone_mol = _reconstruct_backbone(starter, building_blocks) + tagged_backbone_smiles = mol_to_smiles(backbone_mol, include_tags=True) + except Exception: + logger.warning("reconstruct_named_sequence: backbone reconstruction failed", exc_info=True) + backbone_warning = BACKBONE_WARNING + + return Reconstruction( + tagged_input_smiles="", + tagged_backbone_smiles=tagged_backbone_smiles, + primary_sequence=primary_sequence, + backbone_warning=backbone_warning, + )