From 7ec25f1802b04e69abc2f107014b41456c704f28 Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Fri, 7 Aug 2026 09:14:23 -0700 Subject: [PATCH 1/9] use molecule db for molecule naming --- charge_backend/moleculedb/molecule_naming.py | 37 ++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/charge_backend/moleculedb/molecule_naming.py b/charge_backend/moleculedb/molecule_naming.py index 3c3e1967..58654acb 100644 --- a/charge_backend/moleculedb/molecule_naming.py +++ b/charge_backend/moleculedb/molecule_naming.py @@ -19,13 +19,16 @@ import json import os import requests -import pandas as pd +import functools +from charge_backend.moleculedb.purchasable import moldb_connect from typing import Literal, TypeAlias MolNameFormat: TypeAlias = Literal["brand", "iupac", "formula", "smiles"] +_MOLECULE_DATABASE_PATH = os.getenv("FLASK_MOLECULE_DB", "/data/db/molecules.db") _DATABASE_PATH = os.getenv("FLASK_INCHI_DB", "/data/inchi_mapping.json") +MOLDB_CONNECTION = None if os.path.exists(_DATABASE_PATH): with open(_DATABASE_PATH, "rb") as fp: DATABASE = json.load(fp) @@ -54,6 +57,30 @@ def inchi_lookup(inchi: str, prefer_iupac: bool = False) -> str | None: return None +@functools.cache +def ai_preferred_name_lookup(inchi): + global MOLDB_CONNECTION + if MOLDB_CONNECTION is None: + if os.path.exists(_MOLECULE_DATABASE_PATH): + MOLDB_CONNECTION = moldb_connect(_MOLECULE_DATABASE_PATH) + if MOLDB_CONNECTION is None: + return None + cursor = MOLDB_CONNECTION.cursor() + + row = cursor.execute( + """ + SELECT n.ai_preferred_name + FROM names n + JOIN molecules m ON n.molecule_id = m.molecule_id + WHERE m.key = ? + """, + (inchi,), + ).fetchone() + + return row[0].strip() if row and row[0] and row[0].strip() else None + + + def smiles_to_html( smiles: str, molecule_name_format: MolNameFormat = "brand", @@ -67,13 +94,19 @@ def smiles_to_html( if mol is None: # Invalid SMILES return smiles - # First, try to find a canonical or IUPAC name + # First, try to find a canonical, IUPAC name if molecule_name_format in ("brand", "iupac"): inchi = str(Chem.MolToInchi(mol)) name = inchi_lookup(inchi, molecule_name_format == "iupac") if name: return name + # fallback to ai preferred name + if molecule_name_format == "brand": + name = ai_preferred_name_lookup(inchi) + if name: + return name + # Otherwise, use RDKit for a general chemical formula # Get the formula if rdMolDescriptors is None: From b578474b9f40cb76429a3c3daa4750f1e0520603 Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Fri, 7 Aug 2026 09:55:39 -0700 Subject: [PATCH 2/9] smiles helper function --- charge_backend/moleculedb/molecule_naming.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/charge_backend/moleculedb/molecule_naming.py b/charge_backend/moleculedb/molecule_naming.py index 58654acb..120f3f5c 100644 --- a/charge_backend/moleculedb/molecule_naming.py +++ b/charge_backend/moleculedb/molecule_naming.py @@ -79,7 +79,16 @@ def ai_preferred_name_lookup(inchi): return row[0].strip() if row and row[0] and row[0].strip() else None +def smiles_preferred_name(smiles): + if Chem is None: + return None + + mol = Chem.MolFromSmiles(smiles) + if mol is None: # Invalid SMILES + return smiles + inchi = str(Chem.MolToInchi(mol)) + return ai_preferred_name_lookup(inchi) def smiles_to_html( smiles: str, From d29f3c86244005c41f75f5517f69102c6aabafaa Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Fri, 7 Aug 2026 09:55:54 -0700 Subject: [PATCH 3/9] fix whitespace --- charge_backend/moleculedb/molecule_naming.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/charge_backend/moleculedb/molecule_naming.py b/charge_backend/moleculedb/molecule_naming.py index 120f3f5c..60504421 100644 --- a/charge_backend/moleculedb/molecule_naming.py +++ b/charge_backend/moleculedb/molecule_naming.py @@ -79,6 +79,7 @@ def ai_preferred_name_lookup(inchi): return row[0].strip() if row and row[0] and row[0].strip() else None + def smiles_preferred_name(smiles): if Chem is None: return None @@ -90,6 +91,7 @@ def smiles_preferred_name(smiles): inchi = str(Chem.MolToInchi(mol)) return ai_preferred_name_lookup(inchi) + def smiles_to_html( smiles: str, molecule_name_format: MolNameFormat = "brand", From 8cd946d7320293da16fb322715ff1687d765fc98 Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Fri, 7 Aug 2026 10:46:31 -0700 Subject: [PATCH 4/9] return none if invalid smiles --- charge_backend/moleculedb/molecule_naming.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charge_backend/moleculedb/molecule_naming.py b/charge_backend/moleculedb/molecule_naming.py index 60504421..7087c1c6 100644 --- a/charge_backend/moleculedb/molecule_naming.py +++ b/charge_backend/moleculedb/molecule_naming.py @@ -86,7 +86,7 @@ def smiles_preferred_name(smiles): mol = Chem.MolFromSmiles(smiles) if mol is None: # Invalid SMILES - return smiles + return None inchi = str(Chem.MolToInchi(mol)) return ai_preferred_name_lookup(inchi) From cf9ab135dcbec40b11a74d93cc1650dd1465ef1a Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Fri, 7 Aug 2026 10:47:49 -0700 Subject: [PATCH 5/9] updated prompt w/ preferred name and functional groups --- charge_backend/retrosynthesis/ai.py | 55 +++++++++++--- .../retrosynthesis/functional_groups.py | 73 +++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 charge_backend/retrosynthesis/functional_groups.py diff --git a/charge_backend/retrosynthesis/ai.py b/charge_backend/retrosynthesis/ai.py index 514df0e8..b61363e7 100644 --- a/charge_backend/retrosynthesis/ai.py +++ b/charge_backend/retrosynthesis/ai.py @@ -17,6 +17,7 @@ from charge_backend.flask_experiment import FlaskExperiment, GraphContext from charge_backend.moleculedb.molecule_naming import ( smiles_to_html, + smiles_preferred_name, ) from charge_backend.retrosynthesis.template import ( generate_nodes_for_molecular_graph, @@ -25,6 +26,7 @@ from charge_backend.moleculedb.purchasable import is_purchasable from charge_backend.retrosynthesis.mapping import build_mapped_reaction_dict_or_none from charge_backend.retrosynthesis.database import find_exact_reactions +from charge_backend.retrosynthesis.functional_groups import functional_groups_from_smiles from charge_backend.retrosynthesis.retrosynthesis_task import ( TemplateFreeRetrosynthesisTask as RetrosynthesisTask, @@ -57,6 +59,37 @@ + "If the evaluation fails, propose a new retrosynthetic step and evaluate it again. " ) +RETROSYNTH_PROMPT_TEMPLATE = """You are a chemistry retrosynthesis assistant. Perform single-step retrosynthesis only. + +Target: +- Preferred Name: {preferred_name} +- SMILES: `{smiles}` +- Functional Groups: {fgs} +- Polymer rule: if `*` appears, it marks polymer repeat-unit boundaries + +Task: +Find the best one-step retrosynthetic path to the target. Use available tools to verify each candidate; if a required tool is unavailable, perform the +same check by chemical reasoning. + +Requirements: +1. Identify the key bond formation, functional group transformation, or disconnection that most directly explains the target. +2. Propose candidate reactants for a single retrosynthetic step. +3. Verify that each proposed reactant SMILES is syntactically valid. +4. Check whether the proposed reactants are chemically plausible and reasonably synthesizable. +5. Evaluate the implied forward reaction. The reactants should regenerate the target in one step without adding, deleting, or rearranging unrelated atoms. +6. If `predict_reaction_products` is available, use it to predict products from the proposed reactants, then canonicalize and compare the predicted +product with the target. If there is any inconsistency log it and try some other set of reactants. +7. If prediction tools are unavailable, perform the same forward-product equivalence check by chemical reasoning. +8. If a candidate fails validation or there is any inconsistency, diagnose the issue, log it, and try another candidate. +9. Choose the best validated step. +10. Return the selected reactants and regenerated product as SMILES. + +Ranking criteria: +- exact or near-exact forward-product equivalence to target +- chemical plausibility +- reactants are buyable, or can be reduced to buyable precursors in few plausible steps +- one-step feasibility +""" async def ai_based_retrosynthesis( node_id: str, @@ -106,17 +139,21 @@ async def ai_based_retrosynthesis( callback_handler.agent_key = agent_key callback_handler.on_agent_update = history_callback + preferred_name = smiles_preferred_name(current_node.smiles) + functional_groups = functional_groups_from_smiles(current_node.smiles) + + user_prompt = RETROSYNTH_PROMPT_TEMPLATE.format( + preferred_name=preferred_name, + smiles=current_node.smiles, + fgs=", ".join(functional_groups) if functional_groups else "None" + ) + if constraint: - user_prompt = RETROSYNTH_CONSTRAINED_USER_PROMPT_TEMPLATE.format( - target_molecule=current_node.smiles, - constrained_reactant=constraint, - ) - else: - user_prompt = RETROSYNTH_UNCONSTRAINED_USER_PROMPT_TEMPLATE.format( - target_molecule=current_node.smiles - ) + user_prompt += ( + f"\n\nConstraint - The following reactants cannot be used in the retrosynthetic step: " + f"{constraint}." + ) - user_prompt += "\nDouble check the reactants with the `predict_reaction_products` tool to see if the products are equivalent to the given product. If there is any inconsistency (canonicalize both sides of the equation first), log it and try some other set of reactants." if query is not None: user_prompt += ( f"\n\nAdditionally, adhere to the following requirements:\n{query}\n\n" diff --git a/charge_backend/retrosynthesis/functional_groups.py b/charge_backend/retrosynthesis/functional_groups.py new file mode 100644 index 00000000..1521799f --- /dev/null +++ b/charge_backend/retrosynthesis/functional_groups.py @@ -0,0 +1,73 @@ +try: + from rdkit import Chem +except ImportError: + Chem = None + +FUNCTIONAL_GROUP_SMARTS = { + # Carbonyl compounds + "aldehyde": "[CX3H1](=O)[#6]", + "ketone": "[#6][CX3](=O)[#6]", + "carboxylic acid": "C(=O)[OX2H1]", + "ester": "C(=O)O[#6]", + "amide": "C(=O)N", + "acyl halide": "C(=O)[F,Cl,Br,I]", + "anhydride": "C(=O)OC(=O)", + + # Alcohols / ethers + "alcohol": "[OX2H][CX4]", + "phenol": "[OX2H][c]", + "ether": "[#6]-O-[#6]", + "epoxide": "[OX2r3]1CC1", + + # Nitrogen + "amine": "[NX3;H2,H1,H0;!$(NC=O)]", + "imine": "[CX3]=[NX2]", + "nitrile": "C#N", + "nitro": "[N+](=O)[O-]", + "azo": "[N]=[N]", + "isocyanate": "N=C=O", + "isothiocyanate": "N=C=S", + "urea": "N-C(=O)-N", + "carbamate": "O-C(=O)-N", + + # Sulfur + "thiol": "[SX2H]", + "thioether": "[#6]-S-[#6]", + "sulfoxide": "[SX3](=O)", + "sulfone": "[SX4](=O)(=O)", + "sulfonamide": "S(=O)(=O)N", + "sulfonic acid": "S(=O)(=O)[OX2H]", + + # Phosphorus + "phosphate": "P(=O)(O)(O)", + + # Unsaturation + "alkene": "C=C", + "alkyne": "C#C", + + # Rings + "aromatic ring": "a1aaaaa1", + + # Halogens + "organohalide": "[#6][F,Cl,Br,I]", + + # Hydrocarbon + "alkyl substituent": "[CX4][a]", +} + + +def functional_groups_from_smiles(smiles): + """Return simple SMARTS-matched functional group names.""" + if Chem is None: + return [] + + mol = Chem.MolFromSmiles(smiles) + if mol is None: + return [] + + groups = [] + for name, smarts in FUNCTIONAL_GROUP_SMARTS.items(): + pattern = Chem.MolFromSmarts(smarts) + if pattern is not None and mol.HasSubstructMatch(pattern): + groups.append(name) + return groups \ No newline at end of file From c75b785f26a428de3e40bbf4224bce86dc9a7543 Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Fri, 7 Aug 2026 10:49:28 -0700 Subject: [PATCH 6/9] back in black --- charge_backend/moleculedb/molecule_naming.py | 2 +- charge_backend/retrosynthesis/ai.py | 14 ++++++++------ charge_backend/retrosynthesis/functional_groups.py | 10 +--------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/charge_backend/moleculedb/molecule_naming.py b/charge_backend/moleculedb/molecule_naming.py index 7087c1c6..c25d38bf 100644 --- a/charge_backend/moleculedb/molecule_naming.py +++ b/charge_backend/moleculedb/molecule_naming.py @@ -83,7 +83,7 @@ def ai_preferred_name_lookup(inchi): def smiles_preferred_name(smiles): if Chem is None: return None - + mol = Chem.MolFromSmiles(smiles) if mol is None: # Invalid SMILES return None diff --git a/charge_backend/retrosynthesis/ai.py b/charge_backend/retrosynthesis/ai.py index b61363e7..6be25332 100644 --- a/charge_backend/retrosynthesis/ai.py +++ b/charge_backend/retrosynthesis/ai.py @@ -26,7 +26,9 @@ from charge_backend.moleculedb.purchasable import is_purchasable from charge_backend.retrosynthesis.mapping import build_mapped_reaction_dict_or_none from charge_backend.retrosynthesis.database import find_exact_reactions -from charge_backend.retrosynthesis.functional_groups import functional_groups_from_smiles +from charge_backend.retrosynthesis.functional_groups import ( + functional_groups_from_smiles, +) from charge_backend.retrosynthesis.retrosynthesis_task import ( TemplateFreeRetrosynthesisTask as RetrosynthesisTask, @@ -35,7 +37,6 @@ from lc_conductor import ToolRuntime - RETROSYNTH_UNCONSTRAINED_USER_PROMPT_TEMPLATE = ( "Provide a retrosynthetic pathway for the target molecule `{target_molecule}`. " + "If there are `*`, the `*` indicate the boundaries of the polymer repeat unit." @@ -91,6 +92,7 @@ - one-step feasibility """ + async def ai_based_retrosynthesis( node_id: str, query: Optional[str], @@ -145,14 +147,14 @@ async def ai_based_retrosynthesis( user_prompt = RETROSYNTH_PROMPT_TEMPLATE.format( preferred_name=preferred_name, smiles=current_node.smiles, - fgs=", ".join(functional_groups) if functional_groups else "None" + fgs=", ".join(functional_groups) if functional_groups else "None", ) if constraint: user_prompt += ( - f"\n\nConstraint - The following reactants cannot be used in the retrosynthetic step: " - f"{constraint}." - ) + f"\n\nConstraint - The following reactants cannot be used in the retrosynthetic step: " + f"{constraint}." + ) if query is not None: user_prompt += ( diff --git a/charge_backend/retrosynthesis/functional_groups.py b/charge_backend/retrosynthesis/functional_groups.py index 1521799f..0f5a733a 100644 --- a/charge_backend/retrosynthesis/functional_groups.py +++ b/charge_backend/retrosynthesis/functional_groups.py @@ -12,13 +12,11 @@ "amide": "C(=O)N", "acyl halide": "C(=O)[F,Cl,Br,I]", "anhydride": "C(=O)OC(=O)", - # Alcohols / ethers "alcohol": "[OX2H][CX4]", "phenol": "[OX2H][c]", "ether": "[#6]-O-[#6]", "epoxide": "[OX2r3]1CC1", - # Nitrogen "amine": "[NX3;H2,H1,H0;!$(NC=O)]", "imine": "[CX3]=[NX2]", @@ -29,7 +27,6 @@ "isothiocyanate": "N=C=S", "urea": "N-C(=O)-N", "carbamate": "O-C(=O)-N", - # Sulfur "thiol": "[SX2H]", "thioether": "[#6]-S-[#6]", @@ -37,20 +34,15 @@ "sulfone": "[SX4](=O)(=O)", "sulfonamide": "S(=O)(=O)N", "sulfonic acid": "S(=O)(=O)[OX2H]", - # Phosphorus "phosphate": "P(=O)(O)(O)", - # Unsaturation "alkene": "C=C", "alkyne": "C#C", - # Rings "aromatic ring": "a1aaaaa1", - # Halogens "organohalide": "[#6][F,Cl,Br,I]", - # Hydrocarbon "alkyl substituent": "[CX4][a]", } @@ -70,4 +62,4 @@ def functional_groups_from_smiles(smiles): pattern = Chem.MolFromSmarts(smarts) if pattern is not None and mol.HasSubstructMatch(pattern): groups.append(name) - return groups \ No newline at end of file + return groups From b336cfe1f966b6d8706d6fb28c2f7469b3ffc9fa Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Fri, 7 Aug 2026 10:56:49 -0700 Subject: [PATCH 7/9] remove old prompts --- charge_backend/retrosynthesis/ai.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/charge_backend/retrosynthesis/ai.py b/charge_backend/retrosynthesis/ai.py index 6be25332..1fc8d3cd 100644 --- a/charge_backend/retrosynthesis/ai.py +++ b/charge_backend/retrosynthesis/ai.py @@ -37,29 +37,6 @@ from lc_conductor import ToolRuntime -RETROSYNTH_UNCONSTRAINED_USER_PROMPT_TEMPLATE = ( - "Provide a retrosynthetic pathway for the target molecule `{target_molecule}`. " - + "If there are `*`, the `*` indicate the boundaries of the polymer repeat unit." - + "The pathway should be provided as a tuple of reactants as SMILES and the product as SMILES. " - + "Perform only single step retrosynthesis. Make sure the SMILES strings are valid. " - + "Use tools to verify the SMILES strings and diagnose any issues that arise." - + "Do the evaluation step-by-step. Propose a retrosynthetic step, then evaluate it. " - + "If the evaluation fails, propose a new retrosynthetic step and evaluate it again. " - + "Find the best possible retrosynthetic step, and use tools to see if the " - + "proposed reactants are synthesizable. " -) - -RETROSYNTH_CONSTRAINED_USER_PROMPT_TEMPLATE = ( - "Provide a retrosynthetic pathway for the target molecule `{target_molecule}`. " - + "If there are `*`, the `*` indicate the boundaries of the polymer repeat unit." - + "The pathway should be provided as a tuple of reactants as SMILES and the product as SMILES. " - + "Perform only single step retrosynthesis. Make sure the SMILES strings are valid. " - + "Use tools to verify the SMILES strings and diagnose any issues that arise. " - + "The following reactant cannot be used in the retrosynthetic step: {constrained_reactant}. " - + "Do the evaluation step-by-step. Propose a retrosynthetic step, then evaluate it. " - + "If the evaluation fails, propose a new retrosynthetic step and evaluate it again. " -) - RETROSYNTH_PROMPT_TEMPLATE = """You are a chemistry retrosynthesis assistant. Perform single-step retrosynthesis only. Target: From 4c68feb1896594295e1605851c3c3a0cf99e72c6 Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Fri, 7 Aug 2026 12:04:26 -0700 Subject: [PATCH 8/9] Extra newline --- charge_backend/retrosynthesis/ai.py | 1 + 1 file changed, 1 insertion(+) diff --git a/charge_backend/retrosynthesis/ai.py b/charge_backend/retrosynthesis/ai.py index 1fc8d3cd..09c6a79e 100644 --- a/charge_backend/retrosynthesis/ai.py +++ b/charge_backend/retrosynthesis/ai.py @@ -67,6 +67,7 @@ - chemical plausibility - reactants are buyable, or can be reduced to buyable precursors in few plausible steps - one-step feasibility + """ From 1ec637960a077a9cac3292839ac0226c55938a19 Mon Sep 17 00:00:00 2001 From: Gunnar Larsen Date: Mon, 10 Aug 2026 08:37:59 -0700 Subject: [PATCH 9/9] make ai preferred name the default --- charge_backend/moleculedb/molecule_naming.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/charge_backend/moleculedb/molecule_naming.py b/charge_backend/moleculedb/molecule_naming.py index c25d38bf..19873dbf 100644 --- a/charge_backend/moleculedb/molecule_naming.py +++ b/charge_backend/moleculedb/molecule_naming.py @@ -108,16 +108,17 @@ def smiles_to_html( # First, try to find a canonical, IUPAC name if molecule_name_format in ("brand", "iupac"): inchi = str(Chem.MolToInchi(mol)) + + # default to AI preferred name + if molecule_name_format == "brand": + name = ai_preferred_name_lookup(inchi) + if name: + return name + name = inchi_lookup(inchi, molecule_name_format == "iupac") if name: return name - # fallback to ai preferred name - if molecule_name_format == "brand": - name = ai_preferred_name_lookup(inchi) - if name: - return name - # Otherwise, use RDKit for a general chemical formula # Get the formula if rdMolDescriptors is None: