Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions charge_backend/moleculedb/molecule_naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -54,6 +57,41 @@ 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_preferred_name(smiles):
if Chem is None:
return None

mol = Chem.MolFromSmiles(smiles)
if mol is None: # Invalid SMILES
return None

inchi = str(Chem.MolToInchi(mol))
return ai_preferred_name_lookup(inchi)


def smiles_to_html(
smiles: str,
molecule_name_format: MolNameFormat = "brand",
Expand All @@ -67,9 +105,16 @@ 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))

# 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
Expand Down
75 changes: 46 additions & 29 deletions charge_backend/retrosynthesis/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +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.retrosynthesis_task import (
TemplateFreeRetrosynthesisTask as RetrosynthesisTask,
Expand All @@ -33,29 +37,38 @@

from lc_conductor import ToolRuntime

RETROSYNTH_PROMPT_TEMPLATE = """You are a chemistry retrosynthesis assistant. Perform single-step retrosynthesis only.

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. "
)
Target:
- Preferred Name: {preferred_name}
- SMILES: `{smiles}`
- Functional Groups: {fgs}
- Polymer rule: if `*` appears, it marks polymer repeat-unit boundaries

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. "
)
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"best" is ill-defined, the following might be better

Suggested change
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
Find the best ranked 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.
Comment on lines +57 to +59

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't 5 and 6 be merged?

7. If prediction tools are unavailable, perform the same forward-product equivalence check by chemical reasoning.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeating the statement in "Task:"

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
9. Choose the best validated step.
9. Choose the best validated step according to the ranking criteria below.

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(
Expand Down Expand Up @@ -106,17 +119,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"
Expand Down
65 changes: 65 additions & 0 deletions charge_backend/retrosynthesis/functional_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
try:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs copyright header

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