@dsh-bio/dsh-bio-gem 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,520 @@
1
+ """dsh-bio-gem Python 操作层 — JSON 协议分发器(GEM 构建/验证/补洞/报告)。
2
+
3
+ 协议与 dsh-bio-genie 的 bio_ops.py 同族:
4
+ TS 侧通过 stdin 发送 {"op": "...", "args": {...}},
5
+ 本脚本执行后将 {"ok": true, "result": ...} 或 {"ok": false, "error": "..."} 写到 stdout。
6
+
7
+ 契约(bridge 层继承):
8
+ - 捕获所有代码异常后恒返回 ok:true,traceback 写 stderr(带 "Traceback (most recent call last)" 头)——
9
+ 代码级失败判定必须在 TS 侧检测该头 → needs_repair=true。
10
+ - 输出前 _sanitize_json 递归规范化(-0.0→0.0, NaN/inf→null),规避 dsh snapshot 校验。
11
+
12
+ op 一览(M1 · 定稿 2026-08-29):
13
+ model_info 读 SBML 输出模型摘要(gem_report 的底层)
14
+ validate 五道验证关卡 G1 加载 / G2 元素平衡 / G3 生长真实性 / G4 表型(条件) / G5 必需性抽检(条件)
15
+ gapfind 缺口分级诊断 L1 缺 exchange / L2 缺转运 / L3 内部路径
16
+ gapfill 规则级补洞(L1/L2 自动,逐条打标 provenance)
17
+ build CarveMe 构建(后台长任务)+ 注释输入支持
18
+ """
19
+ import json
20
+ import os
21
+ import sys
22
+ import traceback
23
+
24
+ # Windows 下 sys.stdin/stdout 默认按 GBK(locale)编解码,而 Node 侧以 UTF-8 写入/读取。
25
+ # 不显式重配置会导致中文参数/结果损坏。强制 UTF-8。
26
+ sys.stdin.reconfigure(encoding="utf-8")
27
+ sys.stdout.reconfigure(encoding="utf-8")
28
+
29
+ # Python -I isolated 模式下脚本目录不进 sys.path——显式插入以导入同目录模块
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+
32
+ _MODEL_DIR = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "models")
33
+
34
+
35
+ def _sanitize_json(obj):
36
+ """递归规范化:-0.0 -> 0.0, NaN/inf -> None(dsh snapshotToolValue 只接受 lossless JSON)。"""
37
+ if isinstance(obj, float):
38
+ if obj != obj or obj in (float("inf"), float("-inf")):
39
+ return None
40
+ if obj == 0.0 and str(obj).startswith("-"):
41
+ return 0.0
42
+ return obj
43
+ if isinstance(obj, dict):
44
+ return {k: _sanitize_json(v) for k, v in obj.items()}
45
+ if isinstance(obj, (list, tuple)):
46
+ return [_sanitize_json(v) for v in obj]
47
+ return obj
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # op: model_info — SBML 摘要(gem_report 底层)
52
+ # ---------------------------------------------------------------------------
53
+ def op_model_info(args):
54
+ """读 SBML:返回基因/反应/代谢物统计 + 复制子统计(多染色体/多质粒)。"""
55
+ import cobra
56
+ path = args.get("model")
57
+ if not path or not os.path.exists(path):
58
+ return {"ok": False, "error": f"model file not found: {path}"}
59
+ m = cobra.io.read_sbml_model(path)
60
+ n_ex = sum(1 for r in m.reactions if r.id.startswith("EX_"))
61
+ n_dm = sum(1 for r in m.reactions if r.id.startswith("DM_"))
62
+ n_boundary = sum(1 for r in m.reactions if r.boundary)
63
+ # 复制子统计:模型基因 ID 前缀 NC_XXXX_N -> 复制子
64
+ from collections import Counter
65
+ repl = Counter()
66
+ for g in m.genes:
67
+ parts = g.id.split("_")
68
+ if len(parts) >= 3 and parts[0] == "NC":
69
+ repl["_".join(parts[:2])] += 1
70
+ else:
71
+ repl["other"] += 1
72
+ # 阶段A-M3: prediction ledger 基率摘要(文件不存在 -> {total: 0};ledger_path 可覆盖默认账本)
73
+ # P1-3:传 model=path 让摘要给 by_model 分布 + own_model_entries(防「本模型账本 N 条」误报)
74
+ ledger_summary, ledger_context = None, None
75
+ try:
76
+ import ledger as _ledger
77
+ # 2026-08-31:一个模型一个账本——缺省(无 ledger_path)定位到该模型自己的账本
78
+ ledger_summary = _ledger.ledger_summary(path=args.get("ledger_path"), model=path)
79
+ n_unverified = ledger_summary["by_status"].get("unverified", 0)
80
+ own = ledger_summary.get("own_model_entries")
81
+ if ledger_summary["total"]:
82
+ ledger_context = (f"预测账本:本模型 {own} 条(一个模型一个账本,账本文件按模型名分),"
83
+ f"{n_unverified} 条 unverified:"
84
+ "全部为模型推导预测(essentiality/phenotype 等),实验或文献兑现前不应当作事实引用;"
85
+ "状态分布即预测可信度基率,回填后 by_status 向 literature_supported/"
86
+ "experimentally_verified 迁移。")
87
+ except Exception as e:
88
+ ledger_summary = {"error": str(e)[:120]}
89
+ return {"ok": True, "result": {
90
+ "path": path,
91
+ "genes": len(m.genes),
92
+ "reactions": len(m.reactions),
93
+ "metabolites": len(m.metabolites),
94
+ "compartments": list(m.compartments.values()) or list(m.compartments.keys()),
95
+ "exchanges": n_ex,
96
+ "demands": n_dm,
97
+ "boundary": n_boundary,
98
+ "replicons": dict(repl),
99
+ "objective": m.objective.name or m.objective.expression is not None and "set" or "None",
100
+ "ledger_summary": ledger_summary,
101
+ **({"ledger_context": ledger_context} if ledger_context else {}),
102
+ }}
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # op: validate — 五道验证关卡(G1-G5)
107
+ # ---------------------------------------------------------------------------
108
+ def op_validate(args):
109
+ from validate import validate_model
110
+ model = args.get("model")
111
+ if not model or not os.path.exists(model):
112
+ return {"ok": False, "error": f"model file not found: {model}"}
113
+ rep = validate_model(
114
+ model,
115
+ medium=args.get("medium"),
116
+ phenotype_table=args.get("phenotype_table"),
117
+ essential_test=args.get("essential_test"),
118
+ reference_growth=args.get("reference_growth"),
119
+ reference_essential=args.get("reference_essential"),
120
+ carbon_mode=args.get("carbon_mode", "supplement"),
121
+ )
122
+ return {"ok": True, "result": rep}
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # op: gapfind — 缺口分级诊断(L1/L2/L3)
127
+ # ---------------------------------------------------------------------------
128
+ def op_gapfind(args):
129
+ from gapfind import find_gaps
130
+ model = args.get("model")
131
+ if not model or not os.path.exists(model):
132
+ return {"ok": False, "error": f"model file not found: {model}"}
133
+ return {"ok": True, "result": find_gaps(model, medium=args.get("medium"),
134
+ substrates=args.get("substrates"))}
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # op: gapfill — 规则级补洞(L1/L2,provenance 打标)
139
+ # ---------------------------------------------------------------------------
140
+ def op_gapfill(args):
141
+ from gapfill import apply_fixes
142
+ model = args.get("model")
143
+ if not model or not os.path.exists(model):
144
+ return {"ok": False, "error": f"model file not found: {model}"}
145
+ if not (args.get("medium") or args.get("substrates")):
146
+ return {"ok": False, "error": "need medium and/or substrates to drive gapfill"}
147
+ return {"ok": True, "result": apply_fixes(
148
+ model, medium=args.get("medium"), substrates=args.get("substrates"),
149
+ max_add=args.get("max_add", 20), out=args.get("out"),
150
+ confirm_budget=args.get("confirm_budget", False))}
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # op: gapseq — 原子步骤(setup/launch/status/fetch),agent 编排长任务
155
+ # ---------------------------------------------------------------------------
156
+ def op_gapseq(args):
157
+ from gapseq_wsl import probe, launch_gapseq, status_gapseq, fetch_gapseq
158
+ action = args.get("action", "setup")
159
+ if action == "setup":
160
+ return {"ok": True, "result": probe()}
161
+ if action == "launch":
162
+ if not args.get("input"):
163
+ return {"ok": False, "error": "launch 需要 input(核苷酸 .fna 绝对路径)"}
164
+ r = launch_gapseq(args["input"], name=args.get("name", "model"),
165
+ work_win=args.get("out_dir"))
166
+ return {"ok": True, "result": r}
167
+ if action == "status":
168
+ return {"ok": True, "result": status_gapseq()}
169
+ if action == "fetch":
170
+ r = fetch_gapseq(args.get("out_dir"), name=args.get("name", "model"))
171
+ return {"ok": True, "result": r}
172
+ return {"ok": False, "error": f"unknown gapseq action: {action}(setup|launch|status|fetch)"}
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # op: phenotype_fix — 表型回填迭代(A3)
177
+ # ---------------------------------------------------------------------------
178
+ def op_phenotype_fix(args):
179
+ from phenotype_fix import phenotype_fix
180
+ model = args.get("model")
181
+ if not model or not os.path.exists(model):
182
+ return {"ok": False, "error": f"model file not found: {model}"}
183
+ r = phenotype_fix(model, phenotype_table=args.get("phenotype_table"),
184
+ medium=args.get("medium"), max_add=args.get("max_add", 20),
185
+ out=args.get("out"), ledger_path=args.get("ledger_path"))
186
+ return {"ok": True, "result": r}
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # op: essential_scan — G5 全量必需基因扫描(路线 P0)
191
+ # ---------------------------------------------------------------------------
192
+ def op_essential_scan(args):
193
+ from essential_scan import essential_scan
194
+ model = args.get("model")
195
+ if not model or not os.path.exists(model):
196
+ return {"ok": False, "error": f"model file not found: {model}"}
197
+ r = essential_scan(model, medium=args.get("medium"), gene_subset=args.get("gene_subset"),
198
+ ledger_path=args.get("ledger_path"), gene_table=args.get("gene_table"))
199
+ return {"ok": True, "result": r}
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # op: annotate — 基因组注释(官方优先 + pyrodigal 兜底)
204
+ # ---------------------------------------------------------------------------
205
+ def op_annotate(args):
206
+ from annotate import nucleotide_to_protein
207
+ fna = args.get("fna")
208
+ if not fna or not os.path.exists(fna):
209
+ return {"ok": False, "error": f"fna file not found: {fna}"}
210
+ faa, src, stats = nucleotide_to_protein(fna, args.get("out"))
211
+ result = {"faa": faa, "source": src, "stats": stats}
212
+ # P1-4:GFF 路径产出 gene_table.tsv(坐标ID→locus_tag/product),供 gem_essentiality 解读必需基因功能
213
+ if stats.get("gene_table"):
214
+ result["gene_table"] = stats["gene_table"]
215
+ return {"ok": True, "result": result}
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # op: media_resolve — 跨引擎介质解析 RPC(genie 消费侧统一走此;防介质语义漂移)
220
+ # ---------------------------------------------------------------------------
221
+ def op_media_resolve(args):
222
+ from gapfind import expand_medium, resolve_medium, build_ex_index, ex_index_is_boundary, ex_display_name
223
+ from silentio import silent_read_sbml
224
+ model = args.get("model")
225
+ if not model or not os.path.exists(model):
226
+ return {"ok": False, "error": f"model file not found: {model}"}
227
+ m = silent_read_sbml(model)
228
+ med, preset = expand_medium(args.get("medium"))
229
+ resolved, unresolved = resolve_medium(m, med)
230
+ idx = build_ex_index(m)
231
+ boundary_style = ex_index_is_boundary(idx)
232
+ result = {
233
+ "resolved_exchanges": sorted(resolved),
234
+ "medium_preset": preset,
235
+ "unresolved": unresolved,
236
+ "model": model,
237
+ # 阶段B-B1 附加(只增):两级策略②启用标注 + 规范展示名
238
+ "boundary_style": boundary_style,
239
+ }
240
+ if boundary_style:
241
+ result["resolved_display"] = [ex_display_name(m, rid) for rid in sorted(resolved)]
242
+ return {"ok": True, "result": result}
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # op: l3_fix — B' 后半:L3 内部路径补洞(L3a 模型内连通性 + L3b 白名单/BiGG 反应式)
247
+ # 证据分级 EVIDENCE_sequence/math;防过补第五闸门(budget.py);补后 G1-G6 重验 + G6 失败回滚
248
+ # ---------------------------------------------------------------------------
249
+ def op_l3_fix(args):
250
+ from l3_fix import l3_fix
251
+ model = args.get("model")
252
+ if not model or not os.path.exists(model):
253
+ return {"ok": False, "error": f"model file not found: {model}"}
254
+ if not (args.get("medium") or args.get("substrates")):
255
+ return {"ok": False, "error": "need medium and substrates to drive L3 diagnosis"}
256
+ return {"ok": True, "result": l3_fix(
257
+ model, medium=args.get("medium"), substrates=args.get("substrates"),
258
+ out=args.get("out"), allow_math=args.get("allow_math", False),
259
+ confirm_budget=args.get("confirm_budget", False),
260
+ whitelist=args.get("whitelist"), faa=args.get("faa"),
261
+ species=args.get("species"), max_iter=args.get("max_iter", 1),
262
+ universal_path=args.get("universal_path"))}
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # op: fluxscan — 阶段A-M1 通量区间制(FVA 区间 + pFBA 点值 + 条件对区间分离判定)
267
+ # 语义 bsp 锁稿:overlap = 求解器伪影禁止引用;判定公式见 fluxscan.judge_interval(单测锁定)
268
+ # ---------------------------------------------------------------------------
269
+ def op_fluxscan(args):
270
+ from fluxscan import fluxscan, DEFAULT_FRACTION, DEFAULT_TOL
271
+ model = args.get("model")
272
+ if not model or not os.path.exists(model):
273
+ return {"ok": False, "error": f"model file not found: {model}"}
274
+ if not args.get("conditions"):
275
+ return {"ok": False, "error": "conditions required(非空数组,每项 {name, medium, substrates?, carbon_mode?},name 唯一)"}
276
+ return {"ok": True, "result": fluxscan(
277
+ model, args.get("conditions"), reactions=args.get("reactions"),
278
+ pairs=args.get("pairs"),
279
+ fraction_of_optimum=args.get("fraction_of_optimum", DEFAULT_FRACTION),
280
+ tolerance=args.get("tolerance", DEFAULT_TOL),
281
+ only_diff=args.get("only_diff", False), export_csv=args.get("export_csv"))}
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # op: sensitivity — 阶段A-M2 结构性灵敏度(GAM×biomass 网格 22 组合 + 必需性重扫 + 单组分漂移)
286
+ # action=probe 秒级只读(GAM 载体定位/组分计数);缺省 full=22 组合全量(约 35-45min,长任务)
287
+ # ---------------------------------------------------------------------------
288
+ def op_sensitivity(args):
289
+ from sensitivity import sensitivity, find_biomass_gam
290
+ model = args.get("model")
291
+ if not model or not os.path.exists(model):
292
+ return {"ok": False, "error": f"model file not found: {model}"}
293
+ if args.get("action") == "probe":
294
+ from silentio import silent_read_sbml
295
+ m = silent_read_sbml(model)
296
+ info = find_biomass_gam(m)
297
+ info["genes"] = len(m.genes)
298
+ info["reactions"] = len(m.reactions)
299
+ return {"ok": True, "result": info}
300
+ baseline_check = None
301
+ if args.get("baseline_check_path"):
302
+ with open(args["baseline_check_path"], encoding="utf-8") as f:
303
+ baseline_check = json.load(f)
304
+ return {"ok": True, "result": sensitivity(
305
+ model, medium=args.get("medium"),
306
+ biomass_scales=args.get("biomass_scales"), gam_grid=args.get("gam_grid"),
307
+ run_component_sensitivity=args.get("run_component_sensitivity", True),
308
+ run_drift=args.get("run_drift", True), top_n=args.get("top_n", 10),
309
+ export_csv=args.get("export_csv"), baseline_check=baseline_check)}
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # 分发器
314
+ # ---------------------------------------------------------------------------
315
+ OPS = {
316
+ "model_info": op_model_info,
317
+ "validate": op_validate,
318
+ "gapfind": op_gapfind,
319
+ "gapfill": op_gapfill,
320
+ "gapseq": op_gapseq,
321
+ "phenotype_fix": op_phenotype_fix,
322
+ "essential_scan": op_essential_scan,
323
+ "annotate": op_annotate,
324
+ "media_resolve": op_media_resolve,
325
+ "l3_fix": op_l3_fix,
326
+ }
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # op: biomass_inspect / biomass_apply — Q2 任务一:biomass 精修(可选 profile,不默认替换)
331
+ # ---------------------------------------------------------------------------
332
+ def op_biomass_inspect(args):
333
+ from biomass_tools import inspect_biomass
334
+ model = args.get("model")
335
+ if not model or not os.path.exists(model):
336
+ return {"ok": False, "error": f"model file not found: {model}"}
337
+ r = inspect_biomass(model, reference=args.get("reference"),
338
+ universal_path=args.get("universal_path"), inx_path=args.get("inx_path"))
339
+ if r.get("ok"):
340
+ return {"ok": True, "result": r["result"]}
341
+ return {"ok": False, "error": r.get("error") or "inspect failed"}
342
+
343
+
344
+ def op_biomass_apply(args):
345
+ from biomass_tools import apply_biomass
346
+ model = args.get("model")
347
+ if not model or not os.path.exists(model):
348
+ return {"ok": False, "error": f"model file not found: {model}"}
349
+ if not args.get("biomass_profile"):
350
+ return {"ok": False, "error": "provide biomass_profile(显式覆盖表 [{met_id, coeff, op: set|add|remove}]);"
351
+ "默认不应用任何 profile,只读诊断请用 action=inspect"}
352
+ r = apply_biomass(model, args.get("biomass_profile"), medium=args.get("medium"),
353
+ phenotype_table=args.get("phenotype_table"), out=args.get("out"),
354
+ essential_sample=args.get("essential_sample", 40), note=args.get("note"))
355
+ if r.get("ok"):
356
+ return {"ok": True, "result": r["result"]}
357
+ return {"ok": False, "error": r.get("error") or "apply failed",
358
+ "skipped": r.get("skipped")}
359
+
360
+
361
+ # OPS 引用上面的 op 函数——biomass 两 op 定义在 main 前补充注册(避免前向引用 NameError)
362
+ OPS["biomass_inspect"] = op_biomass_inspect
363
+ OPS["biomass_apply"] = op_biomass_apply
364
+ OPS["fluxscan"] = op_fluxscan
365
+ OPS["sensitivity"] = op_sensitivity
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # op: ledger — 阶段A-M3 prediction ledger(list/query/update;只读/追加/更新,不删行)
370
+ # 默认账本 ~/.dsh/dsh-bio-gem/ledger/predictions.jsonl;ledger_path 可覆盖(测试用临时路径)
371
+ # ---------------------------------------------------------------------------
372
+ def op_ledger(args):
373
+ import ledger as _ledger
374
+ action = args.get("action", "list")
375
+ lp = args.get("ledger_path")
376
+ if action == "list":
377
+ return {"ok": True, "result": _ledger.query_ledger(
378
+ limit=args.get("limit"), offset=args.get("offset", 0), path=lp)}
379
+ if action == "query":
380
+ return {"ok": True, "result": _ledger.query_ledger(
381
+ rtype=args.get("type"), status=args.get("status"), condition=args.get("condition"),
382
+ model=args.get("model"), limit=args.get("limit"), offset=args.get("offset", 0),
383
+ deprecated=args.get("deprecated"), path=lp)}
384
+ if action == "update":
385
+ r = _ledger.update_row(args.get("prediction_id"), status=args.get("status"),
386
+ source_refs=args.get("source_refs"),
387
+ comparison_refs=args.get("comparison_refs"), path=lp)
388
+ if r.get("ok"):
389
+ return {"ok": True, "result": r}
390
+ return {"ok": False, "error": r.get("error") or "update failed"}
391
+ return {"ok": False, "error": f"unknown ledger action: {action}(list|query|update)"}
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # op: benchmark — 阶段B-B1 通用基准对比(六关并列/生长/含边界介质回退/biomass 探针/必需性对比
396
+ # 含退化护栏/表型/可复现性/账本 comparison_refs 回填;md 落盘可选)
397
+ # ---------------------------------------------------------------------------
398
+ def op_benchmark(args):
399
+ from benchmark import benchmark
400
+ model_a, model_b = args.get("model_a"), args.get("model_b")
401
+ for k, v in (("model_a", model_a), ("model_b", model_b)):
402
+ if not v:
403
+ return {"ok": False, "error": f"{k} required"}
404
+ if not v.startswith("bigg:") and not os.path.exists(v):
405
+ # bigg:<id> URI 由 benchmark 层下载解析(B3);本地路径仍要求存在
406
+ return {"ok": False, "error": f"{k} file not found: {v}"}
407
+ return {"ok": True, "result": benchmark(
408
+ model_a, model_b, medium=args.get("medium"),
409
+ phenotype_table=args.get("phenotype_table"),
410
+ reference_essential=args.get("reference_essential"),
411
+ essential_full=args.get("essential_full", False),
412
+ ledger_refs=args.get("ledger_refs", True),
413
+ export_md=args.get("export_md"), ledger_path=args.get("ledger_path"))}
414
+
415
+
416
+ OPS["ledger"] = op_ledger
417
+ OPS["benchmark"] = op_benchmark
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # op: secretion — 阶段C-C1 可分泌代谢物谱(production envelope 扫描;纯拓扑边界声明内置;
422
+ # wt<=EPS 退化护栏不登记;type=secretion 账本登记幂等)
423
+ # ---------------------------------------------------------------------------
424
+ def op_secretion(args):
425
+ from secretion import secretion
426
+ model = args.get("model")
427
+ if not model or not os.path.exists(model):
428
+ return {"ok": False, "error": f"model file not found: {model}"}
429
+ return {"ok": True, "result": secretion(
430
+ model, medium=args.get("medium"), fractions=args.get("fractions"),
431
+ export_csv=args.get("export_csv"), ledger_refs=args.get("ledger_refs", True),
432
+ ledger_path=args.get("ledger_path"),
433
+ # P0-1:默认 summary(防大输出被引擎省略截断);全量用 mode=full 或 export_csv 落盘
434
+ mode=args.get("mode", "summary"), summary_top=args.get("summary_top", 20))}
435
+
436
+ OPS["secretion"] = op_secretion
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # op: double_knockout — 阶段C-C2 双敲 v1(合成致死;GPR 穷尽先验 + FVA 预筛全扫,max_pairs 预算;
441
+ # 假设声明内置;wt<=EPS 退化护栏不登记;type=synthetic_lethal 账本登记幂等)
442
+ # ---------------------------------------------------------------------------
443
+ def op_double_knockout(args):
444
+ from double_knockout import double_knockout
445
+ model = args.get("model")
446
+ if not model or not os.path.exists(model):
447
+ return {"ok": False, "error": f"model file not found: {model}"}
448
+ return {"ok": True, "result": double_knockout(
449
+ model, medium=args.get("medium"), max_pairs=args.get("max_pairs", 5000),
450
+ export_csv=args.get("export_csv"), ledger_refs=args.get("ledger_refs", True),
451
+ ledger_path=args.get("ledger_path"))}
452
+
453
+
454
+ OPS["double_knockout"] = op_double_knockout
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # op: enrichment — 阶段C-C3 必需基因通路富集(超几何单侧 + BH FDR;通路源=SBML groups
459
+ # [gapseq MetaCyc PWY];无注释模型按契约 annotation_unavailable 兜底;不登记账本)
460
+ # ---------------------------------------------------------------------------
461
+ def op_enrichment(args):
462
+ from enrichment import enrichment
463
+ model = args.get("model")
464
+ if not model or not os.path.exists(model):
465
+ return {"ok": False, "error": f"model file not found: {model}"}
466
+ return {"ok": True, "result": enrichment(
467
+ model, gene_list=args.get("gene_list"), pathway_source=args.get("pathway_source"),
468
+ ledger_path=args.get("ledger_path"), export_csv=args.get("export_csv"))}
469
+
470
+
471
+ OPS["enrichment"] = op_enrichment
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # op: targets — 阶段C-C4 靶点清单规范导出(账本三类预测 -> 锁定 schema;供下游引物/编辑
476
+ # 工具直接输入;与账本计数闭合;引物/质粒设计本身不做)
477
+ # ---------------------------------------------------------------------------
478
+ def op_targets(args):
479
+ from targets import targets
480
+ return {"ok": True, "result": targets(
481
+ model_path=args.get("model"), types=args.get("types"), condition=args.get("condition"),
482
+ ledger_path=args.get("ledger_path"), export_format=args.get("export_format", "csv"),
483
+ export_path=args.get("export_path"))}
484
+
485
+
486
+ OPS["targets"] = op_targets
487
+
488
+
489
+
490
+ def main():
491
+ line = sys.stdin.read()
492
+ try:
493
+ req = json.loads(line)
494
+ op = req.get("op", "")
495
+ args = req.get("args", {}) or {}
496
+ except Exception:
497
+ # 协议级失败:返回 ok:false + 说明
498
+ print(json.dumps({"ok": False, "error": "protocol error: invalid JSON on stdin"}))
499
+ return
500
+ fn = OPS.get(op)
501
+ if fn is None:
502
+ print(json.dumps({"ok": False, "error": f"unknown op: {op}"}))
503
+ return
504
+ try:
505
+ out = fn(args)
506
+ if isinstance(out, dict) and "ok" in out:
507
+ print(json.dumps(_sanitize_json(out), ensure_ascii=False))
508
+ else:
509
+ print(json.dumps(_sanitize_json({"ok": True, "result": out}), ensure_ascii=False))
510
+ except Exception as e:
511
+ # 捕获所有异常,恒返回 ok:true(traceback 写 stderr 头,TS 侧检测)
512
+ sys.stderr.write("Traceback (most recent call last):\n")
513
+ traceback.print_exc(file=sys.stderr)
514
+ print(json.dumps({"ok": True, "result": None,
515
+ "error_hint": f"op {op} failed: {type(e).__name__}: {e}"},
516
+ ensure_ascii=False))
517
+
518
+
519
+ if __name__ == "__main__":
520
+ main()