Samad14 commited on
Commit
a02ccd9
·
verified ·
1 Parent(s): 4e1d2ee

Upload folder using huggingface_hub

Browse files
.env CHANGED
@@ -17,3 +17,6 @@ R2_BUCKET_NAME=bioflow-raw-responses
17
 
18
  # Demo mode (optional — returns cached results for known sequences)
19
  DEMO_MODE=false
 
 
 
 
17
 
18
  # Demo mode (optional — returns cached results for known sequences)
19
  DEMO_MODE=false
20
+
21
+ # Hugging Face CLI (for `hf upload` deploys only, not read by the app)
22
+ HF_TOKEN=
.env.deploy CHANGED
@@ -17,3 +17,6 @@ R2_BUCKET_NAME=bioflow-raw-responses
17
 
18
  # Demo mode (optional — returns cached results for known sequences)
19
  DEMO_MODE=false
 
 
 
 
17
 
18
  # Demo mode (optional — returns cached results for known sequences)
19
  DEMO_MODE=false
20
+
21
+ # Sentry (optional — error monitoring)
22
+ SENTRY_DSN=
Dockerfile CHANGED
@@ -1,7 +1,7 @@
1
  FROM python:3.11-slim
2
 
3
  RUN apt-get update && apt-get install -y --no-install-recommends \
4
- build-essential gcc wget ca-certificates && \
5
  rm -rf /var/lib/apt/lists/*
6
 
7
  # Download pre-compiled PhyML binary from bioconda
@@ -12,6 +12,10 @@ RUN wget -qO /tmp/phyml.tar.bz2 \
12
  chmod +x /usr/local/bin/phyml && \
13
  rm -rf /tmp/phyml.tar.bz2 /tmp/bin
14
 
 
 
 
 
15
  WORKDIR /app
16
  COPY requirements.txt .
17
  RUN pip install --no-cache-dir -r requirements.txt
 
1
  FROM python:3.11-slim
2
 
3
  RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ build-essential gcc wget ca-certificates openbabel && \
5
  rm -rf /var/lib/apt/lists/*
6
 
7
  # Download pre-compiled PhyML binary from bioconda
 
12
  chmod +x /usr/local/bin/phyml && \
13
  rm -rf /tmp/phyml.tar.bz2 /tmp/bin
14
 
15
+ # Download pre-compiled AutoDock Vina binary from GitHub releases
16
+ RUN wget -q "https://github.com/autodock/autodock-vina/releases/download/v1.2.5/vina_1.2.5_linux_x86_64" -O /usr/local/bin/vina && \
17
+ chmod +x /usr/local/bin/vina
18
+
19
  WORKDIR /app
20
  COPY requirements.txt .
21
  RUN pip install --no-cache-dir -r requirements.txt
app/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (177 Bytes). View file
 
app/__pycache__/main.cpython-311.pyc ADDED
Binary file (8.39 kB). View file
 
app/config.py CHANGED
@@ -24,6 +24,7 @@ class Settings:
24
  NCBI_EMAIL: str = os.getenv("NCBI_EMAIL", "bioflow@example.com")
25
  DEMO_MODE: bool = os.getenv("DEMO_MODE", "false").lower() in ("true", "1", "yes")
26
  CORS_ORIGIN: str = os.getenv("CORS_ORIGIN", "https://bioai-platform.vercel.app")
 
27
 
28
 
29
  settings = Settings()
 
24
  NCBI_EMAIL: str = os.getenv("NCBI_EMAIL", "bioflow@example.com")
25
  DEMO_MODE: bool = os.getenv("DEMO_MODE", "false").lower() in ("true", "1", "yes")
26
  CORS_ORIGIN: str = os.getenv("CORS_ORIGIN", "https://bioai-platform.vercel.app")
27
+ SENTRY_DSN: str = os.getenv("SENTRY_DSN", "")
28
 
29
 
30
  settings = Settings()
app/main.py CHANGED
@@ -1,4 +1,7 @@
1
  import logging
 
 
 
2
  from dotenv import load_dotenv
3
  load_dotenv()
4
 
@@ -9,7 +12,7 @@ from slowapi import Limiter, _rate_limit_exceeded_handler
9
  from slowapi.util import get_remote_address
10
  from slowapi.errors import RateLimitExceeded
11
  from app.config import settings
12
- from app.routers import pipelines, pipeline_v2, ai, jobs, share, profile, sequences, uniprot, alignment, structures, pathways, domains, interactions, primers, structure_analysis, phylo, export, api_keys
13
  from app.services.cache import init_redis
14
 
15
  logger = logging.getLogger(__name__)
@@ -53,6 +56,8 @@ app.include_router(structure_analysis.router)
53
  app.include_router(phylo.router)
54
  app.include_router(export.router, prefix="/api/export", tags=["export"])
55
  app.include_router(api_keys.router, prefix="/api/keys", tags=["api_keys"])
 
 
56
 
57
  TERMINAL_STATUSES = {"complete", "failed"}
58
  NON_TERMINAL_STATUSES = {
@@ -96,13 +101,20 @@ async def _fail_stuck_jobs():
96
 
97
  @app.on_event("startup")
98
  async def startup():
 
 
 
 
 
99
  init_redis()
100
  await _fail_stuck_jobs()
101
 
102
 
103
  @app.get("/health")
104
  async def health():
105
- return {"status": "ok"}
 
 
106
 
107
 
108
  @app.exception_handler(Exception)
 
1
  import logging
2
+ import os
3
+
4
+ import sentry_sdk
5
  from dotenv import load_dotenv
6
  load_dotenv()
7
 
 
12
  from slowapi.util import get_remote_address
13
  from slowapi.errors import RateLimitExceeded
14
  from app.config import settings
15
+ from app.routers import pipelines, pipeline_v2, ai, jobs, share, profile, sequences, uniprot, alignment, structures, pathways, domains, interactions, primers, structure_analysis, phylo, export, api_keys, cache_stats, docking
16
  from app.services.cache import init_redis
17
 
18
  logger = logging.getLogger(__name__)
 
56
  app.include_router(phylo.router)
57
  app.include_router(export.router, prefix="/api/export", tags=["export"])
58
  app.include_router(api_keys.router, prefix="/api/keys", tags=["api_keys"])
59
+ app.include_router(cache_stats.router)
60
+ app.include_router(docking.router)
61
 
62
  TERMINAL_STATUSES = {"complete", "failed"}
63
  NON_TERMINAL_STATUSES = {
 
101
 
102
  @app.on_event("startup")
103
  async def startup():
104
+ sentry_sdk.init(
105
+ dsn=settings.SENTRY_DSN,
106
+ environment=os.getenv("ENVIRONMENT", "development"),
107
+ traces_sample_rate=0.1,
108
+ )
109
  init_redis()
110
  await _fail_stuck_jobs()
111
 
112
 
113
  @app.get("/health")
114
  async def health():
115
+ from app.services.cache import get_cache_stats
116
+ stats = get_cache_stats()
117
+ return {"status": "ok", "cache": stats}
118
 
119
 
120
  @app.exception_handler(Exception)
app/routers/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (185 Bytes). View file
 
app/routers/__pycache__/api_keys.cpython-311.pyc ADDED
Binary file (3.47 kB). View file
 
app/routers/__pycache__/export.cpython-311.pyc ADDED
Binary file (3.75 kB). View file
 
app/routers/__pycache__/pipeline_v2.cpython-311.pyc ADDED
Binary file (32.7 kB). View file
 
app/routers/__pycache__/share.cpython-311.pyc ADDED
Binary file (2.96 kB). View file
 
app/routers/cache_stats.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from fastapi import APIRouter, Request
3
+ from app.services.cache import get_cache_stats, reset_cache_stats
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ router = APIRouter(prefix="/api/admin", tags=["admin"])
8
+
9
+
10
+ @router.get("/cache-stats")
11
+ async def cache_stats():
12
+ return get_cache_stats()
13
+
14
+
15
+ @router.post("/cache-stats/reset")
16
+ async def reset_stats():
17
+ reset_cache_stats()
18
+ return {"status": "ok"}
app/routers/docking.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
10
+ from pydantic import BaseModel
11
+
12
+ logger = logging.getLogger(__name__)
13
+ router = APIRouter(prefix="/api/docking", tags=["docking"])
14
+
15
+ # ─── In-memory store ──────────────────────────────────────────────────────────
16
+
17
+ _jobs: dict[str, dict] = {}
18
+ _lock = threading.Lock()
19
+
20
+
21
+ class DockingRequest(BaseModel):
22
+ pdb_id: str
23
+ smiles: str
24
+
25
+
26
+ class DockingJob(BaseModel):
27
+ job_id: str
28
+ pdb_id: str
29
+ smiles: str
30
+ status: str = "queued"
31
+ result: Optional[dict] = None
32
+ error: Optional[str] = None
33
+ created_at: float = 0.0
34
+ done_at: Optional[float] = None
35
+
36
+
37
+ def _init(job_id: str, req: DockingRequest) -> None:
38
+ with _lock:
39
+ _jobs[job_id] = {
40
+ "job_id": job_id,
41
+ "pdb_id": req.pdb_id,
42
+ "smiles": req.smiles,
43
+ "status": "queued",
44
+ "result": None,
45
+ "error": None,
46
+ "created_at": time.time(),
47
+ "done_at": None,
48
+ }
49
+
50
+
51
+ def _patch(job_id: str, **kw) -> None:
52
+ with _lock:
53
+ if job_id in _jobs:
54
+ _jobs[job_id].update(kw)
55
+
56
+
57
+ def _read(job_id: str) -> dict | None:
58
+ with _lock:
59
+ return dict(_jobs[job_id]) if job_id in _jobs else None
60
+
61
+
62
+ async def _worker(job_id: str) -> None:
63
+ job = _read(job_id)
64
+ if not job:
65
+ return
66
+ _patch(job_id, status="preparing")
67
+
68
+ from app.tools.docking import DockingTool
69
+
70
+ tool = DockingTool()
71
+ result = await tool.run({
72
+ "pdb_id": job["pdb_id"],
73
+ "smiles": job["smiles"],
74
+ })
75
+
76
+ if "error" in result and not result.get("poses"):
77
+ _patch(job_id, status="failed", error=result["error"], done_at=time.time())
78
+ else:
79
+ _patch(job_id, status="complete", result=result, done_at=time.time())
80
+
81
+
82
+ @router.post("/run")
83
+ async def run_docking(req: DockingRequest, background_tasks: BackgroundTasks):
84
+ if not req.pdb_id.strip():
85
+ raise HTTPException(400, detail="pdb_id is required")
86
+ if not req.smiles.strip():
87
+ raise HTTPException(400, detail="smiles is required")
88
+
89
+ job_id = str(uuid.uuid4())
90
+ _init(job_id, req)
91
+ background_tasks.add_task(_worker, job_id)
92
+ return {"job_id": job_id, "status": "queued"}
93
+
94
+
95
+ @router.get("/status/{job_id}")
96
+ async def get_status(job_id: str):
97
+ job = _read(job_id)
98
+ if not job:
99
+ raise HTTPException(404, detail=f"Job {job_id} not found")
100
+ return job
app/services/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (186 Bytes). View file
 
app/services/__pycache__/auth.cpython-311.pyc ADDED
Binary file (4.25 kB). View file
 
app/services/__pycache__/supabase.cpython-311.pyc ADDED
Binary file (734 Bytes). View file
 
app/services/cache.py CHANGED
@@ -9,6 +9,7 @@ from app.config import settings
9
  logger = logging.getLogger(__name__)
10
 
11
  _redis = None
 
12
 
13
 
14
  def init_redis():
@@ -29,7 +30,11 @@ def get_redis():
29
  def cache_get(key: str) -> str | None:
30
  r = get_redis()
31
  if r:
32
- return r.get(key)
 
 
 
 
33
  return None
34
 
35
 
@@ -39,6 +44,15 @@ def cache_set(key: str, value: str, ttl: int = 86400):
39
  r.setex(key, ttl, value)
40
 
41
 
 
 
 
 
 
 
 
 
 
42
  def ttl_cache(ttl: int = 86400, prefix: str = "cache"):
43
  def decorator(func: Callable) -> Callable:
44
  @functools.wraps(func)
@@ -50,7 +64,10 @@ def ttl_cache(ttl: int = 86400, prefix: str = "cache"):
50
  cached = cache_get(cache_key)
51
  if cached is not None:
52
  try:
53
- return json.loads(cached)
 
 
 
54
  except (json.JSONDecodeError, TypeError):
55
  pass
56
 
@@ -59,6 +76,8 @@ def ttl_cache(ttl: int = 86400, prefix: str = "cache"):
59
  cache_set(cache_key, json.dumps(result), ttl=ttl)
60
  except (TypeError, ValueError):
61
  pass
 
 
62
  return result
63
 
64
  return wrapper
 
9
  logger = logging.getLogger(__name__)
10
 
11
  _redis = None
12
+ _cache_stats = {"hits": 0, "misses": 0}
13
 
14
 
15
  def init_redis():
 
30
  def cache_get(key: str) -> str | None:
31
  r = get_redis()
32
  if r:
33
+ val = r.get(key)
34
+ if val is not None:
35
+ _cache_stats["hits"] += 1
36
+ return val
37
+ _cache_stats["misses"] += 1
38
  return None
39
 
40
 
 
44
  r.setex(key, ttl, value)
45
 
46
 
47
+ def get_cache_stats() -> dict:
48
+ return {**_cache_stats, "redis_connected": _redis is not None}
49
+
50
+
51
+ def reset_cache_stats():
52
+ _cache_stats["hits"] = 0
53
+ _cache_stats["misses"] = 0
54
+
55
+
56
  def ttl_cache(ttl: int = 86400, prefix: str = "cache"):
57
  def decorator(func: Callable) -> Callable:
58
  @functools.wraps(func)
 
64
  cached = cache_get(cache_key)
65
  if cached is not None:
66
  try:
67
+ result = json.loads(cached)
68
+ if isinstance(result, dict):
69
+ result["from_cache"] = True
70
+ return result
71
  except (json.JSONDecodeError, TypeError):
72
  pass
73
 
 
76
  cache_set(cache_key, json.dumps(result), ttl=ttl)
77
  except (TypeError, ValueError):
78
  pass
79
+ if isinstance(result, dict):
80
+ result["from_cache"] = False
81
  return result
82
 
83
  return wrapper
app/services/ncbi_service.py CHANGED
@@ -77,6 +77,7 @@ class NCBIService:
77
  except Exception as e:
78
  return {"error": str(e)}
79
 
 
80
  async def search_by_name(self, term: str, db: str = "protein", max_results: int = 10) -> dict:
81
  try:
82
  handle = Entrez.esearch(db=db, term=term, retmax=max_results)
 
77
  except Exception as e:
78
  return {"error": str(e)}
79
 
80
+ @ttl_cache(ttl=86400, prefix="ncbi_search")
81
  async def search_by_name(self, term: str, db: str = "protein", max_results: int = 10) -> dict:
82
  try:
83
  handle = Entrez.esearch(db=db, term=term, retmax=max_results)
app/services/pathway_enrichment.py CHANGED
@@ -1,12 +1,29 @@
1
  import httpx
 
 
2
  import logging
3
 
 
 
4
  logger = logging.getLogger(__name__)
5
 
6
  ANALYSIS_BASE = "https://reactome.org/AnalysisService"
7
 
8
 
9
  async def run_enrichment(identifiers: list[str]) -> dict | None:
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  try:
11
  body = "\n".join(identifiers)
12
  async with httpx.AsyncClient(timeout=30) as client:
@@ -48,10 +65,16 @@ async def run_enrichment(identifiers: list[str]) -> dict | None:
48
 
49
  pathways.sort(key=lambda p: p["entitiesFDR"])
50
 
51
- return {
52
  "token": token,
53
  "pathways": pathways,
54
  }
 
 
 
 
 
 
55
  except Exception as e:
56
  logger.warning(f"Pathway enrichment failed: {e}")
57
  return None
 
1
  import httpx
2
+ import json
3
+ import hashlib
4
  import logging
5
 
6
+ from app.services.cache import cache_get, cache_set
7
+
8
  logger = logging.getLogger(__name__)
9
 
10
  ANALYSIS_BASE = "https://reactome.org/AnalysisService"
11
 
12
 
13
  async def run_enrichment(identifiers: list[str]) -> dict | None:
14
+ raw = json.dumps(sorted(identifiers), sort_keys=True)
15
+ key_hash = hashlib.sha256(raw.encode()).hexdigest()[:16]
16
+ cache_key = f"enrichment:{key_hash}"
17
+
18
+ cached = cache_get(cache_key)
19
+ if cached is not None:
20
+ try:
21
+ result = json.loads(cached)
22
+ if isinstance(result, dict):
23
+ result["from_cache"] = True
24
+ return result
25
+ except (json.JSONDecodeError, TypeError):
26
+ pass
27
  try:
28
  body = "\n".join(identifiers)
29
  async with httpx.AsyncClient(timeout=30) as client:
 
65
 
66
  pathways.sort(key=lambda p: p["entitiesFDR"])
67
 
68
+ result = {
69
  "token": token,
70
  "pathways": pathways,
71
  }
72
+ try:
73
+ cache_set(cache_key, json.dumps(result), ttl=86400)
74
+ except (TypeError, ValueError):
75
+ pass
76
+ result["from_cache"] = False
77
+ return result
78
  except Exception as e:
79
  logger.warning(f"Pathway enrichment failed: {e}")
80
  return None
app/tools/docking.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import re
5
+ import shutil
6
+ import tempfile
7
+ import time
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from app.tools.base import BaseTool
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ PDB_DOWNLOAD = "https://files.rcsb.org/download/{pdb_id}.pdb"
17
+ VINA_CMD = shutil.which("vina") or "/usr/local/bin/vina"
18
+
19
+
20
+ def _find_ligand_center(pdb_content: str) -> tuple[float, float, float] | None:
21
+ """Find the geometric center of the largest HETATM ligand (non-water)."""
22
+ het_atoms: list[list[tuple[float, float, float]]] = []
23
+ current_het: list[tuple[float, float, float]] = []
24
+ current_resname = ""
25
+ for line in pdb_content.splitlines():
26
+ if line.startswith("HETATM"):
27
+ resname = line[17:20].strip()
28
+ if resname == "HOH":
29
+ continue
30
+ try:
31
+ x = float(line[30:38].strip())
32
+ y = float(line[38:46].strip())
33
+ z = float(line[46:54].strip())
34
+ except ValueError:
35
+ continue
36
+ if resname != current_resname:
37
+ if current_het:
38
+ het_atoms.append(current_het)
39
+ current_het = [(x, y, z)]
40
+ current_resname = resname
41
+ else:
42
+ current_het.append((x, y, z))
43
+ elif line.startswith("ATOM") or line.startswith("TER"):
44
+ if current_het:
45
+ het_atoms.append(current_het)
46
+ current_het = []
47
+ current_resname = ""
48
+ if current_het:
49
+ het_atoms.append(current_het)
50
+
51
+ if not het_atoms:
52
+ return None
53
+
54
+ largest = max(het_atoms, key=len)
55
+ cx = sum(a[0] for a in largest) / len(largest)
56
+ cy = sum(a[1] for a in largest) / len(largest)
57
+ cz = sum(a[2] for a in largest) / len(largest)
58
+ return cx, cy, cz
59
+
60
+
61
+ def _find_protein_center(pdb_content: str) -> tuple[float, float, float]:
62
+ xs, ys, zs = [], [], []
63
+ for line in pdb_content.splitlines():
64
+ if line.startswith("ATOM") and len(line) >= 54:
65
+ try:
66
+ xs.append(float(line[30:38].strip()))
67
+ ys.append(float(line[38:46].strip()))
68
+ zs.append(float(line[46:54].strip()))
69
+ except ValueError:
70
+ continue
71
+ if not xs:
72
+ return 0.0, 0.0, 0.0
73
+ return sum(xs) / len(xs), sum(ys) / len(ys), sum(zs) / len(zs)
74
+
75
+
76
+ def _clean_protein(pdb_content: str) -> str:
77
+ """Keep only ATOM records (protein), strip HETATM, waters, ANISOU, CONECT."""
78
+ lines: list[str] = []
79
+ for line in pdb_content.splitlines():
80
+ if line.startswith("ATOM") and len(line) >= 54:
81
+ lines.append(line)
82
+ elif line.startswith("TER"):
83
+ lines.append(line)
84
+ elif line.startswith("END"):
85
+ lines.append(line)
86
+ return "\n".join(lines)
87
+
88
+
89
+ def _parse_vina_pdbqt(pdbqt: str) -> list[dict[str, Any]]:
90
+ """Parse Vina output PDBQT into individual pose dicts."""
91
+ models = re.split(r"^MODEL\s+(\d+)", pdbqt, flags=re.MULTILINE)
92
+ poses: list[dict[str, Any]] = []
93
+ current_atoms: list[dict[str, Any]] = []
94
+ current_model = 0
95
+
96
+ for chunk in models:
97
+ chunk = chunk.strip()
98
+ if chunk.isdigit():
99
+ current_model = int(chunk)
100
+ current_atoms = []
101
+ elif chunk and current_model > 0:
102
+ for line in chunk.splitlines():
103
+ if line.startswith("ATOM") or line.startswith("HETATM"):
104
+ try:
105
+ x = float(line[30:38].strip())
106
+ y = float(line[38:46].strip())
107
+ z = float(line[46:54].strip())
108
+ elem = line[76:78].strip()
109
+ current_atoms.append({"x": x, "y": y, "z": z, "element": elem})
110
+ except ValueError:
111
+ continue
112
+ if current_atoms:
113
+ energy_match = re.search(r"REMARK VINA RESULT:\s*([-\d.]+)", chunk)
114
+ poses.append({
115
+ "model": current_model,
116
+ "atoms": len(current_atoms),
117
+ "affinity": float(energy_match.group(1)) if energy_match else None,
118
+ })
119
+ current_atoms = []
120
+ return poses
121
+
122
+
123
+ class DockingTool(BaseTool):
124
+ name = "docking"
125
+
126
+ async def run(self, input: dict) -> dict:
127
+ pdb_id = input.get("pdb_id", "").strip().upper()
128
+ smiles = input.get("smiles", "").strip()
129
+
130
+ if not pdb_id or not smiles:
131
+ return {"error": "pdb_id and smiles are required"}
132
+
133
+ tmpdir = tempfile.mkdtemp(prefix="docking_")
134
+ try:
135
+ # 1. Fetch PDB
136
+ pdb_url = PDB_DOWNLOAD.format(pdb_id=pdb_id)
137
+ async with httpx.AsyncClient(timeout=30) as client:
138
+ r = await client.get(pdb_url)
139
+ if r.status_code != 200:
140
+ return {"error": f"PDB {pdb_id} not found at RCSB"}
141
+ pdb_content = r.text
142
+
143
+ pdb_path = os.path.join(tmpdir, "protein.pdb")
144
+ with open(pdb_path, "w") as f:
145
+ f.write(pdb_content)
146
+
147
+ # 2. Clean protein (strip waters, heteroatoms)
148
+ cleaned = _clean_protein(pdb_content)
149
+ clean_path = os.path.join(tmpdir, "cleaned.pdb")
150
+ with open(clean_path, "w") as f:
151
+ f.write(cleaned)
152
+
153
+ # 3. Convert protein to PDBQT via obabel
154
+ protein_pdbqt = os.path.join(tmpdir, "protein.pdbqt")
155
+ proc = await asyncio.create_subprocess_exec(
156
+ "obabel", clean_path, "-O", protein_pdbqt, "-xr",
157
+ stdout=asyncio.subprocess.PIPE,
158
+ stderr=asyncio.subprocess.PIPE,
159
+ )
160
+ _, stderr = await proc.communicate()
161
+ if proc.returncode != 0 or not os.path.exists(protein_pdbqt):
162
+ err = stderr.decode() if stderr else "obabel failed"
163
+ return {"error": f"Protein PDBQT preparation failed: {err}"}
164
+
165
+ # 4. Convert SMILES to 3D PDBQT via obabel
166
+ ligand_pdbqt = os.path.join(tmpdir, "ligand.pdbqt")
167
+ proc = await asyncio.create_subprocess_exec(
168
+ "obabel", f"-:{smiles}", "-O", ligand_pdbqt, "--gen3d", "-h",
169
+ stdout=asyncio.subprocess.PIPE,
170
+ stderr=asyncio.subprocess.PIPE,
171
+ )
172
+ _, stderr = await proc.communicate()
173
+ if proc.returncode != 0 or not os.path.exists(ligand_pdbqt):
174
+ err = stderr.decode() if stderr else "obabel failed"
175
+ return {"error": f"Ligand PDBQT preparation failed: {err}"}
176
+
177
+ # 5. Determine binding site box
178
+ center = _find_ligand_center(pdb_content)
179
+ if center:
180
+ cx, cy, cz = center
181
+ sx = sy = sz = 20
182
+ else:
183
+ cx, cy, cz = _find_protein_center(pdb_content)
184
+ sx = sy = sz = 30
185
+
186
+ # 6. Run Vina
187
+ out_pdbqt = os.path.join(tmpdir, "out.pdbqt")
188
+ vina_cmd = await asyncio.create_subprocess_exec(
189
+ VINA_CMD,
190
+ "--receptor", protein_pdbqt,
191
+ "--ligand", ligand_pdbqt,
192
+ "--out", out_pdbqt,
193
+ "--center_x", str(cx),
194
+ "--center_y", str(cy),
195
+ "--center_z", str(cz),
196
+ "--size_x", str(sx),
197
+ "--size_y", str(sy),
198
+ "--size_z", str(sz),
199
+ "--exhaustiveness", "8",
200
+ "--num_modes", "9",
201
+ stdout=asyncio.subprocess.PIPE,
202
+ stderr=asyncio.subprocess.PIPE,
203
+ )
204
+ try:
205
+ stdout, stderr = await asyncio.wait_for(vina_cmd.communicate(), timeout=600)
206
+ except asyncio.TimeoutError:
207
+ vina_cmd.kill()
208
+ await vina_cmd.communicate()
209
+ return {"error": "Docking timed out after 10 minutes"}
210
+
211
+ if vina_cmd.returncode != 0 or not os.path.exists(out_pdbqt):
212
+ err = stderr.decode("utf-8", errors="replace")[:500] if stderr else ""
213
+ return {"error": f"Vina failed (exit {vina_cmd.returncode}): {err}"}
214
+
215
+ # 7. Parse results
216
+ with open(out_pdbqt) as f:
217
+ out_content = f.read()
218
+
219
+ poses = _parse_vina_pdbqt(out_content)
220
+ log = stdout.decode() if stdout else ""
221
+
222
+ return {
223
+ "pdb_id": pdb_id,
224
+ "smiles": smiles,
225
+ "poses": poses,
226
+ "num_poses": len(poses),
227
+ "box_center": {"x": cx, "y": cy, "z": cz},
228
+ "box_size": {"x": sx, "y": sy, "z": sz},
229
+ "vina_log": log[:2000],
230
+ "from_cache": False,
231
+ }
232
+
233
+ except Exception as e:
234
+ logger.exception("Docking run failed")
235
+ return {"error": f"Docking failed: {e}"}
236
+ finally:
237
+ shutil.rmtree(tmpdir, ignore_errors=True)
requirements.txt CHANGED
@@ -6,6 +6,7 @@ httpx
6
  aiohttp
7
  biopython
8
  litellm
 
9
  python-dotenv
10
  supabase
11
  reportlab
 
6
  aiohttp
7
  biopython
8
  litellm
9
+ sentry-sdk
10
  python-dotenv
11
  supabase
12
  reportlab