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,
    )