Samad14's picture
feat: techspec additions — de novo tier-6 branch, structure export, page capture + final synthesis
4939f56
Raw History Blame Contribute Delete
31.4 kB
from __future__ import annotations
import json
import math
import re
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field
from typing import Any, Optional
from app.services.supabase import get_client
from app.services.auth import require_user_id
from app.services.ssrf import validate_url
router = APIRouter(prefix="/api/docking", tags=["Docking"])
_TABLE = "docking_jobs"
# ---------------------------------------------------------------------------
# Request / response schemas (match frontend DockingResult type)
# ---------------------------------------------------------------------------
class DockingJobCreate(BaseModel):
pdb_id: str = ""
smiles: str
pdb_url: str = ""
grid_center: Optional[list[float]] = None
grid_size: list[float] = Field(default_factory=lambda: [20.0, 20.0, 20.0])
exhaustiveness: int = 8
num_modes: int = 9
class DockingJobResponse(BaseModel):
job_id: str
status: str
result: Optional[dict[str, Any]] = None
error: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _prune_old(supabase, max_rows: int = 200):
try:
rows = (
supabase.table(_TABLE)
.select("id")
.order("created_at", desc=True)
.range(max_rows, max_rows + 1000)
.execute()
.data
)
if rows:
supabase.table(_TABLE).delete().in_(
"id", [r["id"] for r in rows]
).execute()
except Exception:
pass
def _row_to_response(row: dict) -> dict:
"""Convert a Supabase row to the frontend DockingResult shape."""
result = None
# Prefer Storage URL (Phase 0c)
storage_url = row.get("storage_url")
if storage_url:
from app.services.artifact_storage import download_json
result = download_json(storage_url)
elif row.get("result_sdf"):
try:
result = json.loads(row["result_sdf"])
except Exception:
pass
return {
"job_id": row["id"],
"status": row["status"],
"result": result,
"error": row.get("error"),
}
def _row_to_list_response(row: dict) -> dict:
"""Lightweight row conversion for list views — skips Storage downloads."""
return {
"job_id": row["id"],
"status": row["status"],
"result": None,
"error": row.get("error"),
}
def _ligand_properties(smiles: str) -> dict:
"""Compute essential ligand properties from SMILES using RDKit.
Uses the same descriptor conventions as the ADMET tool (HBA = N+O count).
"""
try:
from rdkit import Chem
from rdkit.Chem import Descriptors, Lipinski, rdMolDescriptors
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return {}
hydrogen_count = sum(a.GetTotalNumHs() for a in mol.GetAtoms())
return {
"molecular_formula": rdMolDescriptors.CalcMolFormula(mol),
"molecular_weight": round(Descriptors.MolWt(mol), 2),
"heavy_atoms": int(mol.GetNumHeavyAtoms()),
"hydrogen_count": int(hydrogen_count),
"total_atoms": int(mol.GetNumHeavyAtoms() + hydrogen_count),
"rotatable_bonds": int(Lipinski.NumRotatableBonds(mol)),
"tpsa": round(Descriptors.TPSA(mol, includeSandP=True), 2),
"hbd": int(Lipinski.NumHDonors(mol)),
"hba": int(rdMolDescriptors.CalcNumLipinskiHBA(mol)),
"logp": round(Descriptors.MolLogP(mol), 2),
}
except Exception:
return {}
# ---------------------------------------------------------------------------
# Background worker
# ---------------------------------------------------------------------------
def _run_docking_sync(job_id: str, payload: dict):
"""Run the full docking pipeline synchronously (in a thread)."""
supabase = get_client()
try:
supabase.table(_TABLE).update({"status": "running"}).eq("id", job_id).execute()
from app.tools.docking import (
fetch_pdb_from_rcsb,
compute_grid_center,
smiles_to_pdbqt,
pdb_to_pdbqt_receptor,
run_vina,
)
import urllib.request
from app.services.ssrf import validate_url
pdb_id = payload.get("pdb_id", "").strip().upper()
pdb_url = payload.get("pdb_url", "").strip()
smiles = payload.get("ligand_smiles") or payload.get("smiles")
if not smiles:
raise ValueError("Missing ligand_smiles in job payload")
# 1. Obtain PDB text
pdb_text: str | None = None
if pdb_url:
validate_url(pdb_url) # SSRF guard even in worker
try:
pdb_text = urllib.request.urlopen(pdb_url, timeout=30).read().decode("utf-8", errors="replace")
except Exception:
pass
if not pdb_text and pdb_id:
pdb_text = fetch_pdb_from_rcsb(pdb_id)
if not pdb_text:
raise RuntimeError(
"Could not obtain a PDB structure. "
"Provide a valid pdb_id or pdb_url."
)
# 2. Strip heteroatoms (keep protein backbone for receptor)
protein_lines = [
l for l in pdb_text.splitlines()
if l.startswith("ATOM") or l.startswith("TER") or l.startswith("END")
]
protein_pdb = "\n".join(protein_lines) if protein_lines else pdb_text
# 3. Compute grid center if not provided
grid_center = payload.get("grid_center")
if not grid_center or all(v == 0 for v in grid_center):
grid_center = compute_grid_center(protein_pdb)
# Add a small offset so the center isn't dead on a backbone atom
grid_center = [round(c + 2.0, 3) for c in grid_center]
grid_size = payload.get("grid_size", [20.0, 20.0, 20.0])
# 4. Prepare receptor
protein_pdbqt = pdb_to_pdbqt_receptor(protein_pdb)
# 5. Prepare ligand
lig_pdbqt = smiles_to_pdbqt(smiles)
# 6. Run AutoDock Vina
vina_result = run_vina(
protein_pdbqt=protein_pdbqt,
ligand_pdbqt=lig_pdbqt,
grid_center=grid_center,
grid_size=grid_size,
exhaustiveness=payload.get("exhaustiveness", 8),
num_modes=payload.get("num_modes", 9),
)
# 7. Compute interaction summary for best pose
interactions = _compute_interactions(
protein_pdb, vina_result["ligand_pdb"]
)
pose_interactions = _summarize_pose_interactions(
protein_pdb, vina_result.get("result_sdf", "")
)
# 8. Ligand essential data (from SMILES, RDKit)
ligand_properties = _ligand_properties(smiles)
result_obj = {
"pdb_id": pdb_id,
"smiles": smiles,
"poses": vina_result["poses"],
"num_poses": vina_result["num_poses"],
"ligand_properties": ligand_properties,
"box_center": {
"x": grid_center[0],
"y": grid_center[1],
"z": grid_center[2],
},
"box_size": {
"x": grid_size[0],
"y": grid_size[1],
"z": grid_size[2],
},
"vina_log": vina_result.get("vina_log", ""),
"vina_version": vina_result.get("vina_version", ""),
"vina_seed": (vina_result.get("vina_meta") or {}).get("random_seed"),
"vina_exhaustiveness": (vina_result.get("vina_meta") or {}).get("exhaustiveness"),
"interactions": interactions,
"pose_interactions": pose_interactions,
"ligand_pdb": vina_result.get("ligand_pdb", ""),
"result_sdf": vina_result.get("result_sdf", ""),
"receptor_pdb": protein_pdb,
}
# Offload to Supabase Storage; DB keeps only the URL
from app.services.artifact_storage import upload_json
storage_url = upload_json(job_id, "result", result_obj)
supabase.table(_TABLE).update({
"status": "complete",
"storage_url": storage_url,
"result_sdf": None, # cleared — data lives in Storage now
}).eq("id", job_id).execute()
except Exception as exc:
import traceback
tb = traceback.format_exc()
supabase.table(_TABLE).update({
"status": "failed",
"error": f"{exc}\n\n{tb}"[:4000],
}).eq("id", job_id).execute()
finally:
_prune_old(supabase)
# ---------------------------------------------------------------------------
# Geometric interaction detector (H-bonds, hydrophobic, pi-stacking, salt bridges)
# ---------------------------------------------------------------------------
# Protein atom classification
_HYDROPHOBIC_RES = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "TRP", "PRO", "GLY"}
_AROMATIC_RES = {"PHE", "TRP", "TYR", "HIS"}
# Atoms in aromatic rings by residue (PDB atom names)
_AROMATIC_RING_ATOMS = {
"PHE": ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
"TYR": ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
"HIS": ["CG", "ND1", "CD2", "CE1", "NE2"],
"TRP": ["CG", "CD1", "CD2", "NE1", "CE2", "CE3", "CZ2", "CZ3", "CH2"],
}
# Two-ring centroids for TRP (5-membered + 6-membered)
_TRP_RING_ATOMS = {
"five": ["CD1", "NE1", "CE2", "CG", "CD2"],
"six": ["CE2", "CD2", "CZ2", "CH2", "CZ3", "CE3"],
}
# Polar atoms eligible for H-bonding
_POLAR_ATOMS = {"N", "O", "S"}
# Residue-level charge groups for salt bridges
_ANIONIC_RES = {"ASP", "GLU"}
_CATIONIC_RES = {"LYS", "ARG", "HIS"}
# Atom names that define the charged group center
_ANIONIC_CARBONS = {"ASP": "CG", "GLU": "CD"}
_CATIONIC_NITROGENS = {"LYS": "NZ", "ARG": ["CZ", "NH1", "NH2"]}
_PDB_COORD_RE = re.compile(
r"^(ATOM|HETATM)\s+\d+\s+(\S+)\s+(\S{3})\s+(\S)\s+(\d+)\s+"
r"([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)"
)
def _parse_atom_coords(pdb_text: str) -> list[tuple[str, str, str, str, int, float, float, float]]:
"""Parse PDB into (record, atom_name, res_name, chain, res_seq, x, y, z)."""
atoms = []
for line in pdb_text.splitlines():
m = _PDB_COORD_RE.match(line)
if m:
atoms.append((
m.group(1), m.group(2), m.group(3), m.group(4),
int(m.group(5)),
float(m.group(6)), float(m.group(7)), float(m.group(8)),
))
return atoms
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
def _angle(a: tuple[float, float, float], b: tuple[float, float, float],
c: tuple[float, float, float]) -> float:
"""Angle at vertex b between segments b→a and b→c, in degrees."""
ba = tuple(x - y for x, y in zip(a, b))
bc = tuple(x - y for x, y in zip(c, b))
dot = sum(x * y for x, y in zip(ba, bc))
mag_ba = math.sqrt(sum(x * x for x in ba))
mag_bc = math.sqrt(sum(x * x for x in bc))
if mag_ba < 1e-9 or mag_bc < 1e-9:
return 0.0
cos_angle = max(-1.0, min(1.0, dot / (mag_ba * mag_bc)))
return math.degrees(math.acos(cos_angle))
def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def _vec_norm(v: tuple[float, float, float]) -> float:
return math.sqrt(sum(x * x for x in v))
def _ring_centroid(coords: list[tuple[float, float, float]]) -> tuple[float, float, float]:
n = len(coords)
if n == 0:
return (0.0, 0.0, 0.0)
return (
sum(c[0] for c in coords) / n,
sum(c[1] for c in coords) / n,
sum(c[2] for c in coords) / n,
)
def _ring_normal(coords: list[tuple[float, float, float]]) -> tuple[float, float, float]:
"""Compute the normal vector of a planar ring via cross product of two edges."""
if len(coords) < 3:
return (0.0, 0.0, 1.0)
v1 = _vec_sub(coords[1], coords[0])
v2 = _vec_sub(coords[2], coords[0])
cross = _vec_cross(v1, v2)
n = _vec_norm(cross)
if n < 1e-9:
return (0.0, 0.0, 1.0)
return (cross[0] / n, cross[1] / n, cross[2] / n)
def _build_residue_map(atoms: list[tuple]) -> dict[tuple[str, str, int], list[tuple]]:
"""Group atoms by (chain, res_name, res_seq)."""
res_map: dict[tuple[str, str, int], list[tuple]] = {}
for a in atoms:
key = (a[3], a[2], a[4]) # chain, res_name, res_seq
res_map.setdefault(key, []).append(a)
return res_map
def _find_hydrogens(atoms: list[tuple]) -> list[tuple]:
"""Return only hydrogen atoms from parsed PDB."""
return [a for a in atoms if a[1].startswith("H") or a[1] in ("1H", "2H", "3H")]
def _compute_interactions(protein_pdb: str, ligand_pdb: str) -> dict:
"""
Compute protein-ligand interactions using proper geometry.
H-bonds: donor-H···acceptor angle > 120°, distance < 3.5Å
Hydrophobic: ligand carbon near protein carbon in hydrophobic residue, < 4.5Å
Pi-stacking: aromatic ring centroids, distance < 5.5Å, inter-ring angle
Salt bridges: charged group centroids, distance < 4.0Å
"""
if not ligand_pdb:
return {"hbonds": [], "hydrophobic": [], "pi_stacking": [], "salt_bridges": []}
prot_atoms = _parse_atom_coords(protein_pdb)
lig_atoms = _parse_atom_coords(ligand_pdb)
prot_h = _find_hydrogens(prot_atoms)
lig_h = _find_hydrogens(lig_atoms)
prot_heavy = [a for a in prot_atoms if not (a[1].startswith("H") or a[1] in ("1H", "2H", "3H"))]
lig_heavy = [a for a in lig_atoms if not (a[1].startswith("H") or a[1] in ("1H", "2H", "3H"))]
hbonds: list[dict] = []
hydrophobic: list[dict] = []
pi_stacking: list[dict] = []
salt_bridges: list[dict] = []
seen_hbonds: set[tuple] = set()
seen_hydrophobic: set[tuple] = set()
seen_salt: set[tuple] = set()
# --- H-bonds with angle check ---
for la in lig_heavy:
l_elem = la[1][0] if la[1] else ""
if l_elem not in _POLAR_ATOMS:
continue
lcoord = (la[5], la[6], la[7])
# Find nearest H on ligand for angle reference
lig_h_near = None
min_h_dist = 1.5
for h in lig_h:
hd = _distance(lcoord, (h[5], h[6], h[7]))
if hd < min_h_dist:
min_h_dist = hd
lig_h_near = (h[5], h[6], h[7])
for pa in prot_heavy:
p_elem = pa[1][0] if pa[1] else ""
if p_elem not in _POLAR_ATOMS:
continue
pcoord = (pa[5], pa[6], pa[7])
d = _distance(lcoord, pcoord)
if d > 3.5 or d < 1.0:
continue
# Find nearest H on protein donor for angle check
prot_h_near = None
min_ph_dist = 1.5
for h in prot_h:
hd = _distance(pcoord, (h[5], h[6], h[7]))
if hd < min_ph_dist:
min_ph_dist = hd
prot_h_near = (h[5], h[6], h[7])
# Check angle if we have hydrogen positions
angle_ok = True
if lig_h_near and prot_h_near:
# H-bond angle: ligand-H···protein or protein-H···ligand
a1 = _angle(lig_h_near, lcoord, pcoord)
a2 = _angle(prot_h_near, pcoord, lcoord)
angle_ok = max(a1, a2) > 120.0
elif lig_h_near:
a1 = _angle(lig_h_near, lcoord, pcoord)
angle_ok = a1 > 120.0
elif prot_h_near:
a1 = _angle(prot_h_near, pcoord, lcoord)
angle_ok = a1 > 120.0
# If no H found at all, accept based on distance + element only
if not angle_ok:
continue
key = (la[4], pa[4]) # (lig_res_seq, prot_res_seq)
if key in seen_hbonds:
continue
seen_hbonds.add(key)
hbonds.append({
"type": "hbond",
"ligand_atom": la[1],
"ligand_coords": [la[5], la[6], la[7]],
"protein_residue": pa[2],
"protein_residue_seq": pa[4],
"protein_chain": pa[3],
"protein_atom": pa[1],
"protein_coords": [pa[5], pa[6], pa[7]],
"distance": round(d, 2),
"confidence": "high" if d < 3.0 else "medium",
})
if len(hbonds) >= 20:
break
if len(hbonds) >= 20:
break
# --- Hydrophobic contacts ---
for la in lig_heavy:
if la[1][0] != "C":
continue
lcoord = (la[5], la[6], la[7])
for pa in prot_heavy:
if pa[1][0] != "C":
continue
pres = pa[2]
if pres not in _HYDROPHOBIC_RES:
continue
pcoord = (pa[5], pa[6], pa[7])
d = _distance(lcoord, pcoord)
if d < 4.5:
key = (la[4], pa[4])
if key in seen_hydrophobic:
continue
seen_hydrophobic.add(key)
hydrophobic.append({
"type": "hydrophobic",
"ligand_atom": la[1],
"ligand_coords": [la[5], la[6], la[7]],
"protein_residue": pres,
"protein_residue_seq": pa[4],
"protein_chain": pa[3],
"protein_atom": pa[1],
"protein_coords": [pa[5], pa[6], pa[7]],
"distance": round(d, 2),
})
if len(hydrophobic) >= 20:
break
if len(hydrophobic) >= 20:
break
# --- Pi-stacking (aromatic ring centroid geometry) ---
prot_res_map = _build_residue_map(prot_heavy)
for res_key, res_atoms in prot_res_map.items():
chain, res_name, res_seq = res_key
if res_name not in _AROMATIC_RES:
continue
ring_atom_names = _AROMATIC_RING_ATOMS[res_name]
ring_atoms_by_name = {a[1]: a for a in res_atoms}
ring_coords = []
for rn in ring_atom_names:
if rn in ring_atoms_by_name:
a = ring_atoms_by_name[rn]
ring_coords.append((a[5], a[6], a[7]))
if len(ring_coords) < 3:
continue
centroid = _ring_centroid(ring_coords)
normal = _ring_normal(ring_coords)
# For TRP, also check the 5-membered ring
rings_to_check = [(ring_coords, centroid, normal)]
if res_name == "TRP":
for ring_name in ("five", "six"):
ring_atom_names_2 = _TRP_RING_ATOMS[ring_name]
coords_2 = []
for rn in ring_atom_names_2:
if rn in ring_atoms_by_name:
a = ring_atoms_by_name[rn]
coords_2.append((a[5], a[6], a[7]))
if len(coords_2) >= 3:
rings_to_check.append((coords_2, _ring_centroid(coords_2), _ring_normal(coords_2)))
for ring_coords_r, centroid_r, normal_r in rings_to_check:
# Find aromatic atoms in ligand (heuristic: C/N in a flat region)
lig_aromatic_coords = []
for la in lig_heavy:
if la[1][0] in ("C", "N"):
lig_aromatic_coords.append((la[5], la[6], la[7]))
if len(lig_aromatic_coords) < 3:
continue
# Use all ligand heavy atoms as a pseudo-centroid
lig_centroid = _ring_centroid(lig_aromatic_coords)
dist = _distance(centroid_r, lig_centroid)
if dist > 6.5:
continue
# Compute angle between ring normal and vector to ligand centroid
v_to_lig = _vec_sub(lig_centroid, centroid_r)
v_norm = _vec_norm(v_to_lig)
if v_norm < 1e-9:
continue
cos_angle = abs(sum(x * y for x, y in zip(normal_r, v_to_lig))) / (
_vec_norm(normal_r) * v_norm
)
ring_angle = math.degrees(math.acos(max(0, min(1, cos_angle))))
# Parallel: ring normal ~parallel to centroid-centroid vector (angle < 30°)
# T-shaped: ring normal ~perpendicular (angle 60-90°)
stacking_type = "unknown"
if ring_angle < 30 and dist < 5.5:
stacking_type = "parallel"
elif 60 < ring_angle < 90 and dist < 6.5:
stacking_type = "perpendicular"
if stacking_type == "unknown":
continue
pi_stacking.append({
"type": "pi_stacking",
"protein_residue": res_name,
"protein_residue_seq": res_seq,
"protein_chain": chain,
"ring_centroid": [round(c, 3) for c in centroid_r],
"ring_normal": [round(c, 3) for c in normal_r],
"ligand_centroid": [round(c, 3) for c in lig_centroid],
"distance": round(dist, 2),
"angle": round(ring_angle, 1),
"stacking_type": stacking_type,
"confidence": "high" if dist < 4.5 else "medium",
})
if len(pi_stacking) >= 10:
break
if len(pi_stacking) >= 10:
break
# --- Salt bridges (charged group centroid distance) ---
for la in lig_heavy:
l_elem = la[1][0] if la[1] else ""
if l_elem not in ("N", "O", "S", "C"):
continue
lcoord = (la[5], la[6], la[7])
for pa in prot_heavy:
pres = pa[2]
if pres in _ANIONIC_RES and pa[1] in ("OD1", "OD2", "OE1", "OE2"):
d = _distance(lcoord, pa[1:8] if False else (pa[5], pa[6], pa[7]))
if d < 4.0 and l_elem in ("N",):
key = (la[4], pa[4])
if key not in seen_salt:
seen_salt.add(key)
salt_bridges.append({
"type": "salt_bridge",
"ligand_atom": la[1],
"ligand_coords": [la[5], la[6], la[7]],
"protein_residue": pres,
"protein_residue_seq": pa[4],
"protein_chain": pa[3],
"protein_atom": pa[1],
"protein_coords": [pa[5], pa[6], pa[7]],
"distance": round(d, 2),
"charge_pair": "positive-negative",
})
if pres in _CATIONIC_RES:
cat_atoms = _CATIONIC_NITROGENS.get(pres, [])
if isinstance(cat_atoms, str):
cat_atoms = [cat_atoms]
if pa[1] in cat_atoms:
d = _distance(lcoord, (pa[5], pa[6], pa[7]))
if d < 4.0 and l_elem in ("O",):
key = (la[4], pa[4])
if key not in seen_salt:
seen_salt.add(key)
salt_bridges.append({
"type": "salt_bridge",
"ligand_atom": la[1],
"ligand_coords": [la[5], la[6], la[7]],
"protein_residue": pres,
"protein_residue_seq": pa[4],
"protein_chain": pa[3],
"protein_atom": pa[1],
"protein_coords": [pa[5], pa[6], pa[7]],
"distance": round(d, 2),
"charge_pair": "negative-positive",
})
return {
"hbonds": hbonds[:20],
"hydrophobic": hydrophobic[:20],
"pi_stacking": pi_stacking[:10],
"salt_bridges": salt_bridges[:10],
}
def _summarize_pose_interactions(protein_pdb: str, output_pdbqt: str) -> list[dict]:
"""Per-pose interaction summary."""
if not output_pdbqt:
return []
models: dict[int, list[str]] = {}
current: int | None = None
for line in output_pdbqt.splitlines():
if line.startswith("MODEL"):
parts = line.split()
if len(parts) >= 2:
current = int(parts[1])
models[current] = []
elif line.startswith("ENDMDL"):
current = None
elif current is not None:
models.setdefault(current, []).append(line)
summaries = []
for mid in sorted(models.keys()):
lig_pdb = "\n".join(l for l in models[mid] if l.startswith("HETATM")) + "\nEND"
inter = _compute_interactions(protein_pdb, lig_pdb)
summaries.append({
"model": mid,
"hbonds": len(inter.get("hbonds", [])),
"hydrophobic": len(inter.get("hydrophobic", [])),
"pi_stacking": len(inter.get("pi_stacking", [])),
"salt_bridges": len(inter.get("salt_bridges", [])),
})
return summaries
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
@router.post("/run", response_model=DockingJobResponse)
async def create_docking_job(request: Request, body: DockingJobCreate, user_id: str = Depends(require_user_id)):
supabase = get_client()
_prune_old(supabase)
# SSRF validation on user-supplied URL
if body.pdb_url:
validate_url(body.pdb_url)
import uuid, datetime
job_id = str(uuid.uuid4())
now = datetime.datetime.utcnow().isoformat()
insert_row = {
"id": job_id,
"status": "queued",
"ligand_smiles": body.smiles,
"user_id": user_id,
"payload": {
"pdb_id": body.pdb_id,
"pdb_url": body.pdb_url,
"grid_center": body.grid_center or [0, 0, 0],
"grid_size": body.grid_size,
"exhaustiveness": body.exhaustiveness,
"num_modes": body.num_modes,
"smiles": body.smiles,
"ligand_smiles": body.smiles,
},
}
try:
supabase.table(_TABLE).insert(insert_row).execute()
except Exception as e:
if "ligand_smiles" in str(e):
supabase.table(_TABLE).insert({
"id": job_id, "status": "queued", "user_id": user_id,
"payload": insert_row["payload"],
}).execute()
else:
raise
return DockingJobResponse(job_id=job_id, status="queued", result=None)
@router.get("/status/{job_id}", response_model=DockingJobResponse)
async def get_docking_job(job_id: str, user_id: str = Depends(require_user_id)):
supabase = get_client()
result = supabase.table(_TABLE).select("*").eq("id", job_id).eq("user_id", user_id).single().execute()
if not result.data:
raise HTTPException(status_code=404, detail="Docking job not found")
return DockingJobResponse(**_row_to_response(result.data))
@router.get("/result/{job_id}/pdb")
async def get_docking_pdb(job_id: str, user_id: str = Depends(require_user_id)):
supabase = get_client()
row = supabase.table(_TABLE).select("result_sdf,storage_url").eq("id", job_id).eq("user_id", user_id).single().execute()
if not row.data:
raise HTTPException(status_code=404, detail="Docking result not found")
data = None
if row.data.get("storage_url"):
from app.services.artifact_storage import download_json
data = download_json(row.data["storage_url"])
elif row.data.get("result_sdf"):
try:
data = json.loads(row.data["result_sdf"])
except Exception:
pass
if not data:
raise HTTPException(status_code=404, detail="Docking result not found")
ligand_pdb = data.get("ligand_pdb", "")
if not ligand_pdb:
raise HTTPException(status_code=404, detail="No ligand PDB available")
from fastapi.responses import PlainTextResponse
return PlainTextResponse(ligand_pdb, media_type="text/plain")
def _load_docking_result(job_id: str, user_id: str) -> dict:
supabase = get_client()
row = supabase.table(_TABLE).select("result_sdf,storage_url").eq("id", job_id).eq("user_id", user_id).single().execute()
if not row.data:
raise HTTPException(status_code=404, detail="Docking result not found")
data = None
if row.data.get("storage_url"):
from app.services.artifact_storage import download_json
data = download_json(row.data["storage_url"])
elif row.data.get("result_sdf"):
try:
data = json.loads(row.data["result_sdf"])
except Exception:
pass
if not data:
raise HTTPException(status_code=404, detail="Docking result not found")
return data
@router.get("/result/{job_id}/ligand.sdf")
async def get_docking_ligand_sdf(job_id: str, user_id: str = Depends(require_user_id)):
"""Ligand-only SDF of the docked poses (techspec §2)."""
data = _load_docking_result(job_id, user_id)
sdf = data.get("result_sdf", "")
if not sdf:
raise HTTPException(
status_code=404,
detail="No SDF stored for this job (older runs predate SDF persistence — re-run the docking job)",
)
return PlainTextResponse(
sdf,
media_type="chemical/x-mdl-molfile",
headers={"Content-Disposition": f'attachment; filename="docked_{job_id[:8]}.sdf"'},
)
@router.get("/result/{job_id}/complex.pdb")
async def get_docking_complex_pdb(job_id: str, user_id: str = Depends(require_user_id)):
"""Receptor + docked ligand merged into a single PDB (techspec §2)."""
data = _load_docking_result(job_id, user_id)
receptor = data.get("receptor_pdb", "")
ligand = data.get("ligand_pdb", "")
if not receptor or not ligand:
raise HTTPException(
status_code=404,
detail="Complex export needs both receptor and ligand structures (older runs may lack them — re-run the job)",
)
complex_text = receptor.rstrip() + "\n" + ligand.rstrip() + "\nEND\n"
return PlainTextResponse(
complex_text,
media_type="chemical/x-pdb",
headers={"Content-Disposition": f'attachment; filename="complex_{job_id[:8]}.pdb"'},
)
@router.get("")
async def list_docking_jobs(limit: int = 50, user_id: str = Depends(require_user_id)):
supabase = get_client()
rows = (
supabase.table(_TABLE)
.select("*")
.eq("user_id", user_id)
.order("created_at", desc=True)
.limit(limit)
.execute()
.data
)
return {"jobs": [_row_to_list_response(r) for r in rows]}