""" Export a MolGate / DrugOps session into a CreaDrug24 case pack. Writes under CREADRUG_ROOT/input/cases// and updates input/cases_molgate.json. """ from __future__ import annotations import json import re import shutil from datetime import datetime, timezone from pathlib import Path from typing import Any from molgate_ui.index_status import safe_session_dir, safe_session_file from molgate_ui.paths import SESSIONS, creadrug24_root CASES_REGISTRY = "input/cases_molgate.json" def _read_json(p: Path) -> dict[str, Any] | None: if not p.is_file(): return None try: return json.loads(p.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return None def _first_existing(sd: Path, rel_paths: list[str]) -> Path | None: for rel in rel_paths: fp = safe_session_file(sd, rel) if fp: return fp return None def _resolve_drug_id(sd: Path, meta: dict[str, Any] | None, s3: dict[str, Any] | None) -> int | None: for src in (meta, s3): if not src: continue raw = src.get("drug_id") or src.get("_catalog_drug_id") if raw is not None: try: return int(raw) except (TypeError, ValueError): pass man = _read_json(sd / "molgate_run_manifest.json") if man: raw = man.get("drug_id") if raw is not None: try: return int(raw) except (TypeError, ValueError): pass return None def _drug_name(meta: dict[str, Any] | None, s3: dict[str, Any] | None, dr: dict[str, Any] | None) -> str: for src in (meta, s3, dr): if not src: continue for key in ("drug_label", "drug_name", "target_drug", "common_name"): val = src.get(key) if isinstance(val, str) and val.strip(): return val.strip() return "" def _target_label(meta: dict[str, Any] | None, s3: dict[str, Any] | None, dr: dict[str, Any] | None) -> str: target = str( (meta or {}).get("target") or (s3 or {}).get("target") or (dr or {}).get("target") or "" ).strip().upper() if target.endswith("_PROXY"): return target.split("_")[0] parts = target.split("_") return parts[0] if parts else target def _default_case_id(session_id: str, drug_id: int | None) -> str: if drug_id is not None and drug_id > 0: return f"M{drug_id:02d}" if drug_id < 100 else f"M{drug_id}" sid = re.sub(r"[^a-fA-F0-9]", "", session_id)[:8] return f"M_{sid or 'session'}" def _sanitize_case_id(case_id: str) -> str: raw = (case_id or "").strip().upper() if not raw: raise ValueError("case_id 不可為空") if not re.match(r"^[A-Z][A-Z0-9_\-]{0,31}$", raw): raise ValueError(f"case_id 格式不合法:{case_id}") return raw def _build_docking_log(dr: dict[str, Any] | None, vina_log: Path | None) -> list[str]: lines: list[str] = [] if vina_log and vina_log.is_file(): try: tail = vina_log.read_text(encoding="utf-8", errors="replace").splitlines()[-12:] lines.extend(t.strip() for t in tail if t.strip()) except OSError: pass if dr: aff = dr.get("best_affinity") mode = dr.get("best_pose_mode", 1) tier = dr.get("confidence_tier", "") qc = dr.get("qc_pass") if aff is not None: lines.insert(0, f"Mode {mode}: {aff} kcal/mol") if tier: lines.append(f"[STAGE 26] Confidence tier {tier}") if qc is not None: lines.append(f"[STAGE 25] QC {'PASS' if qc else 'WARN'}") return lines or ["[DrugOps export] docking log unavailable"] def _build_summary(dr: dict[str, Any] | None, qc: dict[str, Any] | None) -> str: if dr: flags = dr.get("flags") or [] flag_txt = f" Flags: {'; '.join(flags)}" if flags else "" return ( f"DrugOps export · best pose #{dr.get('best_pose_mode', 1)} · " f"affinity {dr.get('best_affinity')} kcal/mol · " f"tier {dr.get('confidence_tier', '—')}.{flag_txt}" ) if qc: return f"DrugOps export · QC status {qc.get('status', '—')}" return "DrugOps session export." def build_case_entry( case_id: str, case_dir_rel: str, *, drug_id: int | None, drug_name: str, smiles: str, target_name: str, index_key: str, pdb_id: str, dr: dict[str, Any] | None, qc: dict[str, Any] | None, vina_log: Path | None, pose_ext: str, ) -> dict[str, Any]: aff = dr.get("best_affinity") if dr else None tier = str(dr.get("confidence_tier") or "—") if dr else "—" qc_pass = dr.get("qc_pass") if dr else None status = "COMPLETE" if aff is not None else "PARTIAL" if qc and str(qc.get("status", "")).upper() in ("FAIL", "ERROR"): status = "QC-FLAG" return { "caseId": case_id, "catalogDrugId": drug_id, "drugName": drug_name or index_key or case_id, "formula": "", "smiles": smiles or "", "targetName": target_name or index_key, "indexKey": index_key, "pdbId": pdb_id, "receptorPdbUrl": f"{case_dir_rel}/receptor.pdb", "dockedPdbqtUrl": f"{case_dir_rel}/best_pose.{pose_ext}", "pocketConfigUrl": f"{case_dir_rel}/stage_23_pocket_center.json", "dockingResultUrl": f"{case_dir_rel}/docking_result.json", "bestAffinity": aff, "confidenceTier": tier, "status": status if qc_pass is not False else "QC-FLAG", "summary": _build_summary(dr, qc), "dockingLog": _build_docking_log(dr, vina_log), "source": "drugops", "exportedAt": datetime.now(timezone.utc).isoformat(), } def _update_registry(creadrug_root: Path, entry: dict[str, Any]) -> Path: reg_path = creadrug_root / CASES_REGISTRY reg_path.parent.mkdir(parents=True, exist_ok=True) payload: dict[str, Any] = {"schema_version": "1.0", "cases": []} if reg_path.is_file(): try: existing = json.loads(reg_path.read_text(encoding="utf-8")) if isinstance(existing, dict) and isinstance(existing.get("cases"), list): payload = existing elif isinstance(existing, list): payload = {"schema_version": "1.0", "cases": existing} except (json.JSONDecodeError, OSError): pass cases: list[dict[str, Any]] = payload.setdefault("cases", []) cid = entry["caseId"] replaced = False for i, c in enumerate(cases): if c.get("caseId") == cid: cases[i] = entry replaced = True break if not replaced: cases.append(entry) payload["updated_at"] = datetime.now(timezone.utc).isoformat() reg_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") return reg_path def export_session_to_creadrug( session_id: str, *, sessions_root: Path | None = None, creadrug_root: Path | None = None, case_id: str | None = None, overwrite: bool = True, ) -> dict[str, Any]: """Copy session artifacts into CreaDrug24 and register the case.""" sid = (session_id or "").strip() sd = safe_session_dir(sessions_root or SESSIONS, sid) if not sd: return {"ok": False, "error": f"session not found: {sid}"} root = (creadrug_root or creadrug24_root()).resolve() if not root.is_dir(): return {"ok": False, "error": f"CreaDrug24 root not found: {root}"} meta = _read_json(sd / "session_meta.json") s1 = _read_json(sd / "stage_01_smiles.json") s3 = _read_json(sd / "stage_03_index.json") dr = _read_json(sd / "docking_result.json") qc = _read_json(sd / "stage_25_qc.json") pocket = _read_json(sd / "stage_23_pocket_center.json") or _read_json(sd / "pocket_config.json") receptor_src = _first_existing(sd, ["protein/cleaned.pdb", "protein/apo_for_docking.pdb"]) pose_pdbqt = _first_existing(sd, ["docking/output.pdbqt", "best_pose.pdbqt"]) pose_pdb = _first_existing(sd, ["best_pose.pdb"]) if not receptor_src: return {"ok": False, "error": "缺少 receptor(protein/cleaned.pdb)"} if not pose_pdbqt and not pose_pdb: return {"ok": False, "error": "缺少 docking pose(docking/output.pdbqt 或 best_pose.pdb)"} if not dr: return {"ok": False, "error": "缺少 docking_result.json(請先跑完 Module 5 Engine A)"} drug_id = _resolve_drug_id(sd, meta, s3) cid = _sanitize_case_id(case_id or _default_case_id(sid, drug_id)) case_dir = root / "input" / "cases" / cid if case_dir.exists() and not overwrite: return {"ok": False, "error": f"case 已存在:{cid}(加 overwrite=true 覆寫)"} case_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(receptor_src, case_dir / "receptor.pdb") pose_ext = "pdbqt" if pose_pdbqt: shutil.copy2(pose_pdbqt, case_dir / "best_pose.pdbqt") pose_ext = "pdbqt" if pose_pdb: shutil.copy2(pose_pdb, case_dir / "best_pose.pdb") if not pose_pdbqt: pose_ext = "pdb" for src_name, dst_name in ( ("docking_result.json", "docking_result.json"), ("final_report.json", "final_report.json"), ("stage_24_vina.json", "stage_24_vina.json"), ("docking/vina.log", "vina.log"), ): src = safe_session_file(sd, src_name) if src: shutil.copy2(src, case_dir / dst_name) if pocket: (case_dir / "stage_23_pocket_center.json").write_text( json.dumps(pocket, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) dr_export = dict(dr) dr_export["output_pdbqt"] = f"input/cases/{cid}/best_pose.{pose_ext}" (case_dir / "docking_result.json").write_text( json.dumps(dr_export, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) manifest = { "caseId": cid, "session_id": sid, "catalogDrugId": drug_id, "exported_at": datetime.now(timezone.utc).isoformat(), "source": "drugops", "session_dir": str(sd), "creadrug_case_dir": str(case_dir), } (case_dir / "case_manifest.json").write_text( json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) case_dir_rel = f"input/cases/{cid}" index_key = str( (s3 or {}).get("index_key") or (meta or {}).get("target") or (dr or {}).get("target") or "" ).strip().upper() pdb_id = str( (meta or {}).get("pdb_id") or (s3 or {}).get("pdb_id") or (dr or {}).get("pdb_id") or "" ).strip().upper() smiles = str( (s1 or {}).get("canonical_smiles") or (s1 or {}).get("input_smiles") or (meta or {}).get("smiles_input") or "" ).strip() entry = build_case_entry( cid, case_dir_rel, drug_id=drug_id, drug_name=_drug_name(meta, s3, dr), smiles=smiles, target_name=_target_label(meta, s3, dr), index_key=index_key, pdb_id=pdb_id, dr=dr, qc=qc, vina_log=case_dir / "vina.log", pose_ext=pose_ext, ) reg_path = _update_registry(root, entry) return { "ok": True, "session_id": sid, "case_id": cid, "case_dir": str(case_dir), "registry": str(reg_path), "entry": entry, "creadrug_root": str(root), }