Samad14's picture
feat(admet): ProTox 3.0 ML toxicity prediction — backend client + endpoint, frontend ProTox tab, restored sidebar chevron
38e4510
Raw History Blame Contribute Delete
7.15 kB
"""ADMET descriptor computation endpoints."""
from __future__ import annotations
import json
import os
import sys
import subprocess
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from app.services.auth import get_user_id
router = APIRouter(prefix="/api/admet", tags=["ADMET"])
class ADMETRequest(BaseModel):
smiles: str | None = Field(None, min_length=1, max_length=500, description="SMILES string")
name: str | None = Field(None, min_length=1, max_length=200, description="Chemical name (resolved via PubChem)")
cid: int | None = Field(None, ge=1, description="PubChem compound CID")
@model_validator(mode="after")
def _require_one(self) -> "ADMETRequest":
if not any([self.smiles, self.name, self.cid]):
raise ValueError("Provide one of: smiles, name, or cid")
return self
class ADMETResponse(BaseModel):
job_id: str | None = None
status: str = "complete"
result: dict | None = None
error: str | None = None
class ProToxRequest(BaseModel):
smiles: str | None = Field(None, min_length=1, max_length=500, description="SMILES string")
name: str | None = Field(None, min_length=1, max_length=200, description="Chemical name (resolved via PubChem)")
models: str | None = Field(None, min_length=1, max_length=400, description="Space-separated ProTox model shorthands")
@model_validator(mode="after")
def _require_one(self) -> "ProToxRequest":
if not any([self.smiles, self.name]):
raise ValueError("Provide one of: smiles or name")
return self
class SearchHit(BaseModel):
cid: int
name: str
formula: str | None = None
smiles: str | None = None
class SearchResponse(BaseModel):
query: str
results: list[SearchHit]
def _compute_in_subprocess(smiles: str) -> dict:
"""Run RDKit computation in an isolated subprocess to prevent segfaults."""
import tempfile
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(
'import json, sys, os\n'
f'sys.path.insert(0, {backend_dir!r})\n'
'from app.tools.admet import compute_descriptors\n'
f'result = compute_descriptors({smiles!r})\n'
'print(json.dumps(result))\n'
)
script_path = f.name
try:
result = subprocess.run(
[sys.executable, script_path],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
err = result.stderr.strip()[-500:] if result.stderr else "unknown error"
if "Invalid SMILES" in err or "ValueError" in err:
raise ValueError(f"Invalid SMILES: {smiles}")
raise RuntimeError(f"RDKit subprocess failed: {err}")
return json.loads(result.stdout)
finally:
try:
os.unlink(script_path)
except OSError:
pass
async def _resolve_input(body: ADMETRequest) -> tuple[str, str | None, int | None]:
"""Resolve the request to (smiles, chemical_name, cid)."""
from app.tools.pubchem import cid_to_record, name_to_cid, PubChemError
if body.smiles:
return body.smiles.strip(), None, None
if body.cid:
try:
rec = await cid_to_record(body.cid)
except PubChemError as e:
raise HTTPException(status_code=404, detail=str(e))
if not rec.get("smiles"):
raise HTTPException(status_code=404, detail="PubChem has no SMILES for this CID")
return rec["smiles"], rec.get("name"), rec["cid"]
# name given: resolve name -> CID -> SMILES
try:
cid = await name_to_cid(body.name or "")
except PubChemError as e:
raise HTTPException(status_code=404, detail=str(e))
if not cid:
raise HTTPException(status_code=404, detail=f"'{body.name}' not found in PubChem")
try:
rec = await cid_to_record(cid)
except PubChemError as e:
raise HTTPException(status_code=404, detail=str(e))
if not rec.get("smiles"):
raise HTTPException(status_code=404, detail="PubChem has no SMILES for this compound")
return rec["smiles"], rec.get("name") or body.name, rec["cid"]
@router.post("/descriptors", response_model=ADMETResponse)
async def compute_descriptors(body: ADMETRequest, user_id: str | None = Depends(get_user_id)):
"""Compute molecular descriptors from SMILES / chemical name / PubChem CID.
Returns Lipinski/Veber compliance, QED score, and key properties.
"""
try:
from app.tools.admet import compute_descriptors as _compute
smiles, chemical_name, cid = await _resolve_input(body)
if os.name == "nt":
result = _compute_in_subprocess(smiles)
else:
result = _compute(smiles)
if chemical_name:
result["chemical_name"] = chemical_name
if cid is not None:
result["pubchem_cid"] = cid
return ADMETResponse(result=result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Descriptor computation failed: {e}")
@router.get("/search", response_model=SearchResponse)
async def search_compounds(
q: str = Query(..., min_length=2, max_length=200, description="Name fragment"),
limit: int = Query(10, ge=1, le=25),
user_id: str | None = Depends(get_user_id),
):
"""PubChem autocomplete for chemical name search."""
from app.tools.pubchem import search_suggestions
hits = await search_suggestions(q, limit)
return SearchResponse(query=q, results=hits)
@router.post("/protox", response_model=ADMETResponse)
async def predict_toxicity(body: ProToxRequest, user_id: str | None = Depends(get_user_id)):
"""ProTox 3.0 ML-based toxicity prediction (Charité).
Queries the ProTox server for acute toxicity, toxicity targets and
optional additional models (organ toxicity, Tox21 pathways, CYPs…).
The upstream server queues requests, so a run can take up to ~5 minutes.
"""
try:
from app.tools.protox import predict_toxicity as _protox, ProToxError
smiles, chemical_name, cid = await _resolve_input(
ADMETRequest(smiles=body.smiles, name=body.name, cid=None)
)
result = await _protox(
smiles=smiles,
name=chemical_name or None,
models=body.models,
)
result["chemical_name"] = chemical_name or body.name
if cid is not None:
result["pubchem_cid"] = cid
return ADMETResponse(result=result)
except ProToxError as e:
raise HTTPException(status_code=502, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"ProTox prediction failed: {e}")