abdullahsamra commited on
Commit
aec67c1
·
1 Parent(s): 84cfbe1

Add cancellation support for generation jobs

Browse files
Files changed (1) hide show
  1. api.py +250 -53
api.py CHANGED
@@ -1,6 +1,7 @@
1
  import os
2
  import json
3
  import time
 
4
  import uuid
5
  import shutil
6
  import subprocess
@@ -23,11 +24,29 @@ BUNDLE_DIR = APP_ROOT / "bundle"
23
  OUTPUTS_DIR = APP_ROOT / "outputs"
24
  JOBS_DIR = OUTPUTS_DIR / "jobs"
25
 
26
- PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost:8000").rstrip("/")
27
  DEEPPURPOSE_URL = os.getenv("DEEPPURPOSE_URL", "http://127.0.0.1:7860/reinvent_predict")
28
  REINVENT_DEVICE = os.getenv("REINVENT_DEVICE", "cpu")
29
  ADGPU_BIN = os.getenv("ADGPU_BIN", "")
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  JOBS_DIR.mkdir(parents=True, exist_ok=True)
32
 
33
  # Attempt to load DeepPurpose affinity service (optional)
@@ -80,16 +99,22 @@ def new_job_id(prefix: str) -> str:
80
  return f"{prefix}_{stamp}_{short}"
81
 
82
 
83
- def make_file_meta(job_id: str, file_path: Path) -> Dict[str, str]:
 
 
 
 
 
84
  rel_path = file_path.relative_to(JOBS_DIR / job_id).as_posix()
85
  relative_url = f"/files/jobs/{job_id}/{rel_path}"
 
 
86
  return {
87
  "filename": file_path.name,
88
  "relative_url": relative_url,
89
- "download_url": f"{PUBLIC_BASE_URL}{relative_url}",
90
  }
91
 
92
-
93
  def clean_record(record: Dict[str, Any]) -> Dict[str, Any]:
94
  """
95
  Public response cleaner.
@@ -128,19 +153,112 @@ def dataframe_to_public_records(df: pd.DataFrame) -> List[Dict[str, Any]]:
128
  records.append(item)
129
  return records
130
 
 
 
 
 
 
 
 
 
 
 
131
 
132
- def run_cmd(cmd: List[str], cwd: Optional[Path] = None, timeout: Optional[int] = None):
133
- result = subprocess.run(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  cmd,
135
  cwd=str(cwd) if cwd else None,
136
  text=True,
137
  stdout=subprocess.PIPE,
138
  stderr=subprocess.STDOUT,
139
- timeout=timeout,
140
- check=False,
141
  )
142
- return result
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  def canonicalize_smiles(smiles: str):
146
  mol = Chem.MolFromSmiles(smiles)
@@ -231,20 +349,26 @@ def job_status_path(job_id: str) -> Path:
231
  return JOBS_DIR / job_id / "job_status.json"
232
 
233
 
234
- def make_api_url(path: str) -> str:
 
 
 
 
235
  if not path.startswith("/"):
236
  path = "/" + path
237
- return f"{PUBLIC_BASE_URL}{path}"
 
 
238
 
239
 
240
- def write_job_status(job_id: str, payload: Dict[str, Any]):
241
  job_dir = JOBS_DIR / job_id
242
  job_dir.mkdir(parents=True, exist_ok=True)
243
 
244
  base = {
245
  "job_id": job_id,
246
- "status_url": make_api_url(f"/jobs/{job_id}"),
247
- "result_url": make_api_url(f"/jobs/{job_id}/result"),
248
  }
249
  base.update(payload)
250
 
@@ -259,7 +383,7 @@ def write_job_status(job_id: str, payload: Dict[str, Any]):
259
  tmp_path.replace(path)
260
 
261
 
262
- def read_job_status(job_id: str) -> Dict[str, Any]:
263
  path = job_status_path(job_id)
264
  if not path.exists():
265
  raise HTTPException(status_code=404, detail="Job not found.")
@@ -279,8 +403,8 @@ def read_job_status(job_id: str) -> Dict[str, Any]:
279
  if last_error:
280
  return {
281
  "job_id": job_id,
282
- "status_url": make_api_url(f"/jobs/{job_id}"),
283
- "result_url": make_api_url(f"/jobs/{job_id}/result"),
284
  "status": "running",
285
  "stage": "status_update",
286
  "message": "Job status is being updated. Retry shortly."
@@ -288,47 +412,56 @@ def read_job_status(job_id: str) -> Dict[str, Any]:
288
 
289
  return {
290
  "job_id": job_id,
291
- "status_url": make_api_url(f"/jobs/{job_id}"),
292
- "result_url": make_api_url(f"/jobs/{job_id}/result"),
293
  "status": "running",
294
  "stage": "status_update",
295
  "message": "Job status is not ready yet."
296
  }
297
 
298
- def run_generate_job(job_id: str, req_data: Dict[str, Any]):
299
  job_dir = JOBS_DIR / job_id
300
 
301
  try:
302
  req = GenerateRequest(**req_data)
303
 
 
 
304
  write_job_status(job_id, {
305
  "status": "running",
306
  "stage": "sampling",
307
  "message": "Running REINVENT molecule generation",
308
  "request": req_data,
309
- })
310
 
311
  runtime_config = write_runtime_sampling_config(job_dir, req.num_molecules)
312
 
313
- run_reinvent_sampling(runtime_config, job_dir)
 
 
 
314
 
315
  write_job_status(job_id, {
316
  "status": "running",
317
  "stage": "enrichment",
318
  "message": "Running RDKit descriptors and DeepPurpose predictions",
319
  "request": req_data,
320
- })
 
 
321
 
322
- enriched_csv = run_enrichment(job_dir, req.return_top_k)
323
 
324
  write_job_status(job_id, {
325
  "status": "running",
326
  "stage": "docking",
327
  "message": f"Running docking mode: {req.docking_mode}",
328
  "request": req_data,
329
- })
 
 
330
 
331
- final_csv = run_optional_docking(job_dir, enriched_csv, req)
332
 
333
  df = pd.read_csv(final_csv)
334
 
@@ -355,8 +488,8 @@ def run_generate_job(job_id: str, req_data: Dict[str, Any]):
355
  "num_docked": count_docked(df),
356
  },
357
  "files": {
358
- "csv": make_file_meta(job_id, clean_csv),
359
- "json": make_file_meta(job_id, clean_json),
360
  },
361
  "results": results,
362
  "warnings": [
@@ -376,7 +509,15 @@ def run_generate_job(job_id: str, req_data: Dict[str, Any]):
376
  "request": req_data,
377
  "summary": result_payload["summary"],
378
  "files": result_payload["files"],
379
- })
 
 
 
 
 
 
 
 
380
 
381
  except Exception as e:
382
  if isinstance(e, HTTPException):
@@ -390,21 +531,46 @@ def run_generate_job(job_id: str, req_data: Dict[str, Any]):
390
  "message": "Generation job failed",
391
  "request": req_data,
392
  "error": detail,
393
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
  # -----------------------------
396
  # Endpoints
397
  # -----------------------------
398
 
399
  @app.get("/health")
400
- def health():
401
  return {
402
  "status": "ok",
403
  "bundle_exists": BUNDLE_DIR.exists(),
404
  "models_generator_exists": (BUNDLE_DIR / "models" / "generator" / "egfr_generator.chkpt").exists(),
405
  "models_affinity_exists": (BUNDLE_DIR / "models" / "affinity" / "model.pt").exists(),
406
  "docking_grid_exists": (BUNDLE_DIR / "docking" / "maps_current" / "4WKQ_receptor_v5_SBr.maps.fld").exists(),
407
- "public_base_url": PUBLIC_BASE_URL,
408
  "reinvent_device": REINVENT_DEVICE,
409
  "adgpu_bin": ADGPU_BIN or None,
410
  }
@@ -442,7 +608,7 @@ def get_job_file(job_id: str, path: str):
442
 
443
 
444
  @app.post("/ligands/export")
445
- def export_ligand(req: LigandExportRequest):
446
  job_id = new_job_id("lig")
447
  job_dir = JOBS_DIR / job_id
448
  job_dir.mkdir(parents=True, exist_ok=True)
@@ -467,7 +633,7 @@ def export_ligand(req: LigandExportRequest):
467
  else:
468
  raise HTTPException(status_code=400, detail="Unsupported format.")
469
 
470
- meta = make_file_meta(job_id, out_path)
471
  meta["format"] = req.format
472
 
473
  return {
@@ -526,10 +692,10 @@ def find_reinvent_command() -> List[str]:
526
  return [os.getenv("PYTHON", "python"), "-m", "reinvent.Reinvent"]
527
 
528
 
529
- def run_reinvent_sampling(runtime_config: Path, job_dir: Path):
530
  cmd = find_reinvent_command() + ["-l", str(job_dir / "reinvent_sampling.log"), str(runtime_config)]
531
 
532
- result = run_cmd(cmd, cwd=BUNDLE_DIR, timeout=1800)
533
 
534
  log_path = job_dir / "reinvent_command_output.log"
535
  log_path.write_text(result.stdout or "", encoding="utf-8", errors="replace")
@@ -545,7 +711,7 @@ def run_reinvent_sampling(runtime_config: Path, job_dir: Path):
545
  )
546
 
547
 
548
- def run_enrichment(job_dir: Path, return_top_k: int) -> Path:
549
  input_csv = job_dir / "generated_smiles.csv"
550
  enriched_csv = job_dir / "generated_smiles_enriched.csv"
551
 
@@ -565,7 +731,7 @@ def run_enrichment(job_dir: Path, return_top_k: int) -> Path:
565
  "--top-k", str(return_top_k),
566
  ]
567
 
568
- result = run_cmd(cmd, cwd=BUNDLE_DIR, timeout=1800)
569
 
570
  (job_dir / "enrichment.log").write_text(result.stdout or "", encoding="utf-8", errors="replace")
571
 
@@ -582,7 +748,7 @@ def run_enrichment(job_dir: Path, return_top_k: int) -> Path:
582
  return enriched_csv
583
 
584
 
585
- def run_optional_docking(job_dir: Path, enriched_csv: Path, req: GenerateRequest) -> Path:
586
  final_csv = job_dir / "generated_results.csv"
587
 
588
  script = BUNDLE_DIR / "tools" / "dock_enriched.py"
@@ -610,7 +776,7 @@ def run_optional_docking(job_dir: Path, enriched_csv: Path, req: GenerateRequest
610
  )
611
  cmd += ["--adgpu-bin", ADGPU_BIN]
612
 
613
- result = run_cmd(cmd, cwd=BUNDLE_DIR, timeout=7200)
614
 
615
  (job_dir / "docking.log").write_text(result.stdout or "", encoding="utf-8", errors="replace")
616
 
@@ -628,48 +794,79 @@ def run_optional_docking(job_dir: Path, enriched_csv: Path, req: GenerateRequest
628
 
629
 
630
  @app.post("/generate", status_code=202)
631
- def submit_generate(req: GenerateRequest, background_tasks: BackgroundTasks):
632
  job_id = new_job_id("gen")
633
  job_dir = JOBS_DIR / job_id
634
  job_dir.mkdir(parents=True, exist_ok=True)
635
 
636
  req_data = req.model_dump()
637
-
638
  write_job_status(job_id, {
639
  "status": "queued",
640
  "stage": "queued",
641
  "message": "Generation job accepted and queued",
642
  "request": req_data,
643
- })
644
 
645
- background_tasks.add_task(run_generate_job, job_id, req_data)
646
 
647
  return {
648
  "job_id": job_id,
649
  "status": "queued",
650
  "message": "Generation job accepted. Poll status_url until completed.",
651
- "status_url": make_api_url(f"/jobs/{job_id}"),
652
- "result_url": make_api_url(f"/jobs/{job_id}/result"),
653
  }
654
 
655
 
656
  @app.get("/jobs/{job_id}")
657
- def get_job_status(job_id: str):
658
- return read_job_status(job_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
 
 
660
 
661
  @app.get("/jobs/{job_id}/result")
662
- def get_job_result(job_id: str):
663
- status = read_job_status(job_id)
 
664
 
665
  if status.get("status") == "failed":
666
- return JSONResponse(status_code=500, content=status)
667
 
668
  if status.get("status") != "completed":
669
- return JSONResponse(status_code=202, content=status)
670
 
671
  result_path = JOBS_DIR / job_id / "generated_results.json"
672
  if not result_path.exists():
673
  raise HTTPException(status_code=404, detail="Result file not found.")
674
 
675
- return json.loads(result_path.read_text(encoding="utf-8"))
 
 
1
  import os
2
  import json
3
  import time
4
+ import threading
5
  import uuid
6
  import shutil
7
  import subprocess
 
24
  OUTPUTS_DIR = APP_ROOT / "outputs"
25
  JOBS_DIR = OUTPUTS_DIR / "jobs"
26
 
27
+ PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").strip().rstrip("/")
28
  DEEPPURPOSE_URL = os.getenv("DEEPPURPOSE_URL", "http://127.0.0.1:7860/reinvent_predict")
29
  REINVENT_DEVICE = os.getenv("REINVENT_DEVICE", "cpu")
30
  ADGPU_BIN = os.getenv("ADGPU_BIN", "")
31
 
32
+ def get_public_base_url(request: Request | None = None) -> str:
33
+ configured = PUBLIC_BASE_URL.strip().rstrip("/")
34
+
35
+ if configured and "localhost" not in configured and "127.0.0.1" not in configured:
36
+ return configured
37
+
38
+ if request is not None:
39
+ forwarded_proto = request.headers.get("x-forwarded-proto")
40
+ forwarded_host = request.headers.get("x-forwarded-host") or request.headers.get("host")
41
+
42
+ if forwarded_host:
43
+ proto = forwarded_proto or request.url.scheme or "https"
44
+ return f"{proto}://{forwarded_host}".rstrip("/")
45
+
46
+ return str(request.base_url).rstrip("/")
47
+
48
+ return configured or "http://localhost:8000"
49
+
50
  JOBS_DIR.mkdir(parents=True, exist_ok=True)
51
 
52
  # Attempt to load DeepPurpose affinity service (optional)
 
99
  return f"{prefix}_{stamp}_{short}"
100
 
101
 
102
+ def make_file_meta(
103
+ job_id: str,
104
+ file_path: Path,
105
+ request: Request | None = None,
106
+ base_url: str | None = None,
107
+ ) -> Dict[str, str]:
108
  rel_path = file_path.relative_to(JOBS_DIR / job_id).as_posix()
109
  relative_url = f"/files/jobs/{job_id}/{rel_path}"
110
+ resolved_base_url = (base_url or get_public_base_url(request)).rstrip("/")
111
+
112
  return {
113
  "filename": file_path.name,
114
  "relative_url": relative_url,
115
+ "download_url": f"{resolved_base_url}{relative_url}",
116
  }
117
 
 
118
  def clean_record(record: Dict[str, Any]) -> Dict[str, Any]:
119
  """
120
  Public response cleaner.
 
153
  records.append(item)
154
  return records
155
 
156
+ class JobCancelled(Exception):
157
+ pass
158
+
159
+
160
+ ACTIVE_PROCESSES: Dict[str, subprocess.Popen] = {}
161
+ ACTIVE_PROCESSES_LOCK = threading.Lock()
162
+
163
+
164
+ def cancel_flag_path(job_id: str) -> Path:
165
+ return JOBS_DIR / job_id / "cancel.requested"
166
 
167
+
168
+ def is_cancel_requested(job_id: str) -> bool:
169
+ return cancel_flag_path(job_id).exists()
170
+
171
+
172
+ def request_job_cancel(job_id: str) -> None:
173
+ job_dir = JOBS_DIR / job_id
174
+ job_dir.mkdir(parents=True, exist_ok=True)
175
+ cancel_flag_path(job_id).touch(exist_ok=True)
176
+
177
+
178
+ def raise_if_cancelled(job_id: str) -> None:
179
+ if is_cancel_requested(job_id):
180
+ raise JobCancelled(f"Job {job_id} was cancelled.")
181
+
182
+
183
+ def terminate_active_process(job_id: str) -> bool:
184
+ with ACTIVE_PROCESSES_LOCK:
185
+ proc = ACTIVE_PROCESSES.get(job_id)
186
+
187
+ if proc is None or proc.poll() is not None:
188
+ return False
189
+
190
+ proc.terminate()
191
+ return True
192
+
193
+ def run_cmd(
194
+ cmd: List[str],
195
+ cwd: Optional[Path] = None,
196
+ timeout: Optional[int] = None,
197
+ job_id: Optional[str] = None,
198
+ ):
199
+ if job_id:
200
+ raise_if_cancelled(job_id)
201
+
202
+ if not job_id:
203
+ return subprocess.run(
204
+ cmd,
205
+ cwd=str(cwd) if cwd else None,
206
+ text=True,
207
+ stdout=subprocess.PIPE,
208
+ stderr=subprocess.STDOUT,
209
+ timeout=timeout,
210
+ check=False,
211
+ )
212
+
213
+ started_at = time.time()
214
+
215
+ proc = subprocess.Popen(
216
  cmd,
217
  cwd=str(cwd) if cwd else None,
218
  text=True,
219
  stdout=subprocess.PIPE,
220
  stderr=subprocess.STDOUT,
 
 
221
  )
 
222
 
223
+ with ACTIVE_PROCESSES_LOCK:
224
+ ACTIVE_PROCESSES[job_id] = proc
225
+
226
+ stdout = ""
227
+
228
+ try:
229
+ while True:
230
+ try:
231
+ stdout, _ = proc.communicate(timeout=1)
232
+ break
233
+ except subprocess.TimeoutExpired:
234
+ if is_cancel_requested(job_id):
235
+ proc.terminate()
236
+ try:
237
+ stdout, _ = proc.communicate(timeout=10)
238
+ except subprocess.TimeoutExpired:
239
+ proc.kill()
240
+ stdout, _ = proc.communicate()
241
+
242
+ raise JobCancelled(f"Job {job_id} was cancelled while command was running.")
243
+
244
+ if timeout is not None and (time.time() - started_at) > timeout:
245
+ proc.kill()
246
+ stdout, _ = proc.communicate()
247
+ raise subprocess.TimeoutExpired(cmd, timeout, output=stdout)
248
+
249
+ if is_cancel_requested(job_id):
250
+ raise JobCancelled(f"Job {job_id} was cancelled.")
251
+
252
+ return subprocess.CompletedProcess(
253
+ args=cmd,
254
+ returncode=proc.returncode,
255
+ stdout=stdout,
256
+ stderr=None,
257
+ )
258
+
259
+ finally:
260
+ with ACTIVE_PROCESSES_LOCK:
261
+ ACTIVE_PROCESSES.pop(job_id, None)
262
 
263
  def canonicalize_smiles(smiles: str):
264
  mol = Chem.MolFromSmiles(smiles)
 
349
  return JOBS_DIR / job_id / "job_status.json"
350
 
351
 
352
+ def make_api_url(
353
+ path: str,
354
+ request: Request | None = None,
355
+ base_url: str | None = None,
356
+ ) -> str:
357
  if not path.startswith("/"):
358
  path = "/" + path
359
+
360
+ resolved_base_url = (base_url or get_public_base_url(request)).rstrip("/")
361
+ return f"{resolved_base_url}{path}"
362
 
363
 
364
+ def write_job_status(job_id: str, payload: Dict[str, Any], base_url: str | None = None):
365
  job_dir = JOBS_DIR / job_id
366
  job_dir.mkdir(parents=True, exist_ok=True)
367
 
368
  base = {
369
  "job_id": job_id,
370
+ "status_url": make_api_url(f"/jobs/{job_id}", base_url=base_url),
371
+ "result_url": make_api_url(f"/jobs/{job_id}/result", base_url=base_url),
372
  }
373
  base.update(payload)
374
 
 
383
  tmp_path.replace(path)
384
 
385
 
386
+ def read_job_status(job_id: str, base_url: str | None = None) -> Dict[str, Any]:
387
  path = job_status_path(job_id)
388
  if not path.exists():
389
  raise HTTPException(status_code=404, detail="Job not found.")
 
403
  if last_error:
404
  return {
405
  "job_id": job_id,
406
+ "status_url": make_api_url(f"/jobs/{job_id}", base_url=base_url),
407
+ "result_url": make_api_url(f"/jobs/{job_id}/result", base_url=base_url),
408
  "status": "running",
409
  "stage": "status_update",
410
  "message": "Job status is being updated. Retry shortly."
 
412
 
413
  return {
414
  "job_id": job_id,
415
+ "status_url": make_api_url(f"/jobs/{job_id}", base_url=base_url),
416
+ "result_url": make_api_url(f"/jobs/{job_id}/result", base_url=base_url),
417
  "status": "running",
418
  "stage": "status_update",
419
  "message": "Job status is not ready yet."
420
  }
421
 
422
+ def run_generate_job(job_id: str, req_data: Dict[str, Any], base_url: str):
423
  job_dir = JOBS_DIR / job_id
424
 
425
  try:
426
  req = GenerateRequest(**req_data)
427
 
428
+ raise_if_cancelled(job_id)
429
+
430
  write_job_status(job_id, {
431
  "status": "running",
432
  "stage": "sampling",
433
  "message": "Running REINVENT molecule generation",
434
  "request": req_data,
435
+ }, base_url=base_url)
436
 
437
  runtime_config = write_runtime_sampling_config(job_dir, req.num_molecules)
438
 
439
+ raise_if_cancelled(job_id)
440
+ run_reinvent_sampling(runtime_config, job_dir, job_id)
441
+
442
+ raise_if_cancelled(job_id)
443
 
444
  write_job_status(job_id, {
445
  "status": "running",
446
  "stage": "enrichment",
447
  "message": "Running RDKit descriptors and DeepPurpose predictions",
448
  "request": req_data,
449
+ }, base_url=base_url)
450
+
451
+ enriched_csv = run_enrichment(job_dir, req.return_top_k, job_id)
452
 
453
+ raise_if_cancelled(job_id)
454
 
455
  write_job_status(job_id, {
456
  "status": "running",
457
  "stage": "docking",
458
  "message": f"Running docking mode: {req.docking_mode}",
459
  "request": req_data,
460
+ }, base_url=base_url)
461
+
462
+ final_csv = run_optional_docking(job_dir, enriched_csv, req, job_id)
463
 
464
+ raise_if_cancelled(job_id)
465
 
466
  df = pd.read_csv(final_csv)
467
 
 
488
  "num_docked": count_docked(df),
489
  },
490
  "files": {
491
+ "csv": make_file_meta(job_id, clean_csv, base_url=base_url),
492
+ "json": make_file_meta(job_id, clean_json, base_url=base_url),
493
  },
494
  "results": results,
495
  "warnings": [
 
509
  "request": req_data,
510
  "summary": result_payload["summary"],
511
  "files": result_payload["files"],
512
+ }, base_url=base_url)
513
+
514
+ except JobCancelled:
515
+ write_job_status(job_id, {
516
+ "status": "cancelled",
517
+ "stage": "cancelled",
518
+ "message": "Generation job cancelled by user.",
519
+ "request": req_data,
520
+ }, base_url=base_url)
521
 
522
  except Exception as e:
523
  if isinstance(e, HTTPException):
 
531
  "message": "Generation job failed",
532
  "request": req_data,
533
  "error": detail,
534
+ }, base_url=base_url)
535
+
536
+ def rewrite_public_urls(payload: Dict[str, Any], base_url: str) -> Dict[str, Any]:
537
+ base_url = base_url.rstrip("/")
538
+
539
+ for key in ("status_url", "result_url"):
540
+ value = payload.get(key)
541
+ if isinstance(value, str) and value.startswith(("http://localhost", "http://127.0.0.1")):
542
+ path = "/" + value.split("/", 3)[3] if "/" in value[8:] else ""
543
+ payload[key] = f"{base_url}{path}"
544
+
545
+ files = payload.get("files")
546
+ if isinstance(files, dict):
547
+ for meta in files.values():
548
+ if isinstance(meta, dict):
549
+ relative_url = meta.get("relative_url")
550
+ if relative_url:
551
+ meta["download_url"] = f"{base_url}{relative_url}"
552
+
553
+ file_meta = payload.get("file")
554
+ if isinstance(file_meta, dict):
555
+ relative_url = file_meta.get("relative_url")
556
+ if relative_url:
557
+ file_meta["download_url"] = f"{base_url}{relative_url}"
558
+
559
+ return payload
560
 
561
  # -----------------------------
562
  # Endpoints
563
  # -----------------------------
564
 
565
  @app.get("/health")
566
+ def health(request: Request):
567
  return {
568
  "status": "ok",
569
  "bundle_exists": BUNDLE_DIR.exists(),
570
  "models_generator_exists": (BUNDLE_DIR / "models" / "generator" / "egfr_generator.chkpt").exists(),
571
  "models_affinity_exists": (BUNDLE_DIR / "models" / "affinity" / "model.pt").exists(),
572
  "docking_grid_exists": (BUNDLE_DIR / "docking" / "maps_current" / "4WKQ_receptor_v5_SBr.maps.fld").exists(),
573
+ "public_base_url": get_public_base_url(request),
574
  "reinvent_device": REINVENT_DEVICE,
575
  "adgpu_bin": ADGPU_BIN or None,
576
  }
 
608
 
609
 
610
  @app.post("/ligands/export")
611
+ def export_ligand(req: LigandExportRequest, request: Request):
612
  job_id = new_job_id("lig")
613
  job_dir = JOBS_DIR / job_id
614
  job_dir.mkdir(parents=True, exist_ok=True)
 
633
  else:
634
  raise HTTPException(status_code=400, detail="Unsupported format.")
635
 
636
+ meta = make_file_meta(job_id, out_path, request)
637
  meta["format"] = req.format
638
 
639
  return {
 
692
  return [os.getenv("PYTHON", "python"), "-m", "reinvent.Reinvent"]
693
 
694
 
695
+ def run_reinvent_sampling(runtime_config: Path, job_dir: Path, job_id: str):
696
  cmd = find_reinvent_command() + ["-l", str(job_dir / "reinvent_sampling.log"), str(runtime_config)]
697
 
698
+ result = run_cmd(cmd, cwd=BUNDLE_DIR, timeout=1800, job_id=job_id)
699
 
700
  log_path = job_dir / "reinvent_command_output.log"
701
  log_path.write_text(result.stdout or "", encoding="utf-8", errors="replace")
 
711
  )
712
 
713
 
714
+ def run_enrichment(job_dir: Path, return_top_k: int, job_id: str) -> Path:
715
  input_csv = job_dir / "generated_smiles.csv"
716
  enriched_csv = job_dir / "generated_smiles_enriched.csv"
717
 
 
731
  "--top-k", str(return_top_k),
732
  ]
733
 
734
+ result = run_cmd(cmd, cwd=BUNDLE_DIR, timeout=1800, job_id=job_id)
735
 
736
  (job_dir / "enrichment.log").write_text(result.stdout or "", encoding="utf-8", errors="replace")
737
 
 
748
  return enriched_csv
749
 
750
 
751
+ def run_optional_docking(job_dir: Path, enriched_csv: Path, req: GenerateRequest, job_id: str) -> Path:
752
  final_csv = job_dir / "generated_results.csv"
753
 
754
  script = BUNDLE_DIR / "tools" / "dock_enriched.py"
 
776
  )
777
  cmd += ["--adgpu-bin", ADGPU_BIN]
778
 
779
+ result = run_cmd(cmd, cwd=BUNDLE_DIR, timeout=7200, job_id=job_id)
780
 
781
  (job_dir / "docking.log").write_text(result.stdout or "", encoding="utf-8", errors="replace")
782
 
 
794
 
795
 
796
  @app.post("/generate", status_code=202)
797
+ def submit_generate(req: GenerateRequest, background_tasks: BackgroundTasks, request: Request):
798
  job_id = new_job_id("gen")
799
  job_dir = JOBS_DIR / job_id
800
  job_dir.mkdir(parents=True, exist_ok=True)
801
 
802
  req_data = req.model_dump()
803
+ base_url = get_public_base_url(request)
804
  write_job_status(job_id, {
805
  "status": "queued",
806
  "stage": "queued",
807
  "message": "Generation job accepted and queued",
808
  "request": req_data,
809
+ }, base_url=base_url)
810
 
811
+ background_tasks.add_task(run_generate_job, job_id, req_data, base_url)
812
 
813
  return {
814
  "job_id": job_id,
815
  "status": "queued",
816
  "message": "Generation job accepted. Poll status_url until completed.",
817
+ "status_url": make_api_url(f"/jobs/{job_id}", base_url=base_url),
818
+ "result_url": make_api_url(f"/jobs/{job_id}/result", base_url=base_url),
819
  }
820
 
821
 
822
  @app.get("/jobs/{job_id}")
823
+ def get_job_status(job_id: str, request: Request):
824
+ base_url = get_public_base_url(request)
825
+ status = read_job_status(job_id, base_url=base_url)
826
+ return rewrite_public_urls(status, base_url)
827
+
828
+ @app.post("/jobs/{job_id}/cancel", status_code=202)
829
+ def cancel_job(job_id: str, request: Request):
830
+ base_url = get_public_base_url(request)
831
+ job_dir = JOBS_DIR / job_id
832
+
833
+ if not job_dir.exists():
834
+ raise HTTPException(status_code=404, detail="Job not found.")
835
+
836
+ status = read_job_status(job_id, base_url=base_url)
837
+ current_status = status.get("status")
838
+
839
+ if current_status in {"completed", "failed", "cancelled"}:
840
+ status["message"] = f"Job already {current_status}."
841
+ return rewrite_public_urls(status, base_url)
842
+
843
+ request_job_cancel(job_id)
844
+ process_terminated = terminate_active_process(job_id)
845
+
846
+ write_job_status(job_id, {
847
+ "status": "cancel_requested",
848
+ "stage": status.get("stage", "cancel_requested"),
849
+ "message": "Cancellation requested. The job will stop at the next safe checkpoint.",
850
+ "process_terminated": process_terminated,
851
+ "request": status.get("request"),
852
+ }, base_url=base_url)
853
 
854
+ return rewrite_public_urls(read_job_status(job_id, base_url=base_url), base_url)
855
 
856
  @app.get("/jobs/{job_id}/result")
857
+ def get_job_result(job_id: str, request: Request):
858
+ base_url = get_public_base_url(request)
859
+ status = read_job_status(job_id, base_url=base_url)
860
 
861
  if status.get("status") == "failed":
862
+ return JSONResponse(status_code=500, content=rewrite_public_urls(status, base_url))
863
 
864
  if status.get("status") != "completed":
865
+ return JSONResponse(status_code=202, content=rewrite_public_urls(status, base_url))
866
 
867
  result_path = JOBS_DIR / job_id / "generated_results.json"
868
  if not result_path.exists():
869
  raise HTTPException(status_code=404, detail="Result file not found.")
870
 
871
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
872
+ return rewrite_public_urls(payload, base_url)