DrugOps / engines /molgate_module5_engineB.py
chihsing's picture
Upload 180 files
af894e0 verified
Raw History Blame Contribute Delete
12.9 kB
"""
MolGate — Module 5: Docking & Output (Engine B)
Stage 27: 最終人工審核
輸入:docking_result.json(Engine A 契約)
輸出:stage_27_review.json、final_report.json
決策:
ACCEPT → 寫入 final_report.json,pipeline 完成
RETRY → 標記需重跑的 Module,不寫最終報告
FLAG → 標記問題,結果存檔但不視為完成
依賴:無額外依賴
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
_PKG_ROOT = Path(__file__).resolve().parent
_UI_ROOT = _PKG_ROOT.parent
if str(_PKG_ROOT) not in sys.path:
sys.path.insert(0, str(_PKG_ROOT))
if str(_UI_ROOT) not in sys.path:
sys.path.insert(0, str(_UI_ROOT))
from molgate_ui.console_utf8 import configure_console_utf8, should_prompt_interactive
configure_console_utf8()
SCHEMA_VERSION = "0.2"
RETRY_TARGETS = {
"1": ("Module 1", "SMILES / PDB 輸入問題"),
"2": ("Module 2", "配體準備問題(質子化、手性)"),
"3": ("Module 3", "蛋白準備問題(去水、殘基 override)"),
"4": ("Module 4", "口袋定義問題(配體選擇、座標)"),
"5": ("Module 5", "Docking 參數問題(exhaustiveness、box size)"),
}
# ── 工具函式 ──────────────────────────────────────────
def save_stage(session_dir: Path, stage: int, name: str, data: dict[str, Any]) -> dict[str, Any]:
data = dict(data)
data["_stage"] = stage
data["_name"] = name
data["_timestamp"] = datetime.now().isoformat()
data["_schema_version"] = SCHEMA_VERSION
path = session_dir / f"stage_{stage:02d}_{name}.json"
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
return data
def load_docking_result(session_dir: Path) -> dict[str, Any]:
path = session_dir / "docking_result.json"
if not path.exists():
raise FileNotFoundError("找不到 docking_result.json,請先跑 Engine A")
data = json.loads(path.read_text(encoding="utf-8"))
if not data.get("ready_for_review"):
raise ValueError("docking_result.json 未標記 ready_for_review=True,請重跑 Engine A")
sv = str(data.get("schema_version") or "").strip()
if sv and sv != SCHEMA_VERSION:
print(f" ⚠ docking_result.schema_version={sv!r},建議重跑 Module 5 Engine A({SCHEMA_VERSION})")
return data
def display_result(dr: dict[str, Any]):
"""顯示 docking 結果摘要。"""
print("\n" + "─" * 55)
print(" DOCKING 結果摘要")
print("─" * 55)
target = dr.get("target", "?")
pdb_id = dr.get("pdb_id", "?")
het_id = dr.get("het_id", "?")
chain = dr.get("chain", "?")
td = dr.get("target_drug", "")
cn = dr.get("common_name", "")
print(f" Target:{target} | PDB:{pdb_id}")
if td:
print(f" Docking 藥物:{td}")
if cn:
print(f" 共晶配體:{cn} ({het_id} chain {chain})")
else:
print(f" 共晶配體:{het_id} chain {chain}")
print(f"\n Best pose:Mode {dr['best_pose_mode']} | "
f"{dr['best_affinity']:.3f} kcal/mol")
print(f" Confidence tier:{dr['confidence_tier']}")
print(f" QC pass:{dr['qc_pass']}")
cutoff = float(dr.get("out_of_pocket_rmsd_lb_cutoff", 8.0))
oop_modes = dr.get("out_of_pocket_modes") or []
poses = dr.get("all_poses", [])
if poses:
print(f"\n 所有 pose:")
for p in poses:
rmsd_warn = " ⚠ 口袋外" if p["rmsd_lb"] > cutoff else ""
print(f" Mode {p['mode']:2d}: {p['affinity']:7.3f} kcal/mol | "
f"RMSD lb={p['rmsd_lb']:.2f}{rmsd_warn}")
if oop_modes:
print(f" ⚠ OUT_OF_POCKET cutoff:{cutoff:.1f} Å | modes={oop_modes}")
flags = dr.get("flags", [])
if flags:
print(f"\n Flags:")
for f in flags:
print(f" ⚠ {f}")
print("─" * 55)
def prompt_decision() -> tuple[str, str]:
"""人工審核決策介面。"""
print("\n ── Stage 27:最終審核 ──")
print(" [1] ACCEPT → 接受結果,寫入最終報告")
print(" [2] RETRY → 標記問題,指定重跑 Module")
print(" [3] FLAG → 標記問題,結果存檔但不視為完成")
print()
while True:
try:
raw = input(" 請選擇 [1/2/3]:").strip()
if raw in ("1", "2", "3"):
break
except KeyboardInterrupt:
sys.exit(0)
print(" 請輸入 1、2 或 3")
note = ""
if raw == "1":
try:
note = input(" 備註(選填,直接 Enter 跳過):").strip()
except KeyboardInterrupt:
pass
return "ACCEPT", note
if raw == "2":
print("\n 指定需重跑的 Module:")
for k, (mod, reason) in RETRY_TARGETS.items():
print(f" [{k}] {mod}:{reason}")
retry_mod = ""
retry_reason = ""
while True:
try:
m = input(" 請選擇 Module [1-5]:").strip()
if m in RETRY_TARGETS:
retry_mod, retry_reason = RETRY_TARGETS[m]
break
except KeyboardInterrupt:
sys.exit(0)
print(" 請輸入 1~5")
try:
note = input(f" 問題說明(選填):").strip()
except KeyboardInterrupt:
pass
return "RETRY", f"重跑 {retry_mod}:{retry_reason}。{note}".strip()
if raw == "3":
try:
note = input(" FLAG 原因(選填):").strip()
except KeyboardInterrupt:
pass
return "FLAG", note
return "FLAG", ""
def build_non_interactive_note(
decision: str,
note: str | None,
retry_module: str | None = None,
) -> str:
"""API/自動化模式下組裝 note。"""
base = (note or "").strip()
if decision == "RETRY":
if retry_module and retry_module in RETRY_TARGETS:
mod, reason = RETRY_TARGETS[retry_module]
prefix = f"重跑 {mod}:{reason}"
return f"{prefix}。{base}".strip()
return base or "重跑(未指定模組)"
return base
def write_final_report(
session_dir: Path,
dr: dict[str, Any],
note: str,
) -> Path:
"""寫入最終報告。"""
report = {
"schema_version": SCHEMA_VERSION,
"status": "COMPLETE",
"target": dr.get("target", ""),
"pdb_id": dr.get("pdb_id", ""),
"het_id": dr.get("het_id", ""),
"chain": dr.get("chain", ""),
"pocket_rule": dr.get("pocket_rule", ""),
"best_affinity": dr["best_affinity"],
"best_pose_mode": dr["best_pose_mode"],
"confidence_tier": dr["confidence_tier"],
"qc_pass": dr["qc_pass"],
"flags": dr.get("flags", []),
"all_poses": dr.get("all_poses", []),
"output_pdbqt": dr.get("output_pdbqt", ""),
"pocket_center": dr.get("pocket_center", {}),
"out_of_pocket_rmsd_lb_cutoff": dr.get("out_of_pocket_rmsd_lb_cutoff"),
"out_of_pocket_modes": dr.get("out_of_pocket_modes", []),
"reviewer_note": note,
"reviewed_at": datetime.now().isoformat(),
}
for key in ("common_name", "target_drug"):
if dr.get(key):
report[key] = dr[key]
path = session_dir / "final_report.json"
path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
return path
def _update_manifest_module5_engine_b(
session_dir: Path,
decision: str,
report_path: Path | None,
) -> None:
try:
from molgate_manifest import read_manifest, rel_path, write_manifest
except ImportError:
return
mp = session_dir / "molgate_run_manifest.json"
if not mp.is_file():
return
try:
m = read_manifest(session_dir)
pl = dict(m.get("pipeline") or {})
pl["module5_engine_b"] = {
"status": decision.lower(),
"completed_at": datetime.now().isoformat(),
"stages": ["27"],
}
m["pipeline"] = pl
if report_path:
paths = dict(m.get("paths") or {})
rmap = dict(paths.get("relative") or {})
rmap["final_report"] = rel_path(session_dir, report_path)
paths["relative"] = rmap
m["paths"] = paths
summ = dict(m.get("summary") or {})
summ["pipeline_complete"] = True
summ["final_report"] = rmap["final_report"]
m["summary"] = summ
write_manifest(session_dir, m)
except Exception:
pass
# ── Stage 27:最終人工審核 ────────────────────────────
def stage27_review(
session_dir: Path,
dr: dict[str, Any],
*,
decision_override: str | None = None,
note_override: str | None = None,
retry_module: str | None = None,
) -> dict[str, Any]:
print("\n[Stage 27] 最終人工審核...")
display_result(dr)
if decision_override:
decision = decision_override.strip().upper()
if decision not in ("ACCEPT", "RETRY", "FLAG"):
raise ValueError(f"不支援 decision={decision_override!r},需為 ACCEPT/RETRY/FLAG")
note = build_non_interactive_note(decision, note_override, retry_module)
else:
if not should_prompt_interactive():
raise ValueError(
"Stage 27 需人工審核:請在 UI 選擇 ACCEPT/RETRY/FLAG,或使用 --decision"
)
decision, note = prompt_decision()
report_path = None
status = decision
if decision == "ACCEPT":
report_path = write_final_report(session_dir, dr, note)
print(f"\n ✓ 結果接受")
print(f" ✓ 最終報告:{report_path}")
elif decision == "RETRY":
print(f"\n ↩ 標記重跑:{note}")
elif decision == "FLAG":
print(f"\n ⚑ 標記問題:{note or '(無說明)'}")
print(f" → Status: {status}")
result = {
"status": status,
"decision": decision,
"note": note,
"final_report": str(report_path.as_posix()) if report_path else None,
"pipeline_complete": decision == "ACCEPT",
}
r = save_stage(session_dir, 27, "review", result)
_update_manifest_module5_engine_b(session_dir, decision, report_path)
return r
# ── 主流程 ────────────────────────────────────────────
def run_engine_b(
session_dir: Path,
*,
decision: str | None = None,
note: str | None = None,
retry_module: str | None = None,
):
print("=" * 55)
print(" MolGate — Module 5 Engine B")
print(" Stage 27:最終人工審核")
print("=" * 55)
try:
dr = load_docking_result(session_dir)
except (FileNotFoundError, ValueError) as e:
print(f"❌ {e}")
sys.exit(1)
try:
result = stage27_review(
session_dir,
dr,
decision_override=decision,
note_override=note,
retry_module=retry_module,
)
except Exception as e:
print(f"\n❌ Pipeline stopped: {e}")
sys.exit(1)
print("\n" + "=" * 55)
decision = result["decision"]
if decision == "ACCEPT":
print(" Pipeline 完成 ✓")
print(f" 最終報告:{session_dir / 'final_report.json'}")
elif decision == "RETRY":
print(f" ↩ 需重跑:{result['note']}")
else:
print(f" ⚑ 已標記:{result['note'] or '(無說明)'}")
print("=" * 55)
return result
# ── CLI ──────────────────────────────────────────────
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="MolGate Module 5 Engine B")
ap.add_argument("--session-dir", required=True)
ap.add_argument(
"--decision",
choices=("ACCEPT", "RETRY", "FLAG"),
default=None,
help="非互動模式決策(供 API/UI 使用)",
)
ap.add_argument(
"--note",
default=None,
help="非互動模式備註(可選)",
)
ap.add_argument(
"--retry-module",
choices=("1", "2", "3", "4", "5"),
default=None,
help="decision=RETRY 時指定重跑模組(1-5)",
)
args = ap.parse_args()
sd = Path(args.session_dir)
if not sd.is_dir():
print(f"❌ Session 目錄不存在:{sd}")
sys.exit(1)
run_engine_b(
sd,
decision=args.decision,
note=args.note,
retry_module=args.retry_module,
)