VSRAJHAVEL commited on
Commit
0d6cfbb
·
verified ·
1 Parent(s): 2fd4f2f

Upload folder using huggingface_hub

Browse files
app/__pycache__/server.cpython-310.pyc ADDED
Binary file (24.2 kB). View file
 
app/__pycache__/streamlit_app.cpython-310.pyc ADDED
Binary file (36.1 kB). View file
 
app/server.py ADDED
@@ -0,0 +1,808 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BioGraph AI: Enterprise Drug Discovery REST API Server.
3
+ High-performance FastAPI backend serving Deep GNN models (MPNN, GIN, GAT, GCN),
4
+ In Silico ADMET, 3D Molecular Energetics, Pharmacophore Attribution, and Batch Screening.
5
+ """
6
+ import os
7
+ import sys
8
+ import base64
9
+ from typing import List, Dict, Any, Optional, Tuple
10
+
11
+ # Add project root to path
12
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
13
+
14
+ import torch
15
+ import numpy as np
16
+ from fastapi import FastAPI, HTTPException, Query
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.staticfiles import StaticFiles
19
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
20
+ from pydantic import BaseModel, Field
21
+
22
+ from rdkit import Chem, RDLogger
23
+ from rdkit.Chem import Draw, Descriptors
24
+ from rdkit.Chem.Draw import rdMolDraw2D
25
+
26
+ from src.data import smiles_to_graph, DATASET_CONFIGS
27
+ from src.evaluate import load_trained_model
28
+ from src.explain import explain_molecule
29
+ from src.admet import compute_admet_profile
30
+ from src.conformers import generate_3d_conformer
31
+ from src.ensemble import predict_ensemble, predict_all_endpoints, MODEL_NAMES
32
+ from src.chemical_space import get_chemical_space_explorer
33
+ from src.analogs import evaluate_lead_optimization
34
+ from src.batch_screen import screen_batch_molecules
35
+
36
+ # Silence RDKit deprecation noise
37
+ RDLogger.DisableLog('rdApp.*')
38
+
39
+ app = FastAPI(
40
+ title="BioGraph AI Drug Discovery API",
41
+ description="High-Throughput Deep Molecular Graph Neural Network Screening Platform",
42
+ version="2.0.0"
43
+ )
44
+
45
+ # Enable CORS for frontend development and production
46
+ app.add_middleware(
47
+ CORSMiddleware,
48
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:8000", "*"],
49
+ allow_credentials=True,
50
+ allow_methods=["*"],
51
+ allow_headers=["*"],
52
+ )
53
+
54
+ # Cached Models Dictionary in memory
55
+ _MODEL_CACHE: Dict[str, Dict[str, Any]] = {}
56
+
57
+
58
+ def get_cached_model_instance(model_name: str, dataset_name: str):
59
+ """Retrieve or load cached model instance."""
60
+ ds = dataset_name.lower()
61
+ m = model_name.lower()
62
+ if ds not in _MODEL_CACHE:
63
+ _MODEL_CACHE[ds] = {}
64
+ if m not in _MODEL_CACHE[ds]:
65
+ mod, payload = load_trained_model(model_name=m, dataset_name=ds)
66
+ _MODEL_CACHE[ds][m] = mod
67
+ return _MODEL_CACHE[ds][m]
68
+
69
+
70
+ # Molecular Presets
71
+ PRESET_DRUGS = [
72
+ {
73
+ "name": "Aspirin",
74
+ "category": "Analgesic / Anti-inflammatory",
75
+ "smiles": "CC(=O)OC1=CC=CC=C1C(=O)O",
76
+ "indication": "COX-1/COX-2 inhibitor for pain, fever, and thrombosis prevention."
77
+ },
78
+ {
79
+ "name": "Ibuprofen",
80
+ "category": "NSAID Pain Reliever",
81
+ "smiles": "CC(C)CC1=CC=C(C=C1)C(C)C(=O)O",
82
+ "indication": "Nonsteroidal anti-inflammatory for arthritis and pain."
83
+ },
84
+ {
85
+ "name": "Paracetamol",
86
+ "category": "Antipyretic / Analgesic",
87
+ "smiles": "CC(=O)NC1=CC=C(O)C=C1",
88
+ "indication": "Centrally acting analgesic for fever and mild-to-moderate pain."
89
+ },
90
+ {
91
+ "name": "Caffeine",
92
+ "category": "CNS Stimulant",
93
+ "smiles": "CN1C=NC2=C1C(=O)N(C(=O)N2C)C",
94
+ "indication": "Adenosine A1/A2A receptor antagonist promoting alertness."
95
+ },
96
+ {
97
+ "name": "Atorvastatin (Lipitor)",
98
+ "category": "Statin / Cardiovascular",
99
+ "smiles": "CC(C)c1c(C(=O)Nc2ccccc2)c(-c2ccccc2)c(-c2ccc(F)cc2)n1CCC(O)CC(O)CC(=O)O",
100
+ "indication": "HMG-CoA reductase inhibitor for hypercholesterolemia."
101
+ },
102
+ {
103
+ "name": "Remdesivir",
104
+ "category": "Antiviral",
105
+ "smiles": "CCC(CC)COC(=O)C(C)NP(=O)(OCC1C(C(C(O1)(C#N)c2ccc3n2ncnc3N)O)O)Oc4ccccc4",
106
+ "indication": "Viral RNA-dependent RNA polymerase nucleotide prodrug inhibitor."
107
+ },
108
+ {
109
+ "name": "Imatinib (Gleevec)",
110
+ "category": "Oncology / Kinase Inhibitor",
111
+ "smiles": "Cc1ccc(NC(=O)c2ccc(CN3CCN(C)CC3)cc2)cc1Nc4nccc(-c5cccnc5)n4",
112
+ "indication": "BCR-ABL tyrosine kinase inhibitor for chronic myelogenous leukemia."
113
+ },
114
+ {
115
+ "name": "Sildenafil (Viagra)",
116
+ "category": "PDE5 Inhibitor",
117
+ "smiles": "CCCC1=NN(C)C2=C1N=C(NC2=O)c3cc(S(=O)(=O)N4CCN(C)CC4)ccc3OCC",
118
+ "indication": "Phosphodiesterase type 5 inhibitor for erectile dysfunction."
119
+ },
120
+ {
121
+ "name": "Penicillin V",
122
+ "category": "Beta-Lactam Antibiotic",
123
+ "smiles": "CC1(C)SC2C(NC(=O)COc3ccccc3)C(=O)N2C1C(=O)O",
124
+ "indication": "Bacterial cell wall synthesis inhibitor for streptococcal infections."
125
+ },
126
+ {
127
+ "name": "Diazepam (Valium)",
128
+ "category": "Benzodiazepine / Anxiolytic",
129
+ "smiles": "CN1C(=O)CN=C(c2ccccc2)c3cc(Cl)ccc13",
130
+ "indication": "GABA-A receptor positive allosteric modulator for anxiety."
131
+ },
132
+ {
133
+ "name": "Nicotine",
134
+ "category": "Cholinergic Agonist",
135
+ "smiles": "CN1CCCC1c2cccnc2",
136
+ "indication": "Nicotinic acetylcholine receptor agonist."
137
+ },
138
+ {
139
+ "name": "Metformin",
140
+ "category": "Antidiabetic",
141
+ "smiles": "CN(C)C(=N)NC(=N)N",
142
+ "indication": "AMPK activator decreasing hepatic glucose production."
143
+ },
144
+ {
145
+ "name": "Warfarin",
146
+ "category": "Anticoagulant",
147
+ "smiles": "CC(=O)CC(c1ccccc1)c2c(O)c3ccccc3oc2=O",
148
+ "indication": "Vitamin K epoxide reductase complex subunit 1 inhibitor."
149
+ },
150
+ {
151
+ "name": "Ciprofloxacin",
152
+ "category": "Fluoroquinolone Antibiotic",
153
+ "smiles": "O=C(O)c1cn(C2CC2)c3cc(N4CCNCC4)c(F)cc3c1=O",
154
+ "indication": "Bacterial DNA topoisomerase IV and DNA gyrase inhibitor."
155
+ }
156
+ ]
157
+
158
+
159
+ def generate_molecule_svg(
160
+ mol: Chem.Mol,
161
+ width: int = 400,
162
+ height: int = 300,
163
+ highlight_atoms: Optional[List[int]] = None,
164
+ highlight_colors: Optional[Dict[int, Tuple[float, float, float]]] = None
165
+ ) -> str:
166
+ """Generate high-resolution 2D vector SVG drawing of the molecule."""
167
+ drawer = rdMolDraw2D.MolDraw2DSVG(width, height)
168
+ opts = drawer.drawOptions()
169
+ opts.clearBackground = True
170
+ opts.bondLineWidth = 2.0
171
+ opts.addAtomIndices = False
172
+
173
+ if highlight_atoms:
174
+ drawer.DrawMolecule(mol, highlightAtoms=highlight_atoms, highlightAtomColors=highlight_colors or {})
175
+ else:
176
+ drawer.DrawMolecule(mol)
177
+ drawer.FinishDrawing()
178
+ return drawer.GetDrawingText()
179
+
180
+
181
+ # --- Pydantic Request & Response Models ---
182
+
183
+ class ValidateRequest(BaseModel):
184
+ smiles: str = Field(..., description="SMILES string to validate")
185
+
186
+ class ValidateResponse(BaseModel):
187
+ valid: bool
188
+ error: Optional[str] = None
189
+ canonical_smiles: Optional[str] = None
190
+ formula: Optional[str] = None
191
+ svg_2d: Optional[str] = None
192
+ heavy_atoms: Optional[int] = None
193
+ mw: Optional[float] = None
194
+
195
+ class AnalyzeRequest(BaseModel):
196
+ smiles: str = Field(..., description="Canonical SMILES string")
197
+
198
+ class ExplainRequest(BaseModel):
199
+ smiles: str = Field(...)
200
+ model: str = Field("mpnn", description="One of: mpnn, gin, gat, gcn")
201
+ dataset: str = Field("esol", description="One of: esol, bbbp, tox21")
202
+ task_idx: int = Field(0, description="Task index (0 for ESOL/BBBP, 0-11 for Tox21)")
203
+ epochs: int = Field(35, description="Optimization epochs for GNNExplainer")
204
+
205
+ class ConformerRequest(BaseModel):
206
+ smiles: str = Field(...)
207
+ seed: int = Field(42)
208
+
209
+ class ChemicalSpaceRequest(BaseModel):
210
+ smiles: str = Field(...)
211
+ dataset: str = Field("esol")
212
+
213
+ class AnalogsRequest(BaseModel):
214
+ smiles: str = Field(...)
215
+ dataset: str = Field("esol")
216
+ max_analogs: int = Field(8)
217
+
218
+ class BatchScreenRequest(BaseModel):
219
+ smiles_list: List[str] = Field(...)
220
+ compound_names: Optional[List[str]] = None
221
+ prefer_cns: bool = Field(False)
222
+
223
+ class SketcherAtom(BaseModel):
224
+ id: int
225
+ element: str = "C"
226
+ x: float = 0.0
227
+ y: float = 0.0
228
+
229
+ class SketcherBond(BaseModel):
230
+ source: int
231
+ target: int
232
+ order: float = 1.0
233
+
234
+ class GraphToSmilesRequest(BaseModel):
235
+ atoms: List[SketcherAtom]
236
+ bonds: List[SketcherBond]
237
+
238
+ class SmilesToGraphRequest(BaseModel):
239
+ smiles: str
240
+
241
+
242
+
243
+ # --- REST Endpoints ---
244
+
245
+ @app.get("/api/health")
246
+ def health_check():
247
+ """Verify backend status and PyTorch/PyG/RDKit runtime health."""
248
+ return {
249
+ "status": "healthy",
250
+ "framework": "PyTorch Geometric 2.8 & RDKit",
251
+ "architectures": ["MPNN", "GIN", "GAT", "GCN", "Random Forest"],
252
+ "datasets": ["ESOL", "BBBP", "Tox21"],
253
+ "checkpoints_loaded": len(_MODEL_CACHE)
254
+ }
255
+
256
+
257
+ @app.get("/api/presets")
258
+ def get_presets():
259
+ """Return curated library of pharmaceutical drug presets."""
260
+ return PRESET_DRUGS
261
+
262
+
263
+ @app.post("/api/validate", response_model=ValidateResponse)
264
+ def validate_smiles(req: ValidateRequest):
265
+ """
266
+ Rapid, non-blocking SMILES validation endpoint for real-time frontend typing feedback.
267
+ """
268
+ smi = req.smiles.strip()
269
+ if not smi:
270
+ return ValidateResponse(
271
+ valid=False,
272
+ error="SMILES string is empty. Please enter a valid molecular structure."
273
+ )
274
+
275
+ mol = Chem.MolFromSmiles(smi)
276
+ if mol is None:
277
+ # Provide helpful hint for common syntax issues
278
+ if ('(' in smi or ')' in smi) and smi.count('(') != smi.count(')'):
279
+ err_msg = f"Invalid structure: Unmatched parentheses in '{smi}'."
280
+ elif any(c.isdigit() for c in smi) and sum(smi.count(str(d)) % 2 != 0 for d in range(1, 10)) > 0:
281
+ err_msg = f"Invalid structure: Unclosed ring closure number in '{smi}'."
282
+ else:
283
+ err_msg = f"Invalid chemical structure: '{smi}'. Check for valence violations or unrecognized atom symbols."
284
+
285
+ return ValidateResponse(
286
+ valid=False,
287
+ error=err_msg
288
+ )
289
+
290
+ try:
291
+ can_smi = Chem.MolToSmiles(mol)
292
+ formula = Chem.rdMolDescriptors.CalcMolFormula(mol)
293
+ svg = generate_molecule_svg(mol, width=280, height=180)
294
+ mw = round(float(Descriptors.MolWt(mol)), 2)
295
+ heavy = int(mol.GetNumHeavyAtoms())
296
+
297
+ return ValidateResponse(
298
+ valid=True,
299
+ error=None,
300
+ canonical_smiles=can_smi,
301
+ formula=formula,
302
+ svg_2d=svg,
303
+ heavy_atoms=heavy,
304
+ mw=mw
305
+ )
306
+ except Exception as e:
307
+ return ValidateResponse(
308
+ valid=False,
309
+ error=f"Error featurizing molecule: {str(e)}"
310
+ )
311
+
312
+
313
+ @app.post("/api/sketcher/graph-to-smiles")
314
+ def sketcher_graph_to_smiles(req: GraphToSmilesRequest):
315
+ """Convert drawn 2D atom/bond graph into validated canonical SMILES."""
316
+ if not req.atoms:
317
+ return {
318
+ "valid": True,
319
+ "smiles": "",
320
+ "formula": "",
321
+ "mw": 0.0,
322
+ "heavy_atoms": 0,
323
+ "svg_2d": ""
324
+ }
325
+
326
+ mol = Chem.RWMol()
327
+ id_to_idx = {}
328
+ for a in req.atoms:
329
+ elem = a.element.strip().capitalize()
330
+ if not elem:
331
+ elem = "C"
332
+ try:
333
+ idx = mol.AddAtom(Chem.Atom(elem))
334
+ id_to_idx[a.id] = idx
335
+ except Exception:
336
+ idx = mol.AddAtom(Chem.Atom("C"))
337
+ id_to_idx[a.id] = idx
338
+
339
+ for b in req.bonds:
340
+ src = id_to_idx.get(b.source)
341
+ dst = id_to_idx.get(b.target)
342
+ if src is not None and dst is not None and src != dst:
343
+ if not mol.GetBondBetweenAtoms(src, dst):
344
+ order = Chem.BondType.SINGLE
345
+ if b.order == 2:
346
+ order = Chem.BondType.DOUBLE
347
+ elif b.order == 3:
348
+ order = Chem.BondType.TRIPLE
349
+ elif b.order == 1.5:
350
+ order = Chem.BondType.AROMATIC
351
+ mol.AddBond(src, dst, order)
352
+
353
+ try:
354
+ Chem.SanitizeMol(mol)
355
+ smi = Chem.MolToSmiles(mol)
356
+ formula = Chem.rdMolDescriptors.CalcMolFormula(mol)
357
+ mw = round(float(Descriptors.MolWt(mol)), 2)
358
+ svg = generate_molecule_svg(mol, width=280, height=180)
359
+ return {
360
+ "valid": True,
361
+ "smiles": smi,
362
+ "formula": formula,
363
+ "mw": mw,
364
+ "heavy_atoms": int(mol.GetNumHeavyAtoms()),
365
+ "svg_2d": svg
366
+ }
367
+ except Exception:
368
+ # Generate non-sanitized SMILES if valence is temporarily incomplete during drawing
369
+ try:
370
+ smi = Chem.MolToSmiles(mol, kekuleSmiles=True)
371
+ return {
372
+ "valid": True,
373
+ "smiles": smi,
374
+ "formula": "",
375
+ "mw": 0.0,
376
+ "heavy_atoms": int(mol.GetNumHeavyAtoms()),
377
+ "svg_2d": ""
378
+ }
379
+ except Exception as e2:
380
+ return {
381
+ "valid": False,
382
+ "error": str(e2),
383
+ "smiles": "",
384
+ "formula": "",
385
+ "mw": 0.0,
386
+ "heavy_atoms": 0,
387
+ "svg_2d": ""
388
+ }
389
+
390
+
391
+ @app.post("/api/sketcher/smiles-to-graph")
392
+ def sketcher_smiles_to_graph(req: SmilesToGraphRequest):
393
+ """Convert any canonical SMILES into 2D canvas coordinates and graph structure."""
394
+ smi = req.smiles.strip()
395
+ if not smi:
396
+ return {"atoms": [], "bonds": [], "smiles": ""}
397
+ mol = Chem.MolFromSmiles(smi)
398
+ if not mol:
399
+ raise HTTPException(status_code=400, detail="Invalid SMILES structure")
400
+
401
+ AllChem.Compute2DCoords(mol)
402
+ conf = mol.GetConformer()
403
+
404
+ xs = [conf.GetAtomPosition(i).x for i in range(mol.GetNumAtoms())]
405
+ ys = [conf.GetAtomPosition(i).y for i in range(mol.GetNumAtoms())]
406
+ min_x, max_x = min(xs), max(xs)
407
+ min_y, max_y = min(ys), max(ys)
408
+ center_x = (min_x + max_x) / 2.0
409
+ center_y = (min_y + max_y) / 2.0
410
+
411
+ span = max(max_x - min_x, max_y - min_y, 1.0)
412
+ scale = min(220.0 / span, 45.0)
413
+
414
+ atoms = []
415
+ for i, a in enumerate(mol.GetAtoms()):
416
+ pos = conf.GetAtomPosition(i)
417
+ px = 300.0 + (pos.x - center_x) * scale
418
+ py = 200.0 - (pos.y - center_y) * scale
419
+ atoms.append({
420
+ "id": i,
421
+ "element": a.GetSymbol(),
422
+ "x": round(px, 1),
423
+ "y": round(py, 1)
424
+ })
425
+
426
+ bonds = []
427
+ for b in mol.GetBonds():
428
+ order = 1.0
429
+ if b.GetBondType() == Chem.BondType.DOUBLE:
430
+ order = 2.0
431
+ elif b.GetBondType() == Chem.BondType.TRIPLE:
432
+ order = 3.0
433
+ elif b.GetBondType() == Chem.BondType.AROMATIC:
434
+ order = 1.5
435
+ bonds.append({
436
+ "source": b.GetBeginAtomIdx(),
437
+ "target": b.GetEndAtomIdx(),
438
+ "order": order
439
+ })
440
+
441
+ return {
442
+ "atoms": atoms,
443
+ "bonds": bonds,
444
+ "smiles": Chem.MolToSmiles(mol)
445
+ }
446
+
447
+
448
+
449
+ @app.get("/api/benchmarks")
450
+ def get_benchmarks():
451
+ """
452
+ Return local benchmark results reproduced across Bemis-Murcko scaffold splits.
453
+ """
454
+ return {
455
+ "split_method": "Bemis-Murcko Scaffold Split (80% Train, 10% Val, 10% Test)",
456
+ "datasets": {
457
+ "esol": {
458
+ "name": "ESOL (Delaney Solubility)",
459
+ "task": "Regression",
460
+ "metric": "RMSE (mol/L) ↓",
461
+ "models": [
462
+ {"model": "MPNN", "rmse": 1.010, "mae": 0.777, "r2": 0.784, "rank": 1},
463
+ {"model": "GIN", "rmse": 1.054, "mae": 0.812, "r2": 0.764, "rank": 2},
464
+ {"model": "GCN", "rmse": 1.080, "mae": 0.811, "r2": 0.753, "rank": 3},
465
+ {"model": "Baseline (RF)", "rmse": 1.109, "mae": 0.859, "r2": 0.739, "rank": 4},
466
+ {"model": "GAT", "rmse": 1.208, "mae": 0.938, "r2": 0.691, "rank": 5}
467
+ ]
468
+ },
469
+ "bbbp": {
470
+ "name": "BBBP (Blood-Brain Barrier Penetration)",
471
+ "task": "Binary Classification",
472
+ "metric": "ROC-AUC ↑",
473
+ "models": [
474
+ {"model": "Baseline (RF)", "roc_auc": 0.853, "pr_auc": 0.978, "rank": 1},
475
+ {"model": "GIN", "roc_auc": 0.723, "pr_auc": 0.936, "rank": 2},
476
+ {"model": "GCN", "roc_auc": 0.719, "pr_auc": 0.951, "rank": 3},
477
+ {"model": "MPNN", "roc_auc": 0.665, "pr_auc": 0.944, "rank": 4},
478
+ {"model": "GAT", "roc_auc": 0.624, "pr_auc": 0.928, "rank": 5}
479
+ ]
480
+ },
481
+ "tox21": {
482
+ "name": "Tox21 (12 Bioassay Panels)",
483
+ "task": "Multi-Task Classification",
484
+ "metric": "Macro ROC-AUC ↑",
485
+ "models": [
486
+ {"model": "MPNN", "roc_auc": 0.757, "pr_auc": 0.304, "rank": 1},
487
+ {"model": "GCN", "roc_auc": 0.754, "pr_auc": 0.310, "rank": 2},
488
+ {"model": "GIN", "roc_auc": 0.747, "pr_auc": 0.306, "rank": 3},
489
+ {"model": "Baseline (RF)", "roc_auc": 0.730, "pr_auc": 0.349, "rank": 4},
490
+ {"model": "GAT", "roc_auc": 0.711, "pr_auc": 0.264, "rank": 5}
491
+ ]
492
+ }
493
+ }
494
+ }
495
+
496
+
497
+ @app.post("/api/analyze")
498
+ def analyze_molecule(req: AnalyzeRequest):
499
+ """
500
+ Complete Preclinical Analysis: 2D structure, ADMET, 3D conformer, and Multi-Model predictions.
501
+ """
502
+ smi = req.smiles.strip()
503
+ if not smi:
504
+ raise HTTPException(status_code=400, detail="SMILES string is empty. Please enter a valid structure.")
505
+
506
+ mol = Chem.MolFromSmiles(smi)
507
+ if mol is None:
508
+ raise HTTPException(status_code=400, detail=f"Invalid SMILES chemical structure: '{smi}'")
509
+
510
+ can_smi = Chem.MolToSmiles(mol)
511
+ formula = Chem.rdMolDescriptors.CalcMolFormula(mol)
512
+ inchikey = Chem.MolToInchiKey(mol)
513
+ svg_2d = generate_molecule_svg(mol, width=420, height=320)
514
+
515
+ # 1. ADMET Profile
516
+ admet_profile = compute_admet_profile(mol)
517
+
518
+ # 2. 3D Conformer & Strain Energy
519
+ mol_3d, mol_block, energy, conf_status = generate_3d_conformer(mol)
520
+
521
+ # 3. Multi-Model Predictions
522
+ graph = smiles_to_graph(can_smi)
523
+
524
+ # ESOL
525
+ esol_ens = predict_ensemble(graph, "esol")
526
+ esol_mean = float(esol_ens["mean"][0])
527
+ esol_std = float(esol_ens["std"][0])
528
+ esol_models = {m: round(float(v[0]), 3) for m, v in esol_ens["individual"].items()}
529
+ if "baseline" in esol_models:
530
+ esol_models["rf"] = esol_models["baseline"]
531
+
532
+ esol_data = {
533
+ "mean": round(esol_mean, 3),
534
+ "std": round(esol_std, 3),
535
+ "ci_lower": round(float(esol_ens["ci_lower"][0]), 3),
536
+ "ci_upper": round(float(esol_ens["ci_upper"][0]), 3),
537
+ "agreement": esol_ens["agreement"],
538
+ "interpretation": "High Solubility (logS > -2)" if esol_mean > -2 else ("Moderate Solubility (-4 to -2)" if esol_mean >= -4 else "Poor / Insoluble (logS < -4)"),
539
+ "models": esol_models
540
+ }
541
+
542
+ # BBBP
543
+ bbbp_ens = predict_ensemble(graph, "bbbp")
544
+ bbbp_prob = float(bbbp_ens["mean"][0])
545
+ bbbp_std = float(bbbp_ens["std"][0])
546
+ bbbp_models = {m: round(float(v[0]), 4) for m, v in bbbp_ens["individual"].items()}
547
+ if "baseline" in bbbp_models:
548
+ bbbp_models["rf"] = bbbp_models["baseline"]
549
+
550
+ bbbp_data = {
551
+ "probability": round(bbbp_prob, 4),
552
+ "probability_pct": f"{bbbp_prob * 100:.1f}%",
553
+ "std": round(bbbp_std, 4),
554
+ "ci_lower": round(float(bbbp_ens["ci_lower"][0]), 4),
555
+ "ci_upper": round(float(bbbp_ens["ci_upper"][0]), 4),
556
+ "penetrates": bbbp_prob >= 0.5,
557
+ "classification": "PENETRATES (P+)" if bbbp_prob >= 0.5 else "DOES NOT PENETRATE (P-)",
558
+ "models": bbbp_models
559
+ }
560
+
561
+ # Tox21
562
+ tox_ens = predict_ensemble(graph, "tox21")
563
+ tox_tasks = tox_ens["task_names"]
564
+ tox_mean = [round(float(p), 4) for p in tox_ens["mean"]]
565
+ tox_std = [round(float(s), 4) for s in tox_ens["std"]]
566
+ high_risk_count = sum(1 for p in tox_mean if p >= 0.5)
567
+
568
+ tox_models_mean = {m: round(float(np.mean(v)), 4) for m, v in tox_ens["individual"].items()}
569
+ if "baseline" in tox_models_mean:
570
+ tox_models_mean["rf"] = tox_models_mean["baseline"]
571
+
572
+ tox_models_high_risk = {m: int(np.sum(v >= 0.5)) for m, v in tox_ens["individual"].items()}
573
+ if "baseline" in tox_models_high_risk:
574
+ tox_models_high_risk["rf"] = tox_models_high_risk["baseline"]
575
+
576
+ tox_pathways = []
577
+ for i, t in enumerate(tox_tasks):
578
+ pathway_models = {m: round(float(v[i]), 4) for m, v in tox_ens["individual"].items()}
579
+ if "baseline" in pathway_models:
580
+ pathway_models["rf"] = pathway_models["baseline"]
581
+ tox_pathways.append({
582
+ "assay": t,
583
+ "probability": tox_mean[i],
584
+ "probability_pct": f"{tox_mean[i] * 100:.1f}%",
585
+ "std": tox_std[i],
586
+ "risk": "HIGH RISK" if tox_mean[i] >= 0.5 else ("Moderate Risk" if tox_mean[i] >= 0.25 else "LOW RISK"),
587
+ "models": pathway_models
588
+ })
589
+
590
+ tox_data = {
591
+ "pathways": tox_pathways,
592
+ "high_risk_count": high_risk_count,
593
+ "mean_risk": round(float(np.mean(tox_mean)), 4),
594
+ "max_risk": round(float(np.max(tox_mean)), 4),
595
+ "aggregate_status": "CLEAN / LOW RISK" if high_risk_count == 0 else f"{high_risk_count} ELEVATED RISK PATHWAYS",
596
+ "models": tox_models_mean,
597
+ "models_high_risk": tox_models_high_risk
598
+ }
599
+
600
+ return {
601
+ "smiles": can_smi,
602
+ "formula": formula,
603
+ "inchikey": inchikey,
604
+ "heavy_atoms": mol.GetNumHeavyAtoms(),
605
+ "graph_stats": {
606
+ "num_nodes": int(graph.num_nodes),
607
+ "num_edges": int(graph.num_edges),
608
+ "node_dim": int(graph.x.size(1)),
609
+ "edge_dim": int(graph.edge_attr.size(1)) if graph.edge_attr is not None else 0,
610
+ "engine": "PyTorch Geometric 2.8 (Live Graph Inference)"
611
+ },
612
+ "svg_2d": svg_2d,
613
+ "descriptors": admet_profile["descriptors"],
614
+ "filters": admet_profile["filters"],
615
+ "alerts": admet_profile["alerts"],
616
+ "radar": admet_profile["radar"],
617
+ "conformer": {
618
+ "mol_block": mol_block,
619
+ "energy_kcal": round(energy, 2) if energy is not None else None,
620
+ "status": conf_status
621
+ },
622
+ "predictions": {
623
+ "esol": esol_data,
624
+ "bbbp": bbbp_data,
625
+ "tox21": tox_data
626
+ }
627
+ }
628
+
629
+
630
+ @app.post("/api/conformer")
631
+ def get_conformer(req: ConformerRequest):
632
+ """Generate 3D conformer coordinates and force-field strain energy."""
633
+ smi = req.smiles.strip()
634
+ mol = Chem.MolFromSmiles(smi)
635
+ if mol is None:
636
+ raise HTTPException(status_code=400, detail=f"Invalid SMILES chemical structure: '{smi}'")
637
+ mol_3d, mol_block, energy, status = generate_3d_conformer(mol, seed=req.seed)
638
+ return {
639
+ "mol_block": mol_block,
640
+ "energy_kcal": round(energy, 2) if energy is not None else None,
641
+ "status": status
642
+ }
643
+
644
+
645
+ @app.post("/api/explain")
646
+ def run_gnn_explainer(req: ExplainRequest):
647
+ """
648
+ Run GNNExplainer mutual information optimization to attribute pharmacophores.
649
+ """
650
+ smi = req.smiles.strip()
651
+ mol = Chem.MolFromSmiles(smi)
652
+ if mol is None:
653
+ raise HTTPException(status_code=400, detail=f"Invalid SMILES chemical structure: '{smi}'")
654
+
655
+ model = get_cached_model_instance(req.model, req.dataset)
656
+ task_type = "regression" if req.dataset == "esol" else ("multitask_classification" if req.dataset == "tox21" else "classification")
657
+ num_tasks = 12 if req.dataset == "tox21" else 1
658
+
659
+ atom_imp, edge_imp, raw_pred = explain_molecule(
660
+ model=model,
661
+ smiles=smi,
662
+ task_type=task_type,
663
+ task_idx=req.task_idx,
664
+ num_tasks=num_tasks,
665
+ num_epochs=req.epochs
666
+ )
667
+
668
+ # Highlight top atom centers in red/salmon in SVG
669
+ norm_imp = (atom_imp - np.min(atom_imp)) / (np.max(atom_imp) - np.min(atom_imp) + 1e-8)
670
+ highlight_colors = {}
671
+ top_indices = []
672
+
673
+ for idx, w in enumerate(norm_imp):
674
+ if w > 0.35:
675
+ top_indices.append(idx)
676
+ # Interpolate from salmon to crimson
677
+ highlight_colors[idx] = (1.0, float(1.0 - w * 0.7), float(1.0 - w * 0.7))
678
+
679
+ svg_highlighted = generate_molecule_svg(mol, width=480, height=360, highlight_atoms=top_indices, highlight_colors=highlight_colors)
680
+
681
+ atom_details = []
682
+ for idx, imp in enumerate(atom_imp):
683
+ atom_obj = mol.GetAtomWithIdx(idx)
684
+ atom_details.append({
685
+ "atom_idx": idx,
686
+ "element": atom_obj.GetSymbol(),
687
+ "importance": round(float(imp), 4),
688
+ "normalized_saliency": round(float(norm_imp[idx]), 3),
689
+ "aromatic": atom_obj.GetIsAromatic(),
690
+ "hybridization": str(atom_obj.GetHybridization())
691
+ })
692
+
693
+ atom_details = sorted(atom_details, key=lambda x: x["importance"], reverse=True)
694
+
695
+ return {
696
+ "svg_highlighted": svg_highlighted,
697
+ "prediction": round(float(raw_pred), 4),
698
+ "atom_details": atom_details[:8],
699
+ "all_atoms": atom_details
700
+ }
701
+
702
+
703
+ @app.post("/api/chemical-space")
704
+ def get_chemical_space(req: ChemicalSpaceRequest):
705
+ """
706
+ Project query molecule into 2D chemical landscape and find top nearest neighbors.
707
+ """
708
+ smi = req.smiles.strip()
709
+ if not Chem.MolFromSmiles(smi):
710
+ raise HTTPException(status_code=400, detail=f"Invalid SMILES chemical structure: '{smi}'")
711
+
712
+ explorer = get_chemical_space_explorer(req.dataset)
713
+ query_coords = explorer.project_molecule(smi)
714
+ nearest = explorer.find_nearest_neighbors(smi, top_k=5)
715
+
716
+ # Sample reference library points for background scatter
717
+ ref_df = explorer.df
718
+ if len(ref_df) > 300:
719
+ sample_df = ref_df.sample(300, random_state=42)
720
+ else:
721
+ sample_df = ref_df
722
+
723
+ ref_points = []
724
+ for _, row in sample_df.iterrows():
725
+ ref_points.append({
726
+ "id": str(row["Compound ID"]),
727
+ "x": round(float(row["PC1"]), 3),
728
+ "y": round(float(row["PC2"]), 3),
729
+ "target": round(float(row["Target Value"]), 2)
730
+ })
731
+
732
+ return {
733
+ "query_coords": {"x": round(query_coords[0], 3), "y": round(query_coords[1], 3)} if query_coords else None,
734
+ "reference_points": ref_points,
735
+ "nearest_neighbors": nearest
736
+ }
737
+
738
+
739
+ @app.post("/api/analogs")
740
+ def lead_optimization(req: AnalogsRequest):
741
+ """
742
+ Generate bioisosteres and R-group substituted derivatives with delta property shifts.
743
+ """
744
+ smi = req.smiles.strip()
745
+ if not Chem.MolFromSmiles(smi):
746
+ raise HTTPException(status_code=400, detail=f"Invalid SMILES chemical structure: '{smi}'")
747
+
748
+ res = evaluate_lead_optimization(smi, target_dataset=req.dataset, max_analogs=req.max_analogs)
749
+ formatted = []
750
+ for item in res["analogs"]:
751
+ formatted.append({
752
+ "smiles": item["SMILES"],
753
+ "transformation": item["Transformation"],
754
+ "rationale": item["Rationale"],
755
+ "predicted_value": round(float(item["Predicted Value"]), 3),
756
+ "delta": round(float(item["Delta"]), 3),
757
+ "qed": round(float(item["QED"]), 3),
758
+ "mw": round(float(item["MW"]), 1),
759
+ "logp": round(float(item["LogP"]), 2),
760
+ "tpsa": round(float(item["TPSA"]), 1),
761
+ "lipinski_pass": item["Lipinski Pass"]
762
+ })
763
+ return {
764
+ "parent_val": round(res["parent_val"], 3),
765
+ "parent_qed": round(res["parent_qed"], 3),
766
+ "analogs": formatted
767
+ }
768
+
769
+
770
+ @app.post("/api/batch-screen")
771
+ def batch_screen(req: BatchScreenRequest):
772
+ """
773
+ High-throughput virtual screening of candidate SMILES library.
774
+ """
775
+ if not req.smiles_list:
776
+ raise HTTPException(status_code=400, detail="Empty candidate batch list provided.")
777
+
778
+ df = screen_batch_molecules(
779
+ smiles_list=req.smiles_list,
780
+ compound_names=req.compound_names,
781
+ prefer_bbb=req.prefer_cns
782
+ )
783
+ return df.to_dict(orient="records")
784
+
785
+
786
+ # Static file serving: Serve modern React production frontend (frontend/dist)
787
+ FRONTEND_DIST = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'frontend', 'dist'))
788
+
789
+ if os.path.exists(os.path.join(FRONTEND_DIST, 'index.html')):
790
+ app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIST, "assets")), name="assets")
791
+
792
+ @app.get("/{full_path:path}", response_class=HTMLResponse)
793
+ def serve_react_app(full_path: str):
794
+ file_path = os.path.join(FRONTEND_DIST, full_path)
795
+ if full_path and os.path.exists(file_path) and os.path.isfile(file_path):
796
+ return FileResponse(file_path)
797
+ return FileResponse(os.path.join(FRONTEND_DIST, "index.html"))
798
+ else:
799
+ @app.get("/", response_class=HTMLResponse)
800
+ def serve_build_placeholder():
801
+ return "<h1>BioGraph AI: Run 'cd frontend && npm run build' to compile React UI.</h1>"
802
+
803
+
804
+ if __name__ == "__main__":
805
+ import uvicorn
806
+ import os
807
+ port = int(os.environ.get("PORT", 8000))
808
+ uvicorn.run("app.server:app", host="0.0.0.0", port=port, reload=False)