File size: 16,425 Bytes
af894e0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 | """
MolGate — Module 4: Pocket Definition (Engine B)
Stage 22: Ligand identity confirmation (人機)
輸入:stage_21_hetatm.json + Master Index(het_id / cocrystal_het、pocket_rule、可選 common_name、target_drug)
輸出:stage_22_ligand.json(選定配體)
依賴:與 molgate_module4_engineA 共用 Index 解析(molgate_module3_engineA)
"""
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(_UI_ROOT) not in sys.path:
sys.path.insert(0, str(_UI_ROOT))
if str(_PKG_ROOT) not in sys.path:
sys.path.insert(0, str(_PKG_ROOT))
from molgate_ui.console_utf8 import configure_console_utf8, should_prompt_interactive
configure_console_utf8()
from molgate_module4_engineA import load_target_index_entry
SCHEMA_VERSION = "0.2"
def load_prep_overlay(session_dir: Path, target: str) -> dict[str, Any]:
"""合併 prep_decisions.json 與 CLI target(CLI 優先)。"""
prep: dict[str, Any] = {"target": target.strip()}
p = session_dir / "prep_decisions.json"
if p.is_file():
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, dict):
for k, v in data.items():
if k.startswith("_"):
continue
prep[k] = v
except (json.JSONDecodeError, OSError):
pass
prep["target"] = target.strip()
return prep
def expected_het_id(entry: dict[str, Any]) -> str:
"""比對用單一代碼:優先 het_id,否則 cocrystal_het。"""
for key in ("het_id", "cocrystal_het"):
v = entry.get(key)
if isinstance(v, str) and v.strip():
return v.strip().upper()
return ""
# ── 工具函式 ──────────────────────────────────────────
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_stage21(session_dir: Path) -> dict[str, Any]:
path = session_dir / "stage_21_hetatm.json"
if not path.exists():
raise FileNotFoundError("找不到 stage_21_hetatm.json,請先跑 Engine A")
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("_schema_version") != SCHEMA_VERSION:
raise ValueError("Stage 21 schema version mismatch,請重跑 Engine A")
return data
def prompt_select(
candidates: list[dict],
label: str,
*,
non_interactive: bool = False,
) -> dict:
if not candidates:
raise ValueError(f"無候選可選:{label}")
if not should_prompt_interactive(non_interactive=non_interactive):
pick = candidates[0]
print(
f" [AUTO] 非互動:{label} → [0] {pick.get('resname')} "
f"chain {pick.get('chain')} res {pick.get('resseq')}"
)
return pick
print(f"\n ── {label} ──")
for i, c in enumerate(candidates):
tag = "【目標】 " if c.get("ligand_role") == "target" else ""
print(
f" [{i}] {tag}{c['resname']} chain {c['chain']} "
f"res {c['resseq']} (~{c['mw_estimate']:.0f} Da, "
f"{c['heavy_atom_count']} heavy atoms)"
)
while True:
try:
raw = input(" 請輸入編號選擇:").strip()
idx = int(raw)
if 0 <= idx < len(candidates):
return candidates[idx]
except (ValueError, KeyboardInterrupt):
pass
print(" 請輸入有效編號")
# ── Stage 22:配體身份確認 ────────────────────────────
def stage22_ligand(
session_dir: Path,
target: str,
index_path: Path | None,
*,
non_interactive: bool = False,
selected: dict[str, Any] | None = None,
) -> dict[str, Any]:
print("\n[Stage 22] 配體身份確認...")
prep = load_prep_overlay(session_dir, target)
entry = load_target_index_entry(session_dir, prep, index_path)
if not entry:
result = {
"status": "FAIL",
"reason": "無法載入 Master Index 條目(請確認 --target 與 master_index.json)",
}
save_stage(session_dir, 22, "ligand", result)
raise ValueError(f"Stage 22 FAIL: {result['reason']}")
s21 = load_stage21(session_dir)
candidates = s21.get("candidates", [])
route_fallback_status = ""
if not candidates:
try:
from molgate_ui.anchor_routing import m4_recover_empty_candidates
recovery = m4_recover_empty_candidates(session_dir, entry)
except ImportError:
recovery = None
if recovery:
if recovery.get("needs_rerun_from_m1"):
res_obj = recovery.get("resolution")
result = {
"status": "ROUTE_FALLBACK",
"reason": (
"Direct 共晶 HET 不存在;已寫入 cross-PDB proxy 路由,"
"請以更新後的 pdb_id 自 Module 1 重跑"
),
"route_resolution": res_obj.to_json() if hasattr(res_obj, "to_json") else res_obj,
}
save_stage(session_dir, 22, "ligand", result)
raise ValueError(f"Stage 22 ROUTE_FALLBACK: {result['reason']}")
entry = recovery.get("effective_entry") or entry
candidates = recovery.get("candidates") or []
route_fallback_status = getattr(recovery.get("resolution"), "reason", "") or "route_fallback"
print(f" → Route fallback:{route_fallback_status}")
if not candidates:
result = {
"status": "FAIL",
"reason": "Stage 21 無候選配體,無法確認配體身份",
}
save_stage(session_dir, 22, "ligand", result)
raise ValueError(f"Stage 22 FAIL: {result['reason']}")
het_id = expected_het_id(entry)
pocket_rule = (entry.get("pocket_rule") or "").strip()
common_name = (entry.get("common_name") or "").strip()
target_drug = (entry.get("target_drug") or "").strip()
pdb_id = (entry.get("pdb_id") or prep.get("pdb_id") or "").strip().upper()
print(f" → Master Index expected het(het_id/cocrystal_het):{het_id or '(未設定)'}")
if common_name:
print(f" → 共晶配體俗名(common_name):{common_name}")
if target_drug:
print(f" → Docking 目標藥物(target_drug):{target_drug}")
print(f" → Pocket rule: {pocket_rule}")
print(f" → 候選配體數:{len(candidates)}")
if selected is not None:
want = (
str(selected.get("resname") or "").strip().upper(),
str(selected.get("chain") or "").strip(),
int(selected.get("resseq")),
)
picked = None
for c in candidates:
if (
str(c.get("resname") or "").strip().upper() == want[0]
and str(c.get("chain") or "").strip() == want[1]
and int(c.get("resseq")) == want[2]
):
picked = c
break
if not picked:
raise ValueError(
f"UI 選定的配體 {want[0]} chain {want[1]} res {want[2]} 不在 Stage 21 候選列表中"
)
selected = picked
status = "MANUAL"
print(f" ✓ UI 確認:{selected['resname']} chain {selected['chain']} res {selected['resseq']}")
elif not het_id:
print(f" ⚠ 無法自動比對(Index 缺 het_id / cocrystal_het),請手動選擇")
if non_interactive:
selected = candidates[0]
status = "AUTO_FORCED"
print(f" ✓ --non-interactive:採用候選 [0] {selected['resname']} chain {selected['chain']}")
else:
selected = prompt_select(
candidates,
"請選擇用於口袋定義的配體",
non_interactive=non_interactive,
)
status = "MANUAL"
print(f" ✓ 選定:{selected['resname']} chain {selected['chain']}")
else:
matched = [c for c in candidates if c["resname"].upper() == het_id]
if len(matched) == 1:
selected = matched[0]
status = "AUTO"
print(f" ✓ 自動比對成功:{selected['resname']} chain {selected['chain']}")
elif len(matched) > 1:
print(f" ⚠ 多個 {het_id} 存在於不同 chain,需要確認")
if non_interactive:
selected = matched[0]
status = "AUTO_FORCED"
print(
f" ✓ --non-interactive:採用排序後第一個 {selected['resname']} "
f"chain {selected['chain']} res {selected['resseq']}"
)
else:
selected = prompt_select(
matched,
f"選擇 {het_id} 所在 chain",
non_interactive=non_interactive,
)
status = "MANUAL"
print(f" ✓ 選定:{selected['resname']} chain {selected['chain']}")
elif len(candidates) == 1:
print(
f" ⚠ WARN: 唯一候選 {candidates[0]['resname']} "
f"與 Index 期望 {het_id} 不符"
)
print(f" → 可能是 PDB/CCD 代碼與 Index 不一致")
if non_interactive:
selected = candidates[0]
status = "AUTO_FORCED"
print(f" ✓ --non-interactive:採用唯一候選 {selected['resname']}")
else:
confirm = input(
f" 是否使用 {candidates[0]['resname']} 作為口袋定義配體?[y/N]:"
).strip().lower()
if confirm == "y":
selected = candidates[0]
status = "MANUAL"
print(f" ✓ 手動確認:{selected['resname']}")
else:
result = {
"status": "FAIL",
"reason": f"用戶拒絕使用 {candidates[0]['resname']},無法繼續",
}
save_stage(session_dir, 22, "ligand", result)
raise ValueError(f"Stage 22 FAIL: {result['reason']}")
else:
print(f" ⚠ 無法自動比對,候選列表如下:")
if non_interactive:
preferred = [c for c in candidates if c.get("ligand_role") == "target"]
selected = (preferred[0] if preferred else candidates[0])
status = "AUTO_FORCED"
print(
f" ✓ --non-interactive:採用"
f"{'【目標】' if selected.get('ligand_role') == 'target' else ''}"
f"{selected['resname']} chain {selected['chain']}"
)
else:
selected = prompt_select(
candidates,
"請選擇用於口袋定義的配體",
non_interactive=non_interactive,
)
status = "MANUAL"
print(f" ✓ 選定:{selected['resname']} chain {selected['chain']}")
print(f" → Status: {status}")
result: dict[str, Any] = {
"status": status,
"selected_het_id": selected["resname"],
"chain": selected["chain"],
"resseq": selected["resseq"],
"mw_estimate": selected["mw_estimate"],
"heavy_atom_count": selected["heavy_atom_count"],
"master_index_expected_het": het_id,
"master_index_het_id": het_id,
"match": selected["resname"].upper() == het_id if het_id else None,
"pocket_rule": pocket_rule,
"ready_for_engine_c": True,
}
if route_fallback_status:
result["route_fallback"] = route_fallback_status
if pdb_id:
result["pdb_id"] = pdb_id
if common_name:
result["common_name"] = common_name
if target_drug:
result["target_drug"] = target_drug
return save_stage(session_dir, 22, "ligand", result)
# ── 主流程 ────────────────────────────────────────────
def run_engine_b(
session_dir: Path,
target: str,
index_path: Path | None = None,
*,
non_interactive: bool = False,
selected: dict[str, Any] | None = None,
):
print("=" * 55)
print(" MolGate — Module 4 Engine B")
print(" Stage 22: 配體身份確認(人機)")
print("=" * 55)
prep = load_prep_overlay(session_dir, target)
entry = load_target_index_entry(session_dir, prep, index_path)
if not entry:
print(f"❌ Target '{target}' 無法從 Master Index 載入條目(檔案或鍵名錯誤)")
sys.exit(1)
pdb_id = (entry.get("pdb_id") or prep.get("pdb_id") or "?").strip()
het_hint = expected_het_id(entry) or "?"
print(f"\n Target:{target.upper()} | PDB:{pdb_id}")
print(f" Index 期望共晶代碼:{het_hint}")
try:
result = stage22_ligand(
session_dir,
target,
index_path,
non_interactive=non_interactive,
selected=selected,
)
except FileNotFoundError as e:
print(f"❌ {e}")
sys.exit(1)
except ValueError as e:
print(f"\n❌ Pipeline stopped: {e}")
sys.exit(1)
except Exception as e:
print(f"\n❌ Pipeline stopped: {e}")
sys.exit(1)
print("\n" + "=" * 55)
print(" Engine B 完成 ✓")
print(
f" 選定配體:{result['selected_het_id']} "
f"chain {result['chain']} res {result['resseq']}"
)
print(f" ready_for_engine_c = True")
print(f" 輸出:{session_dir / 'stage_22_ligand.json'}")
print("=" * 55)
return result
# ── CLI ──────────────────────────────────────────────
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="MolGate Module 4 Engine B")
ap.add_argument("--session-dir", required=True, help="Session 目錄路徑")
ap.add_argument("--target", required=True, help="Target 名稱(如 COX2,須對應 master_index.json)")
ap.add_argument(
"--index",
default=None,
help="master_index.json 路徑(預設:session_meta / manifest 或 MolGate 目錄下)",
)
ap.add_argument(
"--non-interactive",
action="store_true",
help="Stage 22 無法唯一自動比對時,採用規則化預設(多命中取第一個/無命中取候選[0]或【目標】)",
)
ap.add_argument(
"--selected-json",
default=None,
help="UI 人機選配體 JSON 檔(含 resname、chain、resseq)",
)
args = ap.parse_args()
sd = Path(args.session_dir)
if not sd.is_dir():
print(f"❌ Session 目錄不存在:{sd}")
sys.exit(1)
idx = Path(args.index) if args.index else None
ui_selected: dict[str, Any] | None = None
if args.selected_json:
pick_path = Path(args.selected_json)
if not pick_path.is_file():
print(f"❌ 找不到 --selected-json:{pick_path}")
sys.exit(1)
try:
ui_selected = json.loads(pick_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
print(f"❌ 無法讀取 --selected-json:{e}")
sys.exit(1)
if not isinstance(ui_selected, dict):
print("❌ --selected-json 必須為 JSON 物件")
sys.exit(1)
run_engine_b(
sd,
args.target,
index_path=idx,
non_interactive=args.non_interactive,
selected=ui_selected,
)
|