#!/usr/bin/env python3 """Build a self-contained 3D protein-ligand viewer with inline JS libraries. Usage: python build_viewer.py --drug metformin python build_viewer.py --drug metformin --sequence CUSTOMSEQ --title "Custom Title" When --sequence is omitted, the script looks up the protein target from a built-in drug-target table (drug name -> target protein -> amino acid sequence). Fetches drug SMILES from PubChem, predicts a protein-ligand complex with OpenFold3 NIM, and generates an interactive 3D viewer saved to canvas. """ import argparse, subprocess, json, os, sys CANVAS = os.path.expanduser("~/.openclaw/canvas") OF3_HOST = os.environ.get("OPENFOLD3_HOST", "172.17.0.1") OF3_URL = f"http://{OF3_HOST}:8000/biology/openfold/openfold3/predict" PUBCHEM_URL = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{}/property/IsomericSMILES/JSON" # Drug -> (target protein name, amino acid sequence) # Sequences are canonical fragments from UniProt, chosen to be under 300 aa # for fast OpenFold3 prediction while covering the drug binding domain. DRUG_TARGETS = { "metformin": ( "Insulin B-chain", "FVNQHLCGSHLVEALYLVCGERGFFYTPKT", ), "atorvastatin": ( "HMG-CoA reductase (catalytic domain)", "EIGTVGGGTQLFNQLESRIRAVLKDAGFLEEARAVIDRPGPYLEDVVTASNLKEGATLITSPAKLLREVGLTPETISKALKESGVRFIRIATTAPYAMNPVSAVEIAGATLYPVSALTEIARGMFVFQSGKYSMSSSGIVLPVVFATLME", ), "rosuvastatin": ( "HMG-CoA reductase (catalytic domain)", "EIGTVGGGTQLFNQLESRIRAVLKDAGFLEEARAVIDRPGPYLEDVVTASNLKEGATLITSPAKLLREVGLTPETISKALKESGVRFIRIATTAPYAMNPVSAVEIAGATLYPVSALTEIARGMFVFQSGKYSMSSSGIVLPVVFATLME", ), "lisinopril": ( "ACE (binding domain)", "QGSERRGPFKSWYGSSPDIIRDQIRKQLQELLQELNEERDCTSIHPFHNIFSEDDASFEERKVLKNMMDTLKRNVQEAVDTYGFK", ), "enalapril": ( "ACE (binding domain)", "QGSERRGPFKSWYGSSPDIIRDQIRKQLQELLQELNEERDCTSIHPFHNIFSEDDASFEERKVLKNMMDTLKRNVQEAVDTYGFK", ), "losartan": ( "Angiotensin II receptor type 1 (transmembrane domain)", "MILNSSTEDGIKRIQDDCPKAGRHNYIFVMIPTLYSIIFVVGIFGNSLVVIVIYFYMKLKTVASVFLLNLALADLCFLLTLPLWAVYTAMEYRWPFGNYLCKIASASVSFNLYASVFLLTCLSIDRYLAIVHPMKSRLRRTMLVAKVTCIIIWLLAGLASLPAIIHRNVFFIENTNITVCAFHYESQNSTLPIGLGLTKNILGFLFPFLIILTSYTLIWKALKKAYEIQKNKPRNDDIFKIIMAIVLFFFFSWIPHQIFTFLDVLIQLGIIRDCRIADIVDTAMPITICIAYFNNCLNPLFYGFLGKKFKRYFLQLLKYIPPKAKSHSNLSTRMSTLSYRPSDNVSSSTKKPAPCFEVE", ), "amlodipine": ( "L-type calcium channel Cav1.2 (domain III)", "QCIDDYDTQFFLQDNAKFEGMCLRDIPDDRDNFDLFLKRVDIGPEDYYLNQHFLDAAENPDPEISFQFEGRILRGFIDIIYDLSDWFDPNEDY", ), "empagliflozin": ( "SGLT2 (sodium-glucose cotransporter 2)", "MDSSRQSGAHQHPPAQRVELQGLADEADARALRGEFSLHPELAARAATPEQAFALGGELPMERDSQLCMGFVHTYFNMTGYSEAETLTGAGPPMAYAIPPQAKEVEEMKEFFQKFGKTYPGLKDIFPETKIDFLRNIMLQHMGIGLASATLVPMYIAAEMTAHMGCMHRFLYASYVAAEFLAIVFAVILFNLGERRKHFS", ), "semaglutide": ( "GLP-1 receptor (extracellular domain)", "RPQGATVSLWETVQKWREYRRQCQRSLTEDPPPATDLFCNRTFDEYACWPDGEPGSFVNVSCPWYLPWASSVPQGHVYRFCTAEGLWLQKDNSSLPWRDLSECEESKRGERNSPEEQLLS", ), } def fetch_js(url): r = subprocess.run(["curl", "-sL", url], capture_output=True, text=True, timeout=30) if r.returncode != 0: print(f" ERROR: curl failed for {url} (exit {r.returncode})", file=sys.stderr) if r.stderr: print(f" {r.stderr[:300]}", file=sys.stderr) raise RuntimeError(f"Failed to fetch {url}") if len(r.stdout) < 1000: print(f" WARN: {url} returned only {len(r.stdout)} bytes", file=sys.stderr) return r.stdout def lookup_smiles(drug): try: r = subprocess.run( ["curl", "-sf", PUBCHEM_URL.format(drug)], capture_output=True, text=True, timeout=15 ) d = json.loads(r.stdout) props = d["PropertyTable"]["Properties"][0] return props.get("IsomericSMILES", props.get("CanonicalSMILES", props.get("SMILES", ""))) except Exception as e: print(f" PubChem lookup failed: {e}", file=sys.stderr) return "" def resolve_target(drug): """Look up drug in built-in table. Returns (target_name, sequence) or None.""" key = drug.strip().lower() if key in DRUG_TARGETS: return DRUG_TARGETS[key] for k, v in DRUG_TARGETS.items(): if k in key or key in k: return v return None def predict_structure(sequence, smiles=""): molecules = [{ "type": "protein", "id": "A", "sequence": sequence, "msa": {"main": {"a3m": { "alignment": f">query\n{sequence}", "format": "a3m" }}} }] if smiles: molecules.append({"type": "ligand", "smiles": smiles}) body = json.dumps({"inputs": [{ "input_id": "viewer", "molecules": molecules, "output_format": "pdb" }]}) r = subprocess.run( ["curl", "-sf", "--max-time", "300", "-X", "POST", "-H", "Content-Type: application/json", "-d", body, OF3_URL], capture_output=True, text=True, timeout=305 ) if r.returncode != 0 or not r.stdout.strip(): print(f" OpenFold3 prediction failed (exit {r.returncode})", file=sys.stderr) if r.stderr: print(f" {r.stderr[:300]}", file=sys.stderr) sys.exit(1) result = json.loads(r.stdout) out = result["outputs"][0]["structures_with_scores"][0] return out["structure"], out def build_html(title, drug, smiles, sequence, pdb, scores, jquery_js, mol3d_js): pdb_escaped = pdb.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${") has_ligand = bool(smiles) conf = scores.get('confidence_score', 0) plddt = scores.get('complex_plddt_score', 0) ptm = scores.get('ptm_score', 0) iptm = scores.get('iptm_score', 0) ligand_legend = f'