@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,393 @@
1
+ # validate.py — dsh-bio-gem 五道验证关卡(M1)
2
+ # G1 加载统计 / G2 内部反应元素平衡 / G3 生长真实性 / G4 底物表型(条件) / G5 必需基因抽检(条件)
3
+ # 规格: docs/ARCHITECTURE.md §5;判据口径 = FBA objective_value(mmol/gDW/h,不用 μ)
4
+ # 实现从 HANDOFF-03 五道关卡协议产品化(农杆菌项目验证过的逻辑)
5
+ import re
6
+ import os
7
+ import json
8
+ import sys
9
+ import cobra
10
+
11
+ # Python -I isolated 模式下脚本目录不进 sys.path——显式插入以导入同目录模块
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+
14
+ EX_PREFIX = ("EX_", "DM_", "SK_")
15
+ CORE_ELEMS = ("C", "N", "P", "S") # 硬核:不平衡必须 = 0
16
+ REPORT_ELEMS = ("H", "O") # 报告不阻塞
17
+ ELM_RE = re.compile(r"([A-Z][a-z]?)(\d*)")
18
+
19
+ # 关卡注册器(GLM 建议 + 2026-08-29 采纳):未来加 G7 不改主流程
20
+ GATE_REGISTRY = {}
21
+
22
+
23
+ def register_gate(name):
24
+ def deco(fn):
25
+ GATE_REGISTRY[name] = fn
26
+ return fn
27
+ return deco
28
+
29
+
30
+ def parse_formula(f):
31
+ """C10H13N5O13P3 -> {"C":10,...}; 忽略 R/X 等通用占位。"""
32
+ d = {}
33
+ if not f:
34
+ return d
35
+ for m in ELM_RE.finditer(f):
36
+ el = m.group(1)
37
+ n = int(m.group(2) or 1)
38
+ d[el] = d.get(el, 0) + n
39
+ return d
40
+
41
+
42
+ def _rxn_elem_balance(rxn):
43
+ """内部反应元素平衡:返回 {elem: delta}(delta=产物-底物,应接近 0)。"""
44
+ bal = {}
45
+ for met, coeff in rxn.metabolites.items():
46
+ if not met.formula:
47
+ continue
48
+ d = parse_formula(met.formula)
49
+ for el, n in d.items():
50
+ bal[el] = bal.get(el, 0) + coeff * n
51
+ return bal
52
+
53
+
54
+ class Validator:
55
+ def __init__(self, model_path):
56
+ from silentio import silent_read_sbml
57
+ self.path = model_path
58
+ self.m = silent_read_sbml(model_path)
59
+
60
+ # ------------------------------------------------------------------ G1
61
+ def g1_load(self):
62
+ m = self.m
63
+ from collections import Counter
64
+ repl = Counter()
65
+ bad_ids = []
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
+ bad_ids.append(g.id)
73
+ n_genes_with_rxn = sum(1 for g in m.genes if len(g.reactions) > 0)
74
+ rep = {
75
+ "status": "PASS" if not bad_ids else "WARN",
76
+ "genes": len(m.genes), "reactions": len(m.reactions),
77
+ "metabolites": len(m.metabolites),
78
+ "replicons": dict(repl),
79
+ "non_nc_gene_ids": bad_ids[:10],
80
+ "gpr_gene_coverage": round(n_genes_with_rxn / len(m.genes), 4) if m.genes else 0,
81
+ }
82
+ return rep
83
+
84
+ # ------------------------------------------------------------------ G2
85
+ def g2_balance(self):
86
+ m = self.m
87
+ internal = [r for r in m.reactions
88
+ if not (r.id.startswith(EX_PREFIX) or r.boundary)]
89
+ formula_coverage = sum(1 for x in m.metabolites if x.formula) / len(m.metabolites)
90
+ bad_core = {} # elem -> [rxn ids]
91
+ bad_report = {}
92
+ checked = 0
93
+ for r in internal:
94
+ if not all(x.formula for x in r.metabolites):
95
+ continue # 公式缺失不计入不平衡(先报覆盖率)
96
+ checked += 1
97
+ bal = _rxn_elem_balance(r)
98
+ for el in CORE_ELEMS:
99
+ v = bal.get(el, 0)
100
+ if abs(v) > 1e-6:
101
+ bad_core.setdefault(el, []).append(r.id)
102
+ for el in REPORT_ELEMS:
103
+ v = bal.get(el, 0)
104
+ if abs(v) > 2: # charged 公式惯例噪声容忍 ±2
105
+ bad_report.setdefault(el, []).append(r.id)
106
+ n_bad = sum(len(v) for v in bad_core.values())
107
+ frac = 1.0 - n_bad / checked if checked else 0.0
108
+ status = "PASS" if n_bad == 0 else ("WARN" if frac >= 0.85 else "FAIL")
109
+ rep = {
110
+ "status": status,
111
+ "internal_reactions": len(internal), "formula_checked": checked,
112
+ "metabolite_formula_coverage": round(formula_coverage, 4),
113
+ "core_unbalanced": {k: len(v) for k, v in bad_core.items()},
114
+ "core_unbalanced_examples": {k: v[:5] for k, v in bad_core.items()},
115
+ "h_o_report": {k: len(v) for k, v in bad_report.items()},
116
+ "core_balance_frac": round(frac, 4),
117
+ # P0-2(2026-08-31 LBA9402 会话实测):agent 会把 0.9985 心算换算成百分比而被防火墙拦——直接给原始百分数字段
118
+ "core_balance_frac_pct": round(frac * 100, 2),
119
+ }
120
+ return rep
121
+
122
+ # ------------------------------------------------------------------ G3
123
+ def g3_growth(self, medium, reference_growth=None):
124
+ """medium: {EX_id: lower_bound}; 三态:medium / no-carbon / all-closed。"""
125
+ m = self.m
126
+
127
+ def _setup(medium_dict):
128
+ for r in m.reactions:
129
+ if r.id.startswith(EX_PREFIX) or r.boundary:
130
+ r.lower_bound = 0.0
131
+ for rid, lb in (medium_dict or {}).items():
132
+ if rid in m.reactions:
133
+ m.reactions.get_by_id(rid).lower_bound = lb
134
+ else:
135
+ return rid
136
+ return None
137
+
138
+ miss = _setup(medium or {})
139
+ if miss:
140
+ return {"status": "FAIL", "reason": f"medium exchange not in model: {miss}",
141
+ "medium_provided": bool(medium)}
142
+ with m:
143
+ wt = m.optimize().objective_value
144
+ # no-carbon: 去掉 formula 含 C 的交换
145
+ no_c_medium = dict(medium or {})
146
+ for rid in list(no_c_medium):
147
+ if rid.startswith("EX_") and rid in m.reactions:
148
+ met = list(m.reactions.get_by_id(rid).metabolites)[0]
149
+ if met.formula and "C" in parse_formula(met.formula):
150
+ del no_c_medium[rid]
151
+ miss = _setup(no_c_medium)
152
+ with m:
153
+ g_no_c = m.optimize().objective_value
154
+ miss = _setup({}) # all closed
155
+ with m:
156
+ g_closed = m.optimize().objective_value
157
+ ok_grow = wt > 1e-6
158
+ ok_noc = abs(g_no_c) < 1e-6
159
+ ok_closed = abs(g_closed) < 1e-6
160
+ ratio = None
161
+ if reference_growth and reference_growth > 0:
162
+ ratio = wt / reference_growth
163
+ if not medium:
164
+ status = "WARN" # 无声明培养基 -> 无法验证
165
+ elif ok_grow and ok_noc and ok_closed and (ratio is None or ratio >= 0.99):
166
+ status = "PASS"
167
+ else:
168
+ status = "FAIL" # 有培养基但生长不达标(或对照泄漏)——构建侧必须走补洞闭环
169
+ rep = {
170
+ "status": status,
171
+ "medium_provided": bool(medium),
172
+ "growth_medium": round(wt, 6),
173
+ "growth_no_carbon": round(g_no_c, 6),
174
+ "growth_all_closed": round(g_closed, 6),
175
+ "ratio_vs_reference": round(ratio, 4) if ratio is not None else None,
176
+ "checks": {"medium>0": ok_grow, "no_carbon==0": ok_noc, "closed==0": ok_closed},
177
+ # 阶段A-M4 口径声明(只增):单点 FBA 值非硬结论
178
+ "units": "mmol/gDW/h",
179
+ "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定",
180
+ }
181
+ return rep
182
+
183
+ # ------------------------------------------------------------------ G6
184
+ @register_gate("G6")
185
+ def g6_atp_leak(self, context=None):
186
+ """ATP 泄漏测试(MEMOTE 核心测试;G3 all-closed 的必要不充分检查):
187
+ 全关交换后最大化 ATP 代谢物的净消耗(demand),通量 > 0.01 → WARN。
188
+ 补洞后必跑(context.post_gapfill 时不再跳过)。
189
+ P1-5 修复(2026-08-31 LBA9402 会话实测):CarveMe 模型 ATP id 为 M_atp_c,
190
+ 旧匹配只看 atp_c/cpd00002_c0 -> 误 SKIP「未找到 ATP」——扩展命名模式 + SKIP 时列出尝试模式与模型内候选。"""
191
+ m = self.m
192
+ atp_patterns = ("atp_c", "cpd00002_c0", "m_atp_c", "atp_c0", "cpd00002", "atp")
193
+ cands = [x for x in m.metabolites if (x.id or "").lower() in atp_patterns]
194
+ cyto = [x for x in cands if x.compartment in ("c0", "c")]
195
+ atp_c = (cyto or cands or [None])[0]
196
+ if atp_c is None:
197
+ atp_like = sorted({x.id for x in m.metabolites if "atp" in (x.id or "").lower()})[:10]
198
+ return {"status": "SKIP",
199
+ "reason": "未找到 ATP 代谢物(已尝试命名模式: " + ", ".join(atp_patterns) + ")",
200
+ "tried_patterns": list(atp_patterns),
201
+ "atp_like_ids_in_model": atp_like,
202
+ "note": "SKIP 系命名口径未命中(非模型缺陷证明);若模型含 ATP 但 id 不在尝试模式中,"
203
+ "补充模式或标注 atp 角色后重跑"}
204
+ dm = cobra.Reaction("DM_gem_atp_leak", name="G6 ATP 泄漏检测 demand",
205
+ lower_bound=0.0, upper_bound=1000.0)
206
+ dm.add_metabolites({atp_c: -1})
207
+ m.add_reactions([dm])
208
+ try:
209
+ with m:
210
+ for r in m.reactions:
211
+ if r.id.startswith(EX_PREFIX) or r.boundary:
212
+ r.lower_bound = 0.0
213
+ v = m.optimize().objective_value
214
+ finally:
215
+ m.remove_reactions([dm])
216
+ leak = abs(v)
217
+ status = "PASS" if leak <= 0.01 else "WARN"
218
+ return {"status": status, "atp_leak_flux": round(leak, 6),
219
+ "atp_metabolite_found": atp_c.id,
220
+ "threshold": 0.01, "post_gapfill": bool((context or {}).get("post_gapfill")),
221
+ "note": "全关交换后 ATP demand 通量应≈0;>0.01 提示能量循环泄漏(L3 MILP 补洞最可能引入)"}
222
+
223
+ # ------------------------------------------------------------------ G4
224
+ def g4_phenotype(self, table_path=None, substrates=None, medium=None, carbon_mode="supplement"):
225
+ """条件执行:需参照表(TSV: substrate<TAB>published 0/1)或 substrates+published。
226
+ carbon_mode: supplement=基准培养基不变+底物-10(对齐 HANDOFF-03 关卡4 基线 16/19→17/19);
227
+ sole=去含碳交换后底物-10(唯一碳源严格语义,氮源类测试会误判)。"""
228
+ if table_path and os.path.exists(table_path):
229
+ rows = []
230
+ with open(table_path, encoding="utf-8") as f:
231
+ for line in f:
232
+ line = line.rstrip("\r\n")
233
+ if not line or line.startswith("#") or line.startswith("substrate"):
234
+ continue
235
+ p = line.split("\t")
236
+ if len(p) >= 2:
237
+ rows.append((p[0].strip(), int(float(p[1]))))
238
+ elif substrates:
239
+ rows = substrates
240
+ else:
241
+ return {"status": "SKIP", "reason": "no phenotype reference provided"}
242
+ if not medium:
243
+ return {"status": "SKIP", "reason": "G4 needs medium to define base"}
244
+ from gapfind import build_ex_index, match_ex, SYN
245
+ m = self.m
246
+ # 统一走 gapfind 的 build_ex_index + match_ex(修复过子串误配规则;勿再各自实现)
247
+ ex_idx = build_ex_index(m)
248
+ results = []
249
+ matched = 0
250
+ for sub, pub in rows:
251
+ exid = match_ex(sub, ex_idx)
252
+ # 基底:medium(supplement)或去碳后加底物(sole)
253
+ med2 = dict(medium)
254
+ if carbon_mode == "sole":
255
+ for rid in list(med2):
256
+ if rid.startswith("EX_") and rid in m.reactions:
257
+ met = list(m.reactions.get_by_id(rid).metabolites)[0]
258
+ if met.formula and "C" in parse_formula(met.formula):
259
+ del med2[rid]
260
+ if exid and exid in m.reactions:
261
+ med2[exid] = -10.0
262
+ for r in m.reactions:
263
+ if r.id.startswith(EX_PREFIX) or r.boundary:
264
+ r.lower_bound = 0.0
265
+ for rid, lb in med2.items():
266
+ if rid in m.reactions:
267
+ m.reactions.get_by_id(rid).lower_bound = lb
268
+ with m:
269
+ g = m.optimize().objective_value
270
+ pred = g > 1e-6
271
+ ok = (pred == bool(pub))
272
+ if ok:
273
+ matched += 1
274
+ results.append({"substrate": sub, "published": int(pub), "predicted": int(pred),
275
+ "growth": round(g, 6), "exchange": exid or None, "match": bool(ok),
276
+ # 阶段A-M4 口径声明(只增):每底物 growth 为单点 FBA 值
277
+ "units": "mmol/gDW/h",
278
+ "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"})
279
+ rep = {
280
+ "status": "PASS" if rows and matched / len(rows) >= 0.8 else ("WARN" if rows else "SKIP"),
281
+ "carbon_mode": carbon_mode,
282
+ "matched": matched, "total": len(rows),
283
+ "rate": round(matched / len(rows), 4) if rows else None,
284
+ "results": results,
285
+ }
286
+ return rep
287
+
288
+ # ------------------------------------------------------------------ G5
289
+ def g5_essentiality(self, essential_test, medium, reference_essential=None):
290
+ """条件执行:对给定基因列表逐一手工敲除(with m: 循环),输出每个基因的必要性。
291
+ 若给 reference_essential(已知必需基因集)→ 对交集算召回。"""
292
+ if not essential_test:
293
+ return {"status": "SKIP", "reason": "no essential_test gene list provided"}
294
+ if not medium:
295
+ return {"status": "SKIP", "reason": "G5 needs medium"}
296
+ m = self.m
297
+ present = [g for g in essential_test if g in m.genes]
298
+ if len(present) / len(essential_test) < 0.8:
299
+ return {"status": "SKIP", "reason": "gene mapping coverage < 80%",
300
+ "present": len(present), "total": len(essential_test)}
301
+ results = []
302
+ for gid in present:
303
+ with m:
304
+ for r in m.reactions:
305
+ if r.id.startswith(EX_PREFIX) or r.boundary:
306
+ r.lower_bound = 0.0
307
+ for rid, lb in medium.items():
308
+ if rid in m.reactions:
309
+ m.reactions.get_by_id(rid).lower_bound = lb
310
+ m.genes.get_by_id(gid).knock_out()
311
+ g = m.optimize().objective_value
312
+ results.append({"gene": gid, "growth": round(g, 6),
313
+ "essential": bool(g < 1e-6)})
314
+ n_ess = sum(1 for r_ in results if r_["essential"])
315
+ recall = None
316
+ if reference_essential:
317
+ ref = set(reference_essential)
318
+ tp = sum(1 for r_ in results if r_["essential"] and r_["gene"] in ref)
319
+ recall = round(tp / len(ref), 4) if ref else None
320
+ rep = {
321
+ "status": "PASS" if (recall is None or recall >= 0.4) else "WARN",
322
+ "tested": len(results), "essential_found": n_ess,
323
+ "recall_vs_reference": recall,
324
+ "details": results,
325
+ }
326
+ return rep
327
+
328
+ # ------------------------------------------------------------------ run
329
+ def run(self, medium=None, phenotype_table=None, essential_test=None,
330
+ reference_growth=None, reference_essential=None, carbon_mode="supplement",
331
+ context=None):
332
+ from gapfind import resolve_medium, expand_medium
333
+ medium, _preset = expand_medium(medium)
334
+ resolved_med, unresolved = resolve_medium(self.m, medium) if medium else ({}, [])
335
+ report = {"model": self.path,
336
+ "units": {"growth": "mmol/gDW/h", "note": "objective_value 是 FBA 通量(mmol/gDW/h),不是比生长速率 μ(h⁻¹)"},
337
+ "g1": self.g1_load(),
338
+ "g2": self.g2_balance(),
339
+ "g6": self.g6_atp_leak(context=context or {})}
340
+ g3 = self.g3_growth(resolved_med, reference_growth)
341
+ if unresolved:
342
+ g3["medium_unresolved"] = unresolved
343
+ report["g3"] = g3
344
+ if phenotype_table:
345
+ report["g4"] = self.g4_phenotype(table_path=phenotype_table, medium=resolved_med,
346
+ carbon_mode=carbon_mode)
347
+ else:
348
+ report["g4"] = {"status": "SKIP", "reason": "no phenotype reference provided"}
349
+ if essential_test:
350
+ report["g5"] = self.g5_essentiality(essential_test, resolved_med, reference_essential)
351
+ else:
352
+ report["g5"] = {"status": "SKIP", "reason": "no essential_test provided"}
353
+ # 总判定:G1/G2/G3/G6(FATAL 才 FAIL;G6 WARN 级不阻塞但补洞后必查)
354
+ blocked = ["g1", "g2", "g3"]
355
+ fails = [k for k in blocked if report[k]["status"] == "FAIL"]
356
+ warns = [k for k in blocked if report[k]["status"] == "WARN"]
357
+ report["overall"] = "FAIL" if fails else ("WARN" if warns else "PASS")
358
+ report["blocking"] = blocked
359
+ return report
360
+
361
+
362
+ def validate_model(model_path, medium=None, phenotype_table=None,
363
+ essential_test=None, reference_growth=None, reference_essential=None,
364
+ carbon_mode="supplement"):
365
+ v = Validator(model_path)
366
+ return v.run(medium=medium, phenotype_table=phenotype_table,
367
+ essential_test=essential_test, reference_growth=reference_growth,
368
+ reference_essential=reference_essential, carbon_mode=carbon_mode)
369
+
370
+
371
+ if __name__ == "__main__":
372
+ # 命令行直跑(开发/测试用):python validate.py <model.xml> [--medium-json x] [--table t] [--g5 g1,g2]
373
+ import sys
374
+ path = sys.argv[1]
375
+ med = None
376
+ table = None
377
+ g5 = None
378
+ refg = None
379
+ i = 2
380
+ while i < len(sys.argv):
381
+ if sys.argv[i] == "--medium-json" and i + 1 < len(sys.argv):
382
+ med = json.loads(sys.argv[i + 1]); i += 2
383
+ elif sys.argv[i] == "--table" and i + 1 < len(sys.argv):
384
+ table = sys.argv[i + 1]; i += 2
385
+ elif sys.argv[i] == "--g5" and i + 1 < len(sys.argv):
386
+ g5 = sys.argv[i + 1].split(","); i += 2
387
+ elif sys.argv[i] == "--ref-growth" and i + 1 < len(sys.argv):
388
+ refg = float(sys.argv[i + 1]); i += 2
389
+ else:
390
+ i += 1
391
+ rep = validate_model(path, medium=med, phenotype_table=table, essential_test=g5,
392
+ reference_growth=refg)
393
+ print(json.dumps(rep, ensure_ascii=False, indent=2))
@@ -0,0 +1,88 @@
1
+ ---
2
+ language: mixed
3
+ ---
4
+
5
+ # dsh-bio-gem 主指引:基因组 → 可验证代谢模型(GEM)
6
+
7
+ 任何「代谢模型 / GEM / 基因组建模型 / 模型验证 / 模型补洞」需求先加载本 skill。
8
+
9
+ ## 工具分层(按任务选,别乱用)
10
+
11
+ | 任务 | 工具 |
12
+ |---|---|
13
+ | 已有 SBML 模型文件,想知道概要(基因/反应/复制子)| `gem_report`(model 参数=绝对路径)|
14
+ | 验证模型质量(五道关卡:加载/元素平衡/生长真实性/表型/必需性抽检)| `gem_validate`(model + medium)|
15
+ | 模型在目标培养基不长,想知道为什么 | `gem_gapfind`(model + medium + substrates)|
16
+ | gapfind 判 L3(内部路径)后自动补洞(白名单/MILP)| `gem_l3_fix`(model + medium + substrates;allow_math=true 才放数学连接;补后自动跑 G6 防能量循环)|
17
+ | 看/改 biomass(FBA 目标函数)| `gem_biomass`(action=inspect 只读组分/对照参考;apply 显式 profile + 三联对照,原文件不动可回滚)|
18
+ | 按缺口自动补洞(缺交换/转运规则修复)| `gem_gapfill`(model + medium)→ 补完重跑 `gem_validate` |
19
+ | 从细菌基因组(蛋白 FASTA 或核苷酸 .fna)构建模型 | `gem_build`(input + 可选 target_medium;fna 自动注释)|
20
+ | 只有裸基因组 .fna,先要蛋白序列 | `gem_annotate`(fna → faa;官方优先 + pyrodigal 兜底)|
21
+ | 需要模型的全量必需基因清单 | `gem_essentiality`(FVA 预筛 + 手工敲除;medium 推荐 AB)|
22
+ | 跨条件通量对比(哪个反应真变了)| `gem_fluxscan`(区间制:FVA 区间+pFBA 点值,区间分离=硬结论,overlap=伪影禁止引用)|
23
+ | 量化模型不确定性(biomass/GAM 扰动下预测稳不稳)| `gem_sensitivity`(22 组合网格+稳定性三分类+单组分漂移;action=probe 秒级探测)|
24
+ | 查询/更新模型预测(必需性/表型预测追踪与实验兑现)| `gem_ledger`(list/query/update;预测默认 unverified,兑现后回填状态)|
25
+ | 两个模型规范对比(论文级基准表)| `gem_benchmark`(model_a+model_b:六关并列/生长/biomass 断供探针/必需性对比[退化侧只报结构]/表型/账本回填;export_md 落盘)|
26
+ | 看模型能产/能分泌什么(L1 拓扑)| `gem_secretion`(production envelope 扫描;边界声明=纯拓扑 LP 结果,可分泌≠会分泌)|
27
+ | 找合成致死基因对(L2 非平凡预测)| `gem_double_knockout`(GPR 穷尽先验+全扫预算;假设生成供实验设计参考非结论)|
28
+ | 必需基因的功能解读(哪些通路富集)| `gem_enrichment`(超几何+BH FDR;通路源=SBML groups[MetaCyc];无注释如实兜底)|
29
+ | 给实验/编辑工具出靶点清单 | `gem_targets`(账本三类预测 -> 锁定 schema CSV/JSON;引物设计不做)|
30
+ | 用 Biolog/文献表型表校准模型(提升匹配率)| `gem_phenotype`(phenotype_table + medium;自动补 L1/L2)|
31
+ | 把自然名培养基转成模型交换(消费方统一入口)| `gem_media_resolve`(model + medium → EX ID 列表)|
32
+ | gapseq 质量重建(需 WSL2)| `gem_gapseq`:setup → launch →(循环 status 直到 done)→ fetch,agent 编排 |
33
+ | 需要跑 FBA/必需性/生产包络线分析 | 用 dsh-bio-genie 的 `bio_fba` / `bio_gene_knockout` / `bio_production_envelope` 等(模型文件直接用)|
34
+
35
+ ## 推荐工作流
36
+
37
+ ```
38
+ 基因组(蛋白 FASTA)
39
+ └─ gem_build(engine=carveme) ──► SBML + 模型卡(内置 M9 介质验证 G1-G3;target_medium 可选)
40
+ ├─ 通过 → gem_report 看概要 / gem_validate 全检 / 交给 bio_fba 等分析
41
+ └─ 目标介质 FAIL → gem_gapfind 分级诊断
42
+ ├─ L1 缺交换 / L2 缺转运 → gem_gapfill 自动补 → 重验
43
+ └─ L3 内部路径 → 诚实报告(需文献反应,不自动补)
44
+
45
+ 基因组(核苷酸 .fna,质量档)
46
+ └─ gem_gapseq 原子编排:setup(探测)→ launch(后台 doall,30-60min 不等)→
47
+ status 循环(2-5min/次,running 时不要干等可并行处理其他)→ fetch(产物拷回)
48
+ → gem_validate / gem_gapfind / gem_gapfill 继续质量闭环
49
+
50
+ 裸基因组(.fna)纯 Windows
51
+ └─ gem_annotate(fna→faa)→ gem_build(engine=carveme) → 验证/补洞闭环
52
+ (gem_build 内部自动调注释层;也可先 gem_annotate 单独出蛋白)
53
+
54
+ 模型质量进阶
55
+ └─ gem_essentiality(全量必需基因→模型卡章节)
56
+ └─ gem_phenotype(表型回填迭代,按 Biolog/文献表校准)
57
+ └─ gem_media_resolve(介质解析——任何下游要跑 FBA 前先解析介质)
58
+ ```
59
+
60
+ ## 硬规则(实测,违反会拿错结果)
61
+
62
+ 0. **外部事实断言必须带证据**(2026-08-29 采纳 GLM 教训):凡涉及"某物种/文献/工具是否存在、是否已发表"等**模型外事实断言**,agent 必须真实检索并附来源(如 PubMed/PMC 链接);无法检索时降级表述为"未验证假设 [假设]",不得由记忆下断言。此项与 EVIDENCE 分级同位:**事实断言也有证据分级**(检索+来源=high;仅记忆=unverified)。
63
+ 1. **培养基一律用自然名成分**:`{"D-Glucose": -5, "NH3": -10, "O2": -12.5, ...}`。插件跨引擎自动解析(gapseq EX_cpdXXXXXX_e0 与 CarveMe/BiGG EX_glc__D_e 命名空间不同,硬编码 ID 会失配)。
64
+ 2. **数字必须来自工具输出**:agent 报告生长值/基因数/复制子数时引用工具 result,不要凭模型知识猜测(防幻觉铁律)。**生长值单位是 mmol/gDW/h(FBA 通量),不是 h⁻¹/μ**——汇报时不要换算成比生长速率。
65
+ 3. **CarveMe 模型的介质边界**:`gem_build` 默认在 M9 最小培养基验证(生长阳性);**AB 等自定义培养基若 FAIL 且 gapfind 判定 L3(内部路径),规则补洞修不了**——此时如实报告「模型可用介质=M9,目标介质需要 L3 级文献补充」,绝不要假装自愈。
66
+ 4. **缺口 90% 是 L1/L2**(缺交换/转运,非缺分解酶)——先诊断再补,别直接加反应。
67
+ 5. **多复制子正常**:质粒/多染色体基因都在同一模型内(C58 实测 4 复制子:967/434/65/18 基因分布于 ChrⅠ/ChrⅡ/pTi/pAt)。
68
+ 6. **通量区间制(阶段A-M4 硬规则)**:无区间不点数——任何条件间通量对比必须消费 `gem_fluxscan` 的区间分离判定;两条件区间分离才是解空间无关硬结论(a_higher/b_higher);overlap 反应的任何点值 diff 必须标注"伪影,禁止引用"。单点 FBA 生长/通量值(gem_validate/gem_phenotype/gem_essentiality 等输出)只是该条件下的一个解,跨条件直接 diff 是求解器伪影。
69
+ 7. **模型指代消歧(阶段D-E2E 硬规则)**:用户说"我们的 C58 模型"等未给路径的指代时——①先查预测账本(`gem_ledger query` 按 model 前缀)与项目档案中已登记的 canonical 路径;②同一名字存在多个模型(如 gapseq 原版 vs carveme 重建)时,必需性/富集等锚点敏感分析必须用登记在案的原始版本或向用户确认,并说明选择了哪个文件;③不同引擎重建的同一物种是不同模型(基因数/必需集都不同),不得混用结论。
70
+ 8. **文件出处/来源推断必须标注 [推断](阶段D-P2 硬规则)**:凡非本次会话工具 result 直接给出、而是凭文件名/目录布局/常识推断的出处(如"这个 faa 是 gem_annotate 产出的"),汇报时必须标注 [推断];可验证时先验证再陈述。例证:阶段 D 轮 1 agent 称 faa 为"前面流程 gem_annotate 产出"——事实上正确但出自文件名推断而非会话验证。
71
+
72
+ ## C58 回归锚(验证工具是否正常)
73
+
74
+ - 未知 → `gem_validate` 对 C58.xml(AB 自然名介质)→ G1 PASS / G3 PASS 生长 **0.519981** / 无碳源 0
75
+ - gapfind 对 C58.xml + 蔗糖 → L1(缺 EX_cpd00076_e0,fixable yes)
76
+ - gapfill 后蔗糖生长 **0.97077**(与手工 P1 补洞一致)
77
+ - gem_build 对 C58 protein.faa → M9 G3 PASS(growth>0),全流程 ~70s
78
+ - gem_fluxscan(区间制)对 C58.xml:条件 {AB} growth **0.519981**;C58_P1.xml + 蔗糖 supplement growth **0.97077**;输出每反应 fva_min/fva_max/pfba,条件对比只认区间分离判定(overlap=伪影禁止引用)
79
+ - gem_sensitivity 对 C58.xml(AB):基准组合(biomass×1.0, GAM=orig 40.0)**精确复现 essential_scan 的 155**(155 vs 155 EXACT MATCH);22 组合网格必需性恒 155
80
+ - gem_ledger:C58 essentiality 155 条 + phenotype 19 条入账,**幂等**——同参复跑 appended=0/skipped=174(账本行数不变)
81
+ - 对不上这些锚点 = 环境/模型被改动,先查再继续。
82
+
83
+ ## 常见坑
84
+
85
+ - **gem_build 别误判超时**:1-2 分钟是正常的(C58 实测 70s),等待完成。
86
+ - medium 自然名拼写:D-Glucose / NH3 / O2 / Phosphate / Sulfate / Mn2+ / Fe3+ 等;别名表已内置(malic acid、gluconate 等)。
87
+ - G2 报告 WARN 且仅 1 个反应不平衡(如 bio1)→ 生物质方程簿记特性,不是模型坏了。
88
+ - 产物模型文件优先给绝对路径;工作区管理沿用 dsh 会话工作区。
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ // dsh-bio-gem — Cordis 插件主模块
2
+ // 注入 tools(5 语义化工具:gem_report/validate/gapfind/gapfill/build)+ skills(gem-expert)。
3
+ import { registerTools } from './tools.js'
4
+ import { registerSkills } from './skills.js'
5
+
6
+ /** Cordis 插件名(cordis.patch.yml row id 同名)。 */
7
+ export const name = 'dsh-bio-gem'
8
+
9
+ /** 需要的服务。M1:tools + skills(无浏览器半/无 server 路由)。 */
10
+ export const inject = ['tools', 'skills']
11
+
12
+ /**
13
+ * 装配插件。
14
+ * @param {import('@deepseek-ai/cordis').Context} ctx
15
+ */
16
+ export function apply(ctx) {
17
+ registerTools(ctx)
18
+ registerSkills(ctx)
19
+ }
package/src/jobs.js ADDED
@@ -0,0 +1,152 @@
1
+ // jobs.js — dsh-bio-gem 后台长任务管理(M1 基建,ESM)
2
+ // gem_build 是 1-2 分钟级任务:startBuild(args) -> jobId(立即返回);
3
+ // jobStatus(jobId) -> 进度/结果(轮询)。进度事件落 <jobDir>/progress.jsonl,
4
+ // 完成时 build.py 写 result.json(进程消失也可恢复读取)。
5
+ import { spawn } from 'node:child_process'
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const ROOT = path.join(os.homedir(), '.dsh', 'dsh-bio-gem')
12
+ const JOBS_DIR = path.join(ROOT, 'jobs')
13
+ // 阶段D-E2E 修复:new URL(...).pathname 在 Windows 产生 "/C:/..." 前导斜杠,
14
+ // path.join 后得 "\C:\...python"(不存在)→ spawn ENOENT → gem_build 恒 "result missing"。
15
+ // 与 python.js 同款写法:fileURLToPath + dirname。
16
+ const PYTHON_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'python')
17
+
18
+ // 运行时探测:优先 miniconda(本机分析环境,cobra 已装),回退 env GEM_PYTHON / PATH
19
+ export function pythonExe() {
20
+ const cands = [
21
+ process.env.GEM_PYTHON,
22
+ 'C:/Users/shuai/miniconda3/python.exe',
23
+ 'python',
24
+ ]
25
+ for (const c of cands) {
26
+ if (!c) continue
27
+ try {
28
+ if (c === 'python' || fs.existsSync(c)) return c
29
+ } catch { /* ignore */ }
30
+ }
31
+ return 'python'
32
+ }
33
+
34
+ const jobs = new Map() // jobId -> {cp, jobDir, ...}
35
+
36
+ export function startBuild({ input, name, engine, medium, outDir }) {
37
+ const jobId = 'gem_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
38
+ const jobDir = path.join(JOBS_DIR, jobId)
39
+ fs.mkdirSync(jobDir, { recursive: true })
40
+ const progressFile = path.join(jobDir, 'progress.jsonl')
41
+ const stdoutFile = path.join(jobDir, 'stdout.txt')
42
+ const stderrFile = path.join(jobDir, 'stderr.txt')
43
+
44
+ const py = pythonExe()
45
+ const args = ['-u', path.join(PYTHON_DIR, 'build.py'),
46
+ '--input', input, '--progress', progressFile]
47
+ if (engine) args.push('--engine', engine)
48
+ if (name) args.push('--name', name)
49
+ if (medium) args.push('--medium-json', JSON.stringify(medium))
50
+ if (outDir) args.push('--out-dir', outDir)
51
+
52
+ const cp = spawn(py, args, { cwd: PYTHON_DIR, windowsHide: true })
53
+ const so = fs.createWriteStream(stdoutFile, { flags: 'a' })
54
+ const se = fs.createWriteStream(stderrFile, { flags: 'a' })
55
+ cp.stdout.pipe(so)
56
+ cp.stderr.pipe(se)
57
+
58
+ const rec = {
59
+ jobId, jobDir, progressFile, stdoutFile, stderrFile,
60
+ cp, done: false, result: null, error: null, started: Date.now(),
61
+ }
62
+ jobs.set(jobId, rec)
63
+
64
+ cp.on('exit', (code) => {
65
+ se.end()
66
+ so.end()
67
+ try {
68
+ const txt = fs.readFileSync(stdoutFile, 'utf8')
69
+ const lines = txt.trim().split(/\r?\n/).filter(Boolean)
70
+ const last = JSON.parse(lines[lines.length - 1])
71
+ rec.result = last
72
+ rec.done = true
73
+ rec.code = code
74
+ } catch (e) {
75
+ rec.error = `parse result failed: ${e.message}`
76
+ rec.done = true
77
+ rec.code = code
78
+ }
79
+ fs.writeFileSync(path.join(jobDir, 'result.json'),
80
+ JSON.stringify({ result: rec.result, error: rec.error, code: rec.code }, null, 2))
81
+ jobs.delete(jobId)
82
+ })
83
+ cp.on('error', (e) => {
84
+ rec.error = e.message
85
+ rec.done = true
86
+ jobs.delete(jobId)
87
+ })
88
+ return { jobId, jobDir, progressFile }
89
+ }
90
+
91
+ export function readProgress(jobId) {
92
+ const pf = path.join(JOBS_DIR, jobId, 'progress.jsonl')
93
+ if (!fs.existsSync(pf)) return []
94
+ try {
95
+ return fs.readFileSync(pf, 'utf8').trim().split(/\r?\n/)
96
+ .filter(Boolean)
97
+ .map((l) => JSON.parse(l))
98
+ } catch {
99
+ return []
100
+ }
101
+ }
102
+
103
+ // 阶段D-P2:失败透明化——job 目录内容摘要 + stderr 尾部(成功路径不计算不返回)
104
+ function jobDetail(jobDir) {
105
+ let files = []
106
+ try {
107
+ files = fs.readdirSync(jobDir).map((f) => {
108
+ let bytes = 0
109
+ try { bytes = fs.statSync(path.join(jobDir, f)).size } catch { /* ignore */ }
110
+ return { name: f, bytes }
111
+ })
112
+ } catch { /* ignore */ }
113
+ let stderr_tail = ''
114
+ try {
115
+ const sp = path.join(jobDir, 'stderr.txt')
116
+ if (fs.existsSync(sp)) stderr_tail = fs.readFileSync(sp, 'utf8').slice(-400)
117
+ } catch { /* ignore */ }
118
+ return { job_dir: jobDir, job_dir_files: files, stderr_tail }
119
+ }
120
+
121
+ // 轻量 in-memory 任务视图(进程存活期);磁盘持久视图用 readProgress/result.json
122
+ export function jobStatus(jobId) {
123
+ const m = jobs.get(jobId)
124
+ const jobDir = path.join(JOBS_DIR, jobId)
125
+ const resultFile = path.join(jobDir, 'result.json')
126
+ const events = readProgress(jobId)
127
+ const last = events.length ? events[events.length - 1] : null
128
+ let result = null
129
+ let error = null
130
+ let done = false
131
+ let code = null
132
+ if (fs.existsSync(resultFile)) {
133
+ try {
134
+ const r = JSON.parse(fs.readFileSync(resultFile, 'utf8'))
135
+ done = true; result = r.result; error = r.error; code = r.code
136
+ } catch { /* ignore */ }
137
+ } else if (m) {
138
+ done = m.done; result = m.result; error = m.error; code = m.code
139
+ } else {
140
+ const stderr = path.join(jobDir, 'stderr.txt')
141
+ error = fs.existsSync(stderr) ? fs.readFileSync(stderr, 'utf8').slice(-800) : 'job vanished'
142
+ done = true
143
+ }
144
+ // build.py 失败信封:{ok:true, result:null, error_hint}(exit 0)——也按失败透出 detail
145
+ const envelopeFailed = !!(result && result.error_hint && result.result == null)
146
+ const failed = !!error || envelopeFailed || (done && code !== 0 && !result)
147
+ return {
148
+ jobId, done, code, result, error, lastEvent: last, events, jobDir,
149
+ ...(failed ? { detail: jobDetail(jobDir) } : {}),
150
+ elapsed_ms: Date.now() - (m ? m.started : Date.now()),
151
+ }
152
+ }