DrugOps / engines /molgate_module1.py
chihsing's picture
Upload 180 files
af894e0 verified
Raw History Blame Contribute Delete
28.4 kB
"""
MolGate — Module 1: Input validation
Stage 01: SMILES standardization (RDKit)
Stage 02: PDB download & validation (RCSB HTTPS + optional Biopython)
Stage 03: Master Index lookup (JSON registry)
Session artifacts: ./molgate_sessions/<session_id>/stage_*.json
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import socket
import sys
import time
from datetime import datetime
from pathlib import Path
# 同目錄模組(無論從何處執行)
_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.index_normalize import normalize_index
from molgate_ui.console_utf8 import configure_console_utf8
configure_console_utf8()
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import ProxyHandler, Request, build_opener, urlopen
from rdkit import Chem
from rdkit.Chem import Descriptors, rdmolops
from rdkit.Chem.MolStandardize import rdMolStandardize
from molgate_manifest import append_registry, finalize_module1_manifest
# ── Paths ─────────────────────────────────────────────────────────
BASE_DIR = _PKG_ROOT / "molgate_sessions"
DEFAULT_INDEX_PATH = _PKG_ROOT / "master_index.json"
SCHEMA_VERSION = "0.3"
# ── Master Index (loaded at runtime) ─────────────────────────────
_MASTER_INDEX: dict[str, Any] = {}
def load_master_index(path: Path | None = None) -> dict[str, Any]:
"""Load Master Index JSON; merge with empty dict. Keys are uppercased for lookup."""
p = path or DEFAULT_INDEX_PATH
if not p.is_file():
raise FileNotFoundError(
f"找不到 Master Index:{p}\n"
"請在 MolGate 目錄放置 master_index.json,或使用 --index 指定路徑。"
)
raw = json.loads(p.read_text(encoding="utf-8"))
ver = raw.pop("_schema_version", None)
raw.pop("_comment", None)
out: dict[str, Any] = {}
for k, v in raw.items():
if not isinstance(v, dict):
continue
entry = dict(v)
entry["_index_schema"] = ver or SCHEMA_VERSION
out[k.upper()] = entry
return out
def get_master_index(path: Path | None = None) -> dict[str, Any]:
global _MASTER_INDEX
if not _MASTER_INDEX or path is not None:
_MASTER_INDEX = load_master_index(path)
return _MASTER_INDEX
# ── Session ───────────────────────────────────────────────────────
def make_session_id(smiles: str, target: str, pdb_id: str) -> str:
raw = f"{smiles}|{target}|{pdb_id}|{SCHEMA_VERSION}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
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
session_dir.mkdir(parents=True, exist_ok=True)
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_stage(session_dir: Path, stage: int, name: str) -> dict | None:
path = session_dir / f"stage_{stage:02d}_{name}.json"
if not path.is_file():
return None
return json.loads(path.read_text(encoding="utf-8"))
# ── Stage 01 輔助:立體未定義掃描(進 Module 2 前預警)────────────────
def scan_undefined_stereo(mol: Chem.Mol) -> dict[str, Any]:
"""
偵測「可能具立體但 SMILES 未標記」之手性中心與雙鍵 E/Z。
在標準化後分子上執行,供下游決策是否先補齊立體再進 Module 2。
"""
rdmolops.AssignStereochemistry(mol, cleanIt=True, force=True)
n_chiral = 0
n_ez = 0
chiral_atoms: list[int] = []
ez_bonds: list[list[int]] = []
method = ""
try:
from rdkit.Chem import rdMolDescriptors
if hasattr(rdMolDescriptors, "CalcNumUnspecifiedAtomStereoCenters"):
n_chiral = int(rdMolDescriptors.CalcNumUnspecifiedAtomStereoCenters(mol))
if hasattr(rdMolDescriptors, "CalcNumUnspecifiedBondStereoCenters"):
n_ez = int(rdMolDescriptors.CalcNumUnspecifiedBondStereoCenters(mol))
if hasattr(rdMolDescriptors, "CalcNumUnspecifiedAtomStereoCenters") or hasattr(
rdMolDescriptors, "CalcNumUnspecifiedBondStereoCenters"
):
method = "rdMolDescriptors"
except Exception:
method = ""
if not method:
try:
centers = Chem.FindMolChiralCenters(mol, includeUnassigned=True)
for c in centers:
if len(c) > 1 and c[1] == "?":
n_chiral += 1
chiral_atoms.append(int(c[0]))
except Exception:
pass
method = "FindMolChiralCenters"
# 細節與後備計數:FindPotentialStereo(亦補齊舊版缺少的 rd 函式時)
try:
from rdkit.Chem import FindPotentialStereo
from rdkit.Chem.rdchem import StereoSpecified, StereoType
for entry in FindPotentialStereo(mol):
if entry.specified != StereoSpecified.Unspecified:
continue
if entry.type == StereoType.Atom_Tetrahedral:
a = int(entry.centeredOn)
if a not in chiral_atoms:
chiral_atoms.append(a)
elif entry.type == StereoType.Bond_Double:
try:
bidx = int(entry.centeredOn)
bnd = mol.GetBondWithIdx(bidx)
a1, a2 = bnd.GetBeginAtomIdx(), bnd.GetEndAtomIdx()
pair = [a1, a2]
if pair not in ez_bonds and [a2, a1] not in ez_bonds:
ez_bonds.append(pair)
except Exception:
continue
except Exception:
pass
chiral_atoms = sorted(set(chiral_atoms))
if n_chiral == 0 and chiral_atoms:
n_chiral = len(chiral_atoms)
if n_ez == 0 and ez_bonds:
n_ez = len(ez_bonds)
return {
"n_undefined_chiral_centers": n_chiral,
"n_undefined_ez_bonds": n_ez,
"undefined_chiral_atom_indices": chiral_atoms,
"undefined_ez_bond_atom_pairs": ez_bonds,
"stereo_scan_method": method or "mixed",
}
# ── Stage 01: SMILES ─────────────────────────────────────────────
def stage01_smiles(smiles: str, session_dir: Path) -> dict:
print("\n[Stage 01] SMILES 標準化...")
mol = Chem.MolFromSmiles(smiles)
if mol is None:
result = {"status": "FAIL", "reason": "無法解析 SMILES", "input_smiles": smiles}
save_stage(session_dir, 1, "smiles", result)
raise ValueError(f"Stage 01 FAIL: {result['reason']}")
try:
canonical = rdMolStandardize.StandardizeSmiles(smiles)
except Exception as e:
result = {
"status": "FAIL",
"reason": f"StandardizeSmiles 失敗: {e}",
"input_smiles": smiles,
}
save_stage(session_dir, 1, "smiles", result)
raise ValueError(f"Stage 01 FAIL: {result['reason']}")
mol_std = Chem.MolFromSmiles(canonical)
if mol_std is None:
result = {"status": "FAIL", "reason": "標準化後 SMILES 無效", "input_smiles": smiles}
save_stage(session_dir, 1, "smiles", result)
raise ValueError(f"Stage 01 FAIL: {result['reason']}")
mw = Descriptors.MolWt(mol_std)
n_atoms = mol_std.GetNumAtoms()
stereo = scan_undefined_stereo(mol_std)
result: dict[str, Any] = {
"status": "PASS",
"input_smiles": smiles,
"canonical_smiles": canonical,
"mol_weight": round(mw, 2),
"num_atoms": n_atoms,
"is_small_molecule": mw < 900,
"n_undefined_chiral_centers": stereo["n_undefined_chiral_centers"],
"n_undefined_ez_bonds": stereo["n_undefined_ez_bonds"],
"undefined_chiral_atom_indices": stereo["undefined_chiral_atom_indices"],
"undefined_ez_bond_atom_pairs": stereo["undefined_ez_bond_atom_pairs"],
"stereo_scan_method": stereo["stereo_scan_method"],
"recommend_resolve_stereo_before_module2": False,
}
warn_msgs: list[str] = []
if not result["is_small_molecule"]:
result["status"] = "WARN"
warn_msgs.append("分子量 > 900 Da,可能不適用標準小分子對接流程")
if stereo["n_undefined_chiral_centers"] > 0:
result["status"] = "WARN"
warn_msgs.append(
f"未定義手性中心:{stereo['n_undefined_chiral_centers']} 處"
f"(atom_idx: {stereo['undefined_chiral_atom_indices']})"
" — 建議補齊 @ 立體再進 Module 2"
)
if stereo["n_undefined_ez_bonds"] > 0:
result["status"] = "WARN"
warn_msgs.append(
f"未定義 E/Z 雙鍵:{stereo['n_undefined_ez_bonds']} 處"
f"(atom pairs: {stereo['undefined_ez_bond_atom_pairs']})"
" — 建議補齊 / 與 \\ 再進 Module 2"
)
if stereo["n_undefined_chiral_centers"] > 0 or stereo["n_undefined_ez_bonds"] > 0:
result["recommend_resolve_stereo_before_module2"] = True
if warn_msgs:
result["warnings"] = warn_msgs
save_stage(session_dir, 1, "smiles", result)
print(f" [OK] Canonical SMILES: {canonical}")
print(f" [OK] MW: {mw:.1f} Da | Atoms: {n_atoms}")
print(f" [OK] 立體掃描: 未定義手性 {stereo['n_undefined_chiral_centers']} | 未定義 E/Z {stereo['n_undefined_ez_bonds']}")
if stereo["n_undefined_chiral_centers"] > 0:
print(
f" [WARN] 有未定義手性中心(indices: {stereo['undefined_chiral_atom_indices']})"
)
if stereo["n_undefined_ez_bonds"] > 0:
print(f" [WARN] 有未定義 E/Z(pairs: {stereo['undefined_ez_bond_atom_pairs']})")
if result.get("recommend_resolve_stereo_before_module2"):
print(" [NEXT] 建議:先處理立體再進 Module 2(仍為 WARN,非強制中止)")
print(f" [STATUS] {result['status']}")
return result
# ── Stage 02: PDB ─────────────────────────────────────────────────
_PDB_SOURCE_URLS = [
("rcsb_download", "https://files.rcsb.org/download/{id}.pdb"),
("rcsb_view", "https://files.rcsb.org/view/{id}.pdb"),
("pdbe_ent", "https://www.ebi.ac.uk/pdbe/entry-files/download/pdb{id}.ent"),
]
def _validate_pdb_content(text: str, pdb_id: str) -> dict:
lines = text.splitlines()
has_atom = any(l.startswith(("ATOM ", "HETATM")) for l in lines)
has_cryst = any(l.startswith("CRYST1") for l in lines)
atom_n = sum(1 for l in lines if l.startswith("ATOM"))
het_n = sum(1 for l in lines if l.startswith("HETATM"))
chains = sorted({l[21] for l in lines if len(l) > 21 and l.startswith("ATOM")})
if not has_atom and not any(l.startswith("HETATM") for l in lines):
return {"ok": False, "reason": "檔案無 ATOM/HETATM 記錄"}
if len(text.strip()) < 200:
return {"ok": False, "reason": "檔案過短,可能非有效 PDB"}
return {
"ok": True,
"pdb_id": pdb_id.upper(),
"atom_count": atom_n,
"hetatm_count": het_n,
"chains": chains,
"has_hetatm": het_n > 0,
"has_cryst1": has_cryst,
}
def _format_network_reason(err: Exception) -> str:
"""Normalize network exceptions to readable diagnostics."""
if isinstance(err, HTTPError):
return f"HTTP {err.code}: {err.reason}"
if isinstance(err, URLError):
r = err.reason
if isinstance(r, OSError):
if getattr(r, "winerror", None) == 10061:
return "連線被拒絕 (WinError 10061,可能是防火牆/代理/公司網路限制)"
return f"網路錯誤: {r}"
return f"網路錯誤: {r}"
return str(err)
def _download_pdb_text(pid: str, *, max_retries: int = 2) -> tuple[str | None, str | None, list[str]]:
"""
Try multiple mirrors with retries.
Return (body, source_url, diagnostics).
"""
diagnostics: list[str] = []
for source_name, pattern in _PDB_SOURCE_URLS:
url = pattern.format(id=pid)
for attempt in range(1, max_retries + 1):
req = Request(url, headers={"User-Agent": "MolGate/0.2 (Module1; research)"})
for mode in ("proxy_env", "direct"):
try:
if mode == "proxy_env":
with urlopen(req, timeout=45) as resp:
body = resp.read().decode("utf-8", errors="replace")
else:
opener = build_opener(ProxyHandler({}))
with opener.open(req, timeout=45) as resp:
body = resp.read().decode("utf-8", errors="replace")
if "404" in body[:200] or len(body) < 200:
diagnostics.append(f"{source_name} try#{attempt} {mode}: 內容異常或為空")
continue
return body, url, diagnostics
except Exception as e: # noqa: BLE001
diagnostics.append(f"{source_name} try#{attempt} {mode}: {_format_network_reason(e)}")
time.sleep(0.6 * attempt)
return None, None, diagnostics
def _find_local_pdb(pid: str, session_dir: Path) -> Path | None:
"""Find local PDB by priority: current session, shared repo, previous sessions."""
local_candidates = [
session_dir / "pdb" / f"{pid}.pdb",
_PKG_ROOT / "pdb" / f"{pid}.pdb",
_PKG_ROOT / "tool" / "pdb" / f"{pid}.pdb",
]
for c in local_candidates:
if c.is_file():
return c
# Reuse from previous sessions if available.
if BASE_DIR.is_dir():
for p in sorted(BASE_DIR.glob(f"*/pdb/{pid}.pdb"), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file():
return p
return None
def stage02_pdb(pdb_id: str, session_dir: Path, *, allow_network: bool = False) -> dict:
print(f"\n[Stage 02] PDB 載入與驗證:{pdb_id}...")
pid = pdb_id.strip().upper()
if not re.fullmatch(r"[0-9][A-Z0-9]{3}", pid):
result = {"status": "FAIL", "pdb_id": pid, "reason": "PDB ID 格式應為 4 字元(例:7DFP)"}
save_stage(session_dir, 2, "pdb", result)
raise ValueError(f"Stage 02 FAIL: {result['reason']}")
pdb_dir = session_dir / "pdb"
pdb_dir.mkdir(parents=True, exist_ok=True)
out_path = pdb_dir / f"{pid}.pdb"
url = None
# Offline-first: prefer local prepared PDB sources.
src = _find_local_pdb(pid, session_dir)
if src is not None:
body = src.read_text(encoding="utf-8", errors="replace")
v = _validate_pdb_content(body, pid)
if not v.get("ok"):
result = {
"status": "FAIL",
"pdb_id": pid,
"pdb_path": str(src.resolve()),
"reason": f"本地 PDB 驗證失敗: {v.get('reason')}",
}
save_stage(session_dir, 2, "pdb", result)
raise ValueError(f"Stage 02 FAIL: {result['reason']}")
out_path.write_text(body, encoding="utf-8")
result = {
"status": "PASS",
"pdb_id": pid,
"pdb_path": str(out_path.resolve()),
"source": "local",
"source_path": str(src.resolve()),
"download_url": None,
"atom_count": v["atom_count"],
"hetatm_count": v["hetatm_count"],
"chains": v["chains"],
"has_hetatm": v["has_hetatm"],
"has_cryst1": v["has_cryst1"],
}
save_stage(session_dir, 2, "pdb", result)
print(f" [OK] 已由本地載入:{src}")
print(f" [OK] 複製到 session:{out_path}")
print(
f" [OK] ATOM: {v['atom_count']} | HETATM: {v['hetatm_count']} | Chains: {v['chains']}"
)
print(f" [STATUS] {result['status']}")
return result
if not allow_network:
result = {
"status": "FAIL",
"pdb_id": pid,
"reason": (
"離線模式未找到本地 PDB。"
"請先放置於 ./pdb 或 ./tool/pdb,或先跑過一個含此 PDB 的 session,"
"或改用 --allow-network。"
),
}
save_stage(session_dir, 2, "pdb", result)
raise ValueError(f"Stage 02 FAIL: {result['reason']}")
body, url, diagnostics = _download_pdb_text(pid)
if body is None:
host_diag = ""
try:
socket.getaddrinfo("files.rcsb.org", 443)
except Exception as e: # noqa: BLE001
host_diag = f" | DNS 檢查失敗(files.rcsb.org): {e}"
proxy_hint = (
os.environ.get("HTTPS_PROXY")
or os.environ.get("https_proxy")
or os.environ.get("HTTP_PROXY")
or os.environ.get("http_proxy")
)
proxy_diag = f" | 代理設定: {proxy_hint}" if proxy_hint else " | 代理設定: <none>"
result = {
"status": "FAIL",
"pdb_id": pid,
"reason": ";".join(diagnostics) + host_diag + proxy_diag,
}
save_stage(session_dir, 2, "pdb", result)
raise ValueError(f"Stage 02 FAIL: {result['reason']}")
out_path.write_text(body, encoding="utf-8")
v = _validate_pdb_content(body, pid)
if not v.get("ok"):
result = {"status": "FAIL", "pdb_id": pid, "pdb_path": str(out_path), "reason": v.get("reason")}
save_stage(session_dir, 2, "pdb", result)
raise ValueError(f"Stage 02 FAIL: {result['reason']}")
result = {
"status": "PASS",
"pdb_id": pid,
"pdb_path": str(out_path.resolve()),
"source": "network",
"source_path": None,
"download_url": url,
"network_attempts": diagnostics,
"atom_count": v["atom_count"],
"hetatm_count": v["hetatm_count"],
"chains": v["chains"],
"has_hetatm": v["has_hetatm"],
"has_cryst1": v["has_cryst1"],
}
save_stage(session_dir, 2, "pdb", result)
print(f" [OK] 已寫入:{out_path}")
print(
f" [OK] ATOM: {v['atom_count']} | HETATM: {v['hetatm_count']} | Chains: {v['chains']}"
)
print(f" [STATUS] {result['status']}")
return result
# ── Stage 03: Master Index ────────────────────────────────────────
def stage03_index(
target: str,
pdb_id: str,
session_dir: Path,
index_path: Path | None = None,
index_key: str | None = None,
manual_verified: bool = False,
) -> dict:
lookup_key = (index_key or target).strip().upper()
print(f"\n[Stage 03] Master Index 查詢:target={target} | index_key={lookup_key}...")
idx = get_master_index(index_path)
entry = idx.get(lookup_key)
if entry is None:
result = {
"status": "FAIL",
"target": target,
"index_key": lookup_key,
"reason": f"Index key '{lookup_key}' 不在 Master Index,請先於 master_index.json 策編",
"schema_ok": False,
"missing_fields": ["index_entry"],
"index_validation_mode": "manual_required",
"manual_verification_required": True,
"manual_verified": bool(manual_verified),
"rcsb_verified": False,
}
save_stage(session_dir, 3, "index", result)
raise ValueError(f"Stage 03 FAIL: {result['reason']}")
if not entry.get("allowed", True):
result = {
"status": "BLOCKED",
"target": target,
"index_key": lookup_key,
"reason": f"此 Target 已禁用:{entry.get('notes', '')}",
"schema_ok": True,
"missing_fields": [],
"index_validation_mode": "manual_required",
"manual_verification_required": True,
"manual_verified": bool(manual_verified),
"rcsb_verified": False,
}
save_stage(session_dir, 3, "index", result)
raise ValueError(f"Stage 03 BLOCKED: {result['reason']}")
required_fields = ("pdb_id", "target_drug")
missing_fields: list[str] = []
entry = normalize_index(entry)
for f in required_fields:
v = entry.get(f)
if v is None or str(v).strip() == "":
missing_fields.append(f)
het_id = str(entry.get("het_id") or "").strip().upper()
cocrystal_het = str(entry.get("cocrystal_het") or "").strip().upper()
pocket_rule = str(entry.get("pocket_rule") or "").strip()
if not (het_id or cocrystal_het) and pocket_rule not in ("auto_detect", "manual_coords", "manual_select"):
missing_fields.append("het_id|cocrystal_het (or pocket_rule:auto_detect)")
if not pocket_rule:
missing_fields.append("pocket_rule")
if missing_fields:
result = {
"status": "FAIL",
"target": target,
"index_key": lookup_key,
"reason": "Master Index 條目缺少必填欄位",
"schema_ok": False,
"missing_fields": missing_fields,
"index_validation_mode": "manual_required",
"manual_verification_required": True,
"manual_verified": bool(manual_verified),
"rcsb_verified": False,
}
save_stage(session_dir, 3, "index", result)
raise ValueError(
f"Stage 03 FAIL: {result['reason']} ({', '.join(missing_fields)})"
)
rec_pdb = str(entry.get("pdb_id", "")).upper()
in_pdb = pdb_id.strip().upper()
if rec_pdb and rec_pdb != in_pdb:
status = "WARN"
warning = f"建議 PDB 為 Master Index 指定之 {rec_pdb},目前輸入為 {in_pdb}"
else:
status = "PASS"
warning = None
result = {
"status": status,
"target": target,
"index_key": lookup_key,
"pdb_id": rec_pdb or in_pdb,
"input_pdb": in_pdb,
"state": entry.get("state"),
"family": entry.get("family"),
"pocket_rule": entry.get("pocket_rule"),
"cocrystal_het": cocrystal_het,
"het_id": het_id,
"override": entry.get("override") or {},
"notes": entry.get("notes"),
"schema_ok": True,
"missing_fields": [],
"index_validation_mode": "manual_required",
"manual_verification_required": True,
"manual_verified": bool(manual_verified),
"rcsb_verified": False,
}
if warning:
result["warning"] = warning
save_stage(session_dir, 3, "index", result)
print(
f" [OK] Target: {target} | Index key: {lookup_key} | "
f"Family: {entry.get('family')} | State: {entry.get('state')}"
)
print(f" [OK] Pocket rule: {entry.get('pocket_rule')} | HET: {cocrystal_het or het_id}")
if entry.get("override"):
print(f" [OK] Override: {entry.get('override')}")
print(f" [STATUS] {result['status']}")
if warning:
print(f" [WARN] {warning}")
if not manual_verified:
print(" [WARN] 本版未自動連線 RCSB 驗證 index;請使用者手動確認 PDB/CCD 對應。")
return result
# ── Module 1 runner ───────────────────────────────────────────────
def run_module1(
smiles: str,
target: str,
pdb_id: str,
index_path: Path | None = None,
index_key: str | None = None,
manual_verified: bool = False,
session_id: str | None = None,
drug_id: str | None = None,
drug_label: str | None = None,
allow_network: bool = False,
) -> dict | None:
print("=" * 58)
print(" MolGate — Module 1: Input validation")
print("=" * 58)
BASE_DIR.mkdir(parents=True, exist_ok=True)
sid = session_id or make_session_id(smiles, target, pdb_id)
session_dir = BASE_DIR / sid
session_dir.mkdir(parents=True, exist_ok=True)
meta = {
"session_id": sid,
"module": 1,
"created": datetime.now().isoformat(),
"smiles_input": smiles,
"target": target,
"index_key": (index_key or target).strip().upper(),
"pdb_id": pdb_id,
"index_path": str((index_path or DEFAULT_INDEX_PATH).resolve()),
"index_validation_mode": "manual_required",
"manual_verification_required": True,
"manual_verified": bool(manual_verified),
"rcsb_verified": False,
"drug_id": drug_id,
"drug_label": drug_label,
}
(session_dir / "session_meta.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=False),
encoding="utf-8",
)
print(f"\n Session ID: {sid}")
print(f" Session dir: {session_dir}")
try:
r1 = stage01_smiles(smiles, session_dir)
r2 = stage02_pdb(pdb_id, session_dir, allow_network=allow_network)
r3 = stage03_index(
target,
pdb_id,
session_dir,
index_path=index_path,
index_key=index_key,
manual_verified=manual_verified,
)
except ValueError as e:
print(f"\n[ERROR] Module 1 中止: {e}")
return None
finalize_module1_manifest(
session_dir,
meta,
r1,
r2,
r3,
drug_id=drug_id,
drug_label=drug_label,
)
append_registry(
BASE_DIR,
{
"event": "module1_complete",
"session_id": sid,
"session_dir": str(session_dir.resolve()),
"drug_id": drug_id,
"drug_label": drug_label,
"target": target,
"pdb_id": r2.get("pdb_id") or pdb_id,
"timestamp": datetime.now().isoformat(),
},
)
print("\n" + "=" * 58)
print(" Module 1 完成")
print(f" 日誌目錄: {session_dir}")
print(f" Manifest: {session_dir / 'molgate_run_manifest.json'}")
print("=" * 58)
return {"session_id": sid, "session_dir": str(session_dir), "stage01": r1, "stage02": r2, "stage03": r3}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="MolGate Module 1 — input validation")
p.add_argument("--smiles", required=True, help="輸入 SMILES")
p.add_argument(
"--target",
required=True,
help="Target 顯示名或預設 index 鍵名(若給 --index-key,將以 --index-key 優先)",
)
p.add_argument("--index-key", default=None, help="Master Index 鍵名(建議明確提供)")
p.add_argument("--pdb-id", required=True, dest="pdb_id", help="RCSB PDB ID,例:7DFP")
p.add_argument("--index", type=Path, default=None, help="master_index.json 路徑")
p.add_argument("--drug-id", default=None, help="藥物管理用 ID(例:M01、IBU-001)")
p.add_argument("--drug-label", default=None, help="藥物顯示名稱(選填)")
p.add_argument(
"--allow-network",
action="store_true",
help="允許 Stage 02 連線下載 PDB(預設離線,只用本地資料)",
)
p.add_argument(
"--manual-verified",
action="store_true",
help="標記 index 條目已由使用者人工核對(本版不自動做 RCSB 檢驗)",
)
args = p.parse_args(argv)
r = run_module1(
args.smiles,
args.target,
args.pdb_id,
index_path=args.index,
index_key=args.index_key,
manual_verified=args.manual_verified,
drug_id=args.drug_id,
drug_label=args.drug_label,
allow_network=args.allow_network,
)
return 0 if r is not None else 1
if __name__ == "__main__":
sys.exit(main())