""" MolGate — Module 3: Protein Preparation (Engine B) 人機決策層(單一腳本跑完): Stage 15:有偵測到輔因子/金屬才停,否則一路向下 Stage 17:僅 GPCR/金屬酶相關標靶才停,否則一路向下 Flag 16/18:PROPKA/鹽橋占位(SKIPPED) 最後寫入 prep_decisions.json,並設 ready_for_engine_e = true(供 Engine C 銜接) 依賴:biopython;Index 載入與 Engine A 共用(import 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 from Bio.PDB import PDBParser _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_module3_engineA import build_engine_entry, load_master_index_file from molgate_ui.console_utf8 import configure_console_utf8, should_prompt_interactive from molgate_ui.protein_families import family_needs_stage17_hitl configure_console_utf8() SCHEMA_VERSION = "0.3" PREP_DECISIONS_SCHEMA = "0.3" CRYSTALLIZATION_HET: set[str] = { "HOH", "WAT", "GOL", "EDO", "PEG", "MPD", "SO4", "PO4", "ACT", "DMS", "FMT", "TRS", "MES", "EPE", "CL", "NA", "K", "BR", "IOD", "NO3", "NH4", } METAL_RESNAMES: set[str] = { "ZN", "MG", "MN", "FE", "FE2", "FES", "CU", "NI", "CO", "MO", "W", "V", "CD", "HG", "PT", "AU", "AG", "PB", "CA", "SR", "BA", "RB", "CS", "LI", "CR", "EU", "GD", "SM", "IR", "RU", "RH", "OS", "RE", "TA", } def save_stage(session_dir: Path, stage: int, name: str, data: dict) -> dict: 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_cleaned_pdb(session_dir: Path) -> Path: path = session_dir / "protein" / "cleaned.pdb" if not path.is_file(): raise FileNotFoundError("找不到 cleaned.pdb,請先跑 Engine A") return path def infer_kept_chains(session_dir: Path, structure) -> list[str]: s12 = session_dir / "stage_12_chains.json" if s12.is_file(): try: data = json.loads(s12.read_text(encoding="utf-8")) kc = data.get("kept_chains") if isinstance(kc, list) and kc: return kc except (json.JSONDecodeError, OSError, KeyError): pass return sorted({c.id for m in structure for c in m}) def load_prep_decisions(session_dir: Path) -> dict[str, Any]: path = session_dir / "prep_decisions.json" if path.is_file(): return json.loads(path.read_text(encoding="utf-8")) return { "schema_version": PREP_DECISIONS_SCHEMA, "decisions": {}, "flags": [], } def save_prep_decisions(session_dir: Path, decisions: dict[str, Any]) -> None: path = session_dir / "prep_decisions.json" decisions = dict(decisions) decisions["_timestamp"] = datetime.now().isoformat() path.write_text(json.dumps(decisions, indent=2, ensure_ascii=False), encoding="utf-8") def prompt_choice( options: list[str], label: str, default: int = 0, *, non_interactive: bool = False, ) -> int: if not should_prompt_interactive(non_interactive=non_interactive): print(f" [AUTO] 非互動:{label} → 選項 [{default}]") return default print(f"\n ── {label} ──") for i, opt in enumerate(options): marker = "(預設)" if i == default else "" print(f" [{i}] {opt}{marker}") while True: try: raw = input(f" 請輸入編號(直接 Enter = {default}):").strip() if raw == "": return default idx = int(raw) if 0 <= idx < len(options): return idx except (ValueError, KeyboardInterrupt): pass print(" 請輸入有效編號") def prompt_yes_no( question: str, default: bool = True, *, non_interactive: bool = False, ) -> bool: if not should_prompt_interactive(non_interactive=non_interactive): print(f" [AUTO] 非互動:{question} → {'Y' if default else 'N'}") return default default_str = "Y/n" if default else "y/N" while True: try: raw = input(f" {question} [{default_str}]:").strip().lower() if raw == "": return default if raw in ("y", "yes"): return True if raw in ("n", "no"): return False except KeyboardInterrupt: sys.exit(0) print(" 請輸入 y 或 n") def _cocrystal_resnames(entry: dict[str, Any]) -> set[str]: names: set[str] = set() for key in ("cocrystal_het", "het_id"): v = entry.get(key) if isinstance(v, str) and v.strip(): names.add(v.strip().upper()) try: from molgate_ui.anchor_routing import parse_pocket_rule_het pr_het = parse_pocket_rule_het(str(entry.get("pocket_rule") or "")) if pr_het: names.add(pr_het) except ImportError: pass return names def _anchor_het_keep_keys( entry: dict[str, Any], detected: list[dict[str, Any]], ) -> set[tuple[str, str, Any]]: """Never strip anchor/cocrystal HET instances during Stage 15 removal.""" anchor = _cocrystal_resnames(entry) if not anchor: return set() return {_het_key(d) for d in detected if str(d.get("resname") or "").upper() in anchor} def _normalize_override(raw: Any) -> dict[str, str]: if not isinstance(raw, dict): return {} out: dict[str, str] = {} for k, v in raw.items(): if isinstance(k, str) and v is not None: out[k.strip()] = str(v).strip() return out def _het_key(d: dict[str, Any]) -> tuple[str, str, Any]: return ( str(d.get("resname", "")).strip().upper(), str(d.get("chain", "")).strip(), d.get("resseq"), ) def detect_cofactor_candidates( entry: dict[str, Any], structure, kept_chains: list[str], ) -> tuple[list[dict[str, Any]], list[str]]: """Scan cleaned PDB for cofactor/metal HETATM (excludes cocrystal & crystallization).""" cocrystal = _cocrystal_resnames(entry) detected: list[dict[str, Any]] = [] seen: set[tuple[str, str, Any]] = set() for model in structure: for chain in model: if chain.id not in kept_chains: continue for res in chain: resname = res.get_resname().strip().upper() hetflag = res.get_id()[0].strip() if not (hetflag == "H_" or hetflag.startswith("H")): continue if resname in cocrystal: continue if resname in METAL_RESNAMES: key = (resname, chain.id, res.get_id()[1]) if key not in seen: seen.add(key) detected.append({ "resname": resname, "chain": chain.id, "resseq": res.get_id()[1], "kind": "metal", }) continue if resname in CRYSTALLIZATION_HET: continue key = (resname, chain.id, res.get_id()[1]) if key not in seen: seen.add(key) detected.append({ "resname": resname, "chain": chain.id, "resseq": res.get_id()[1], "kind": "het", }) index_flagged: list[str] = [] cofactor_index = entry.get("cofactor") metal_index = entry.get("metal") if cofactor_index: index_flagged.append(f"cofactor:{cofactor_index}") if metal_index: index_flagged.append(f"metal:{metal_index}") return detected, index_flagged def detect_stage15_from_session( session_dir: Path, target: str, *, index_path: Path | None = None, ) -> dict[str, Any]: """Preview Stage 15 cofactor/metal candidates (no stage files written).""" master = load_master_index_file(index_path) entry = build_engine_entry(master, target, session_dir) pdb_id = (entry.get("pdb_id") or "").upper() if not pdb_id: raise ValueError("index entry missing pdb_id") cleaned_path = load_cleaned_pdb(session_dir) parser = PDBParser(QUIET=True) structure = parser.get_structure(pdb_id, str(cleaned_path)) kept_chains = infer_kept_chains(session_dir, structure) detected, index_flagged = detect_cofactor_candidates(entry, structure, kept_chains) return { "ok": True, "target": target.upper(), "pdb_id": pdb_id, "kept_chains": kept_chains, "detected": detected, "index_flagged": index_flagged, "needs_confirm": bool(detected or index_flagged), "count": len(detected), } def stage15_cofactor( entry: dict[str, Any], structure, kept_chains: list[str], session_dir: Path, *, non_interactive: bool, auto_action: str = "", selective_keep: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: print("\n[Stage 15] 輔因子/金屬處理...") detected, index_flagged = detect_cofactor_candidates(entry, structure, kept_chains) needs_confirm = len(detected) > 0 or len(index_flagged) > 0 if not needs_confirm: print(" ✓ 無需確認的輔因子/金屬候選,一路向下(AUTO)") print(" → Status: AUTO") return save_stage(session_dir, 15, "cofactor", { "status": "AUTO", "detected": [], "index_flagged": [], "action": "none", "kept": [], "removed": [], }) print(" ⚠ 偵測到輔因子/金屬候選(人機確認):") for d in detected: print(f" [{d.get('kind', 'het')}] {d['resname']} chain {d['chain']} res {d['resseq']}") for f in index_flagged: print(f" Master Index 標記:{f}") if needs_confirm: if non_interactive: if selective_keep is None: print(" ❌ 偵測到輔因子/金屬候選,必須由使用者逐項決定。") print(" 系統不會自動移除或保留任何項目。") print(" 請於 Module 3 UI 勾選後確認,或以 --het-keep-json 傳入您的選擇。") sys.exit(1) keep_set = {_het_key(d) for d in selective_keep} keep_set |= _anchor_het_keep_keys(entry, detected) kept = [] removed = [] for d in detected: label = f"{d['resname']}:{d['chain']}:{d['resseq']}" if _het_key(d) in keep_set: kept.append(label) print(f" ✓ 保留:{label}") else: removed.append(d["resname"]) print(f" ✗ 移除:{label}") print(" → Status: MANUAL (使用者逐項確認)") return save_stage(session_dir, 15, "cofactor", { "status": "MANUAL", "detected": detected, "index_flagged": index_flagged, "action": "selective", "kept": kept, "removed": removed, "selective_keep": selective_keep, }) idx = prompt_choice( ["移除全部(預設安全)", "全部保留", "逐一確認"], label="輔因子/金屬處理方式", default=0, non_interactive=non_interactive, ) anchor_keep = _anchor_het_keep_keys(entry, detected) if idx == 0: action = "remove_all" kept = [ f"{d['resname']}:{d['chain']}:{d['resseq']}" for d in detected if _het_key(d) in anchor_keep ] removed = [ d["resname"] for d in detected if _het_key(d) not in anchor_keep ] + index_flagged if kept: print(f" ✓ 保留 anchor HET(共晶/口袋定義):{', '.join(kept)}") print(" ✓ 選擇:移除全部(anchor HET 除外)") status = "MANUAL" elif idx == 1: action = "keep_all" kept = [d["resname"] for d in detected] + index_flagged removed = [] print(" ✓ 選擇:保留全部") status = "MANUAL" else: kept = [] removed = [] for d in detected: keep = prompt_yes_no( f"保留 {d['resname']} chain {d['chain']}?", default=False, ) if keep: kept.append(d["resname"]) else: removed.append(d["resname"]) action = "selective" status = "MANUAL" print(f" → Status: {status}") return save_stage(session_dir, 15, "cofactor", { "status": status, "detected": detected, "index_flagged": index_flagged, "action": action, "kept": kept, "removed": removed, }) def flag16_propka(session_dir: Path) -> dict[str, Any]: print("\n[Flag 16] PROPKA 滴定態(占位,併入 Engine B)...") print(" → Flag: PROPKA_SKIPPED") print(" → Status: SKIPPED") return save_stage(session_dir, 16, "propka", { "status": "SKIPPED", "flag": "PROPKA_SKIPPED", "note": "快速版占位。正式版需 PROPKA/PDB2PQR 並重跑。", }) def stage17_override( entry: dict[str, Any], session_dir: Path, *, non_interactive: bool, ) -> dict[str, Any]: print("\n[Stage 17] 關鍵殘基 Override...") family = entry.get("family", "default") override = _normalize_override(entry.get("override")) needs_hitl = family_needs_stage17_hitl(entry) or len(override) > 0 if not needs_hitl: print(f" ✓ family={family},非 GPCR/金屬酶優先類別且無 Index override,一路向下(AUTO)") print(" → Status: AUTO") return save_stage(session_dir, 17, "override", { "status": "AUTO", "family": family, "override_rules": {}, }) print(f" ⚠ 需確認:family={family}(GPCR/金屬酶相關或具 Index override)") if override: print(f" Master Index override 建議({len(override)} 項):") for residue, rule in override.items(): print(f" {residue} → {rule}") else: print(" Master Index 無預設 override,請自行確認質子化/關鍵殘基敘述") if non_interactive: final_rules = dict(override) print(" ✓ --non-interactive:採用 Index 之 override(無則空)") return save_stage(session_dir, 17, "override", { "status": "AUTO", "family": family, "override_rules": final_rules, }) final_rules: dict[str, str] = {} status = "MANUAL" if override: accepted = prompt_yes_no( "接受上述 override 建議?", default=True, non_interactive=non_interactive, ) if accepted: final_rules = dict(override) print(" ✓ 接受建議 override") else: print(" 手動輸入 override(格式:殘基鍵=狀態,例 HIS114=protonated)") print(" 輸入 done 或空白行結束") while True: try: raw = input(" 輸入:").strip() if raw.lower() == "done" or raw == "": break if "=" in raw: k, v = raw.split("=", 1) final_rules[k.strip()] = v.strip() print(f" ✓ 加入:{k.strip()} → {v.strip()}") except KeyboardInterrupt: break else: print(" 手動輸入 override(殘基鍵=狀態);直接 Enter 結束=無 override") while True: try: raw = input(" 輸入:").strip() if raw.lower() == "done" or raw == "": break if "=" in raw: k, v = raw.split("=", 1) final_rules[k.strip()] = v.strip() print(f" ✓ 加入:{k.strip()} → {v.strip()}") except KeyboardInterrupt: break print(f" → Status: {status}") return save_stage(session_dir, 17, "override", { "status": status, "family": family, "override_rules": final_rules, }) def flag18_saltbridge(session_dir: Path) -> dict[str, Any]: print("\n[Flag 18] 鹽橋分析(占位,併入 Engine B)...") print(" → Flag: SALT_BRIDGE_SKIPPED") print(" → Status: SKIPPED") return save_stage(session_dir, 18, "saltbridge", { "status": "SKIPPED", "flag": "SALT_BRIDGE_SKIPPED", "note": "快速版占位。正式版需 PROPKA 完成後再分析。", }) def _merge_flags(existing: list[Any], new_flags: list[str]) -> list[str]: out: list[str] = [] for x in existing or []: if isinstance(x, str) and x not in out: out.append(x) for f in new_flags: if f not in out: out.append(f) return out def _update_manifest_engine_b(session_dir: Path, prep_path: Path) -> 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) paths = dict(m.get("paths") or {}) rmap = dict(paths.get("relative") or {}) rmap["prep_decisions"] = rel_path(session_dir, prep_path) paths["relative"] = rmap paths["prep_decisions_absolute"] = str(prep_path.resolve()) ns = dict(paths.get("next_step") or {}) ns["after_module3_engine_b"] = { "read_prep_decisions_from": rmap["prep_decisions"], "hint": "下一步:Engine C(receptor.pdbqt,Stage 19/20);需 ready_for_engine_e=true", } paths["next_step"] = ns m["paths"] = paths pl = dict(m.get("pipeline") or {}) pl["module3_engine_b"] = { "status": "complete", "completed_at": datetime.now().isoformat(), "stages": ["15", "16", "17", "18"], } m["pipeline"] = pl summ = dict(m.get("summary") or {}) summ["prep_decisions_relative"] = rmap["prep_decisions"] summ["ready_for_engine_e"] = True m["summary"] = summ write_manifest(session_dir, m) except Exception: pass def run_engine_b( session_dir: Path, target: str, *, index_path: Path | None = None, skip_manifest_target_check: bool = False, non_interactive: bool = False, stage15_keep: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: print("=" * 55) print(" MolGate — Module 3 Engine B(人機決策層)") print(" Stage 15 → Flag 16 → Stage 17 → Flag 18 → prep_decisions.json") print("=" * 55) try: master = load_master_index_file(index_path) except FileNotFoundError as e: print(f"❌ {e}") sys.exit(1) try: entry = build_engine_entry(master, target, session_dir) except KeyError: print( f"[ERROR] Target '{target}' 不在 Master Index," "且 session 內無 session_index.json" ) sys.exit(1) if entry.get("allowed") is False: print(f"❌ Target '{target}' 在 Master Index 標記為 allowed=false,已中止") sys.exit(1) if not skip_manifest_target_check: try: from molgate_manifest import validate_target_matches_manifest validate_target_matches_manifest(session_dir, target) except FileNotFoundError: pass except ValueError as e: print(f"❌ {e}") sys.exit(1) pdb_id = (entry.get("pdb_id") or "").upper() if not pdb_id: print("❌ 條目缺少 pdb_id") sys.exit(1) try: cleaned_path = load_cleaned_pdb(session_dir) except FileNotFoundError as e: print(f"❌ {e}") sys.exit(1) parser = PDBParser(QUIET=True) structure = parser.get_structure(pdb_id, str(cleaned_path)) kept_chains = infer_kept_chains(session_dir, structure) print(f"\n Target:{target.upper()} | PDB:{pdb_id}") print(f" cleaned.pdb:{cleaned_path}") print(f" kept_chains:{kept_chains}") try: r15 = stage15_cofactor( entry, structure, kept_chains, session_dir, non_interactive=non_interactive, auto_action="", selective_keep=stage15_keep, ) r16 = flag16_propka(session_dir) r17 = stage17_override( entry, session_dir, non_interactive=non_interactive, ) r18 = flag18_saltbridge(session_dir) except Exception as e: print(f"\n❌ Pipeline stopped: {e}") sys.exit(1) prep = load_prep_decisions(session_dir) prep["schema_version"] = PREP_DECISIONS_SCHEMA prep["target"] = target.upper() prep["pdb_id"] = pdb_id ch = entry.get("cocrystal_het") or entry.get("het_id") prep["cocrystal_het"] = ch.strip().upper() if isinstance(ch, str) and ch.strip() else None dec = dict(prep.get("decisions") or {}) dec["stage15_cofactor"] = { "action": r15["action"], "kept": r15["kept"], "removed": r15["removed"], "detected": r15["detected"], "index_flagged": r15["index_flagged"], "status": r15["status"], } dec["stage16_propka"] = { "status": r16["status"], "flag": r16.get("flag"), "note": r16.get("note"), } dec["stage17_override"] = { "rules": r17["override_rules"], "family": r17["family"], "status": r17["status"], } dec["stage18_saltbridge"] = { "status": r18["status"], "flag": r18.get("flag"), "note": r18.get("note"), } prep["decisions"] = dec prep["engines"] = dict(prep.get("engines") or {}) prep["engines"]["engine_b"] = { "status": "complete", "completed_at": datetime.now().isoformat(), "stages": ["15", "16", "17", "18"], } flags: list[str] = [] if r16.get("flag"): flags.append(str(r16["flag"])) if r18.get("flag"): flags.append(str(r18["flag"])) if r15["status"] == "MANUAL": flags.append("STAGE15_MANUAL_COFACTOR") if r17["status"] == "MANUAL": flags.append("STAGE17_MANUAL_OVERRIDE") prep["flags"] = _merge_flags(prep.get("flags"), flags) prep["ready_for_engine_e"] = True prep_path = session_dir / "prep_decisions.json" save_prep_decisions(session_dir, prep) _update_manifest_engine_b(session_dir, prep_path) print("\n" + "=" * 55) print(" Engine B 完成 ✓") print(" ready_for_engine_e = true") print(f" 契約:{prep_path}") print(f" Session:{session_dir}") print("=" * 55) return { "stage15": r15, "stage16": r16, "stage17": r17, "stage18": r18, "prep_decisions": str(prep_path), } if __name__ == "__main__": ap = argparse.ArgumentParser(description="MolGate Module 3 Engine B") ap.add_argument("--session-dir", required=True) ap.add_argument("--target", required=True) ap.add_argument( "--index", type=Path, default=None, help="Master Index JSON(預設:MolGate 目錄下 master_index.json)", ) ap.add_argument( "--skip-manifest-target-check", action="store_true", help="不與 molgate_run_manifest.json 的 target 交叉驗證", ) ap.add_argument( "--non-interactive", action="store_true", help="Stage 15/17 不詢問(15 有候選時必須 --het-keep-json;17 採 Index override)", ) ap.add_argument( "--het-keep-json", type=Path, default=None, help="Stage 15 使用者逐項選擇: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) keep_list: list[dict[str, Any]] | None = None if args.het_keep_json: if not args.het_keep_json.is_file(): print(f"❌ het-keep-json 不存在:{args.het_keep_json}") sys.exit(1) keep_list = json.loads(args.het_keep_json.read_text(encoding="utf-8")) run_engine_b( sd, args.target, index_path=args.index, skip_manifest_target_check=args.skip_manifest_target_check, non_interactive=args.non_interactive, stage15_keep=keep_list, )