@dsh-bio/dsh-bio-gem 0.1.2 → 0.1.12
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.
- package/README.md +236 -199
- package/docs/ARCHITECTURE.md +142 -113
- package/docs/releases/v0.1.12.md +43 -0
- package/package.json +5 -3
- package/python/coherence.py +159 -0
- package/python/gapfind.py +413 -396
- package/python/gem_ops.py +19 -0
- package/python/precursor_scan.py +127 -0
- package/python/validate.py +435 -392
- package/skills/gem-expert.md +1 -1
- package/src/index.js +21 -2
- package/src/integration.js +489 -0
- package/src/jobs.js +5 -21
- package/src/python.js +113 -17
- package/src/tools.js +27 -8
package/src/python.js
CHANGED
|
@@ -1,27 +1,123 @@
|
|
|
1
1
|
// python.js — dsh-bio-gem Python 子进程调用器(JSON stdin 协议)
|
|
2
2
|
// bridge 契约同 dsh-bio-genie:stdout 最后一行是 JSON;stderr 含
|
|
3
3
|
// "Traceback (most recent call last)" 头 = 代码级失败(恒 ok:true 时靠它判定)。
|
|
4
|
-
import { spawn } from 'node:child_process'
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
8
9
|
|
|
9
10
|
const PYTHON_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'python')
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
/**
|
|
13
|
+
* 候选解释器,按优先级(**通用化,不写死任何本机路径**):
|
|
14
|
+
*
|
|
15
|
+
* 1. `GEM_PYTHON` — 用户显式指定,最高优先级
|
|
16
|
+
* 2. 宿主插件自举环境 — `$DSH_HOME/dsh-bio-genie/python-env`
|
|
17
|
+
* dsh-bio-genie 的环境引导把 cobra 装在**第一层依赖**里,gem 可直接复用,
|
|
18
|
+
* 因此用户只需安装 genie 即可获得代谢建模能力,无需自备 conda/cobra。
|
|
19
|
+
* (2026-09-11 实测:用该环境的 python 跑通 gem 的 ledger/validate/report op)
|
|
20
|
+
* 3. `CONDA_PREFIX` — 当前激活的 conda 环境(通用信号,非硬编码路径)
|
|
21
|
+
* 4. `python` — PATH 兜底
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Candidate interpreters in the same priority order used by pythonExe().
|
|
25
|
+
*
|
|
26
|
+
* The integration status endpoint consumes this exported, side-effect-free
|
|
27
|
+
* description instead of keeping a second, drift-prone candidate list.
|
|
28
|
+
*/
|
|
29
|
+
export function pythonCandidates() {
|
|
30
|
+
const list = []
|
|
31
|
+
if (process.env.GEM_PYTHON) list.push({ path: process.env.GEM_PYTHON, source: 'GEM_PYTHON' })
|
|
32
|
+
|
|
33
|
+
const dshHome = process.env.DSH_HOME ?? join(os.homedir(), '.dsh')
|
|
34
|
+
const hosted = join(dshHome, 'dsh-bio-genie', 'python-env')
|
|
35
|
+
list.push({
|
|
36
|
+
path: process.platform === 'win32'
|
|
37
|
+
? join(hosted, 'Scripts', 'python.exe')
|
|
38
|
+
: join(hosted, 'bin', 'python'),
|
|
39
|
+
source: 'genie-hosted',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (process.env.CONDA_PREFIX) {
|
|
43
|
+
list.push({
|
|
44
|
+
path: process.platform === 'win32'
|
|
45
|
+
? join(process.env.CONDA_PREFIX, 'python.exe')
|
|
46
|
+
: join(process.env.CONDA_PREFIX, 'bin', 'python'),
|
|
47
|
+
source: 'CONDA_PREFIX',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
list.push({ path: 'python', source: 'PATH' })
|
|
52
|
+
return list
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function candidates() {
|
|
56
|
+
return pythonCandidates().map((candidate) => candidate.path)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** cobra 是本插件除 gem_build 外全部 op 的硬依赖:探测解释器能否 import cobra。 */
|
|
60
|
+
function hasCobra(exe) {
|
|
61
|
+
try {
|
|
62
|
+
const r = spawnSync(exe, ['-I', '-c', 'import cobra'], {
|
|
63
|
+
timeout: 30_000, windowsHide: true, stdio: 'ignore',
|
|
64
|
+
})
|
|
65
|
+
return r.status === 0
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let cachedExe = null
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 选定解释器(进程内缓存)。
|
|
75
|
+
*
|
|
76
|
+
* 不做「路径存在即采用」的浅判断——落在一个没有 cobra 的解释器上时,
|
|
77
|
+
* 工具只会抛 ModuleNotFoundError 而用户无从判断该装到哪里(README 曾专门
|
|
78
|
+
* 警告此坑)。这里逐个探测 `import cobra`,让选择结果可解释。
|
|
79
|
+
*
|
|
80
|
+
* 注:选择依据的可视化由**宿主面板**承担(genie 设置面板的「代谢建模」分页会
|
|
81
|
+
* 展示选中解释器 + 来源 + cobra 版本 + 候选表)——本模块不再重复提供诊断 API,
|
|
82
|
+
* 也刻意不在插件加载期调用本函数(探测约 2.7s,会拖慢宿主启动)。
|
|
83
|
+
*/
|
|
84
|
+
export function pythonExe() {
|
|
85
|
+
if (cachedExe) return cachedExe
|
|
86
|
+
for (const c of candidates()) {
|
|
19
87
|
if (!c) continue
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
88
|
+
if (c !== 'python' && !existsSync(c)) continue
|
|
89
|
+
if (hasCobra(c)) {
|
|
90
|
+
cachedExe = c
|
|
91
|
+
return c
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
cachedExe = 'python'
|
|
95
|
+
return cachedExe
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** op 名 → 对外工具名(多数同名;特例是 model_info 与 biomass 的两个 op)。 */
|
|
99
|
+
const OP_TOOL = {
|
|
100
|
+
model_info: 'gem_report',
|
|
101
|
+
biomass_inspect: 'gem_biomass',
|
|
102
|
+
biomass_apply: 'gem_biomass',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toolNameFor(op) {
|
|
106
|
+
return OP_TOOL[op] ?? `gem_${op}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 与 dsh-bio-genie 的溯源契约对齐:工具输出挂 `_provenance` 背书字段。
|
|
111
|
+
*
|
|
112
|
+
* genie 侧的语义化工具(bio_*)都带该字段,其计算防火墙台账据此与回复里的
|
|
113
|
+
* 数值声明对账;gem 的工具此前不带,两边口径不一致。统一在**唯一出口**
|
|
114
|
+
* (callGem)盖章,避免逐个工具遗漏。不改动已有 _provenance(幂等)。
|
|
115
|
+
*/
|
|
116
|
+
export function stampProvenance(tool, value) {
|
|
117
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && value._provenance === undefined) {
|
|
118
|
+
value._provenance = { tool, at: new Date().toISOString() }
|
|
23
119
|
}
|
|
24
|
-
return
|
|
120
|
+
return value
|
|
25
121
|
}
|
|
26
122
|
|
|
27
123
|
/** 调用 gem_ops.py(op 协议):{op, args} -> result;异常/代码级失败抛 Error。 */
|
|
@@ -34,7 +130,7 @@ export function callGem(op, args, opts = {}) {
|
|
|
34
130
|
let err = ''
|
|
35
131
|
cp.stdout.on('data', (d) => { out += d })
|
|
36
132
|
cp.stderr.on('data', (d) => { err += d })
|
|
37
|
-
cp.on('error', (e) => reject(new Error(`python spawn failed: ${e.message}`)))
|
|
133
|
+
cp.on('error', (e) => reject(new Error(`python spawn failed (${py}): ${e.message}`)))
|
|
38
134
|
const timer = opts.timeoutMs
|
|
39
135
|
? setTimeout(() => { cp.kill(); reject(new Error(`gem op ${op} timeout after ${opts.timeoutMs}ms`)) }, opts.timeoutMs)
|
|
40
136
|
: null
|
|
@@ -42,7 +138,7 @@ export function callGem(op, args, opts = {}) {
|
|
|
42
138
|
if (timer) clearTimeout(timer)
|
|
43
139
|
const lines = out.trim().split(/\r?\n/).filter(Boolean)
|
|
44
140
|
if (!lines.length) {
|
|
45
|
-
return reject(new Error(`gem_ops.py produced no output (op=${op}); stderr: ${err.slice(-400)}`))
|
|
141
|
+
return reject(new Error(`gem_ops.py produced no output (op=${op}, python=${py}); stderr: ${err.slice(-400)}`))
|
|
46
142
|
}
|
|
47
143
|
if (err.includes('Traceback (most recent call last)')) {
|
|
48
144
|
return reject(new Error(`gem op ${op} code-level failure: ${err.slice(-400)}`))
|
|
@@ -54,11 +150,11 @@ export function callGem(op, args, opts = {}) {
|
|
|
54
150
|
return reject(new Error(`gem op ${op} bad JSON: ${lines[lines.length - 1].slice(0, 300)}`))
|
|
55
151
|
}
|
|
56
152
|
if (parsed.ok === false) return reject(new Error(parsed.error || `gem op ${op} failed (ok:false)`))
|
|
57
|
-
resolve(parsed.result)
|
|
153
|
+
resolve(stampProvenance(toolNameFor(op), parsed.result))
|
|
58
154
|
})
|
|
59
155
|
cp.stdin.write(JSON.stringify({ op, args }))
|
|
60
156
|
cp.stdin.end()
|
|
61
157
|
})
|
|
62
158
|
}
|
|
63
159
|
|
|
64
|
-
export {
|
|
160
|
+
export { PYTHON_DIR }
|
package/src/tools.js
CHANGED
|
@@ -6,11 +6,9 @@ import { join } from 'node:path'
|
|
|
6
6
|
import { dirname, isAbsolute } from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
|
-
import { callGem,
|
|
9
|
+
import { callGem, stampProvenance } from './python.js'
|
|
10
10
|
import { startBuild, jobStatus } from './jobs.js'
|
|
11
11
|
|
|
12
|
-
const PY = pythonExe()
|
|
13
|
-
|
|
14
12
|
/** 校验输入存在(绝对路径或用户给定路径)。 */
|
|
15
13
|
function requirePath(v, label) {
|
|
16
14
|
if (!v) throw new Error(`${label} required`)
|
|
@@ -86,7 +84,7 @@ function buildTool() {
|
|
|
86
84
|
if (!st.result || st.result.ok === false || st.result.result == null) {
|
|
87
85
|
throw new Error(`gem_build failed: ${st.result?.error_hint ?? st.error ?? 'result missing'}${d}`)
|
|
88
86
|
}
|
|
89
|
-
return st.result.result
|
|
87
|
+
return stampProvenance('gem_build', st.result.result)
|
|
90
88
|
}
|
|
91
89
|
}
|
|
92
90
|
throw new Error('gem_build timeout (840s)')
|
|
@@ -535,11 +533,32 @@ export function registerTools(ctx) {
|
|
|
535
533
|
timeoutMs: 120_000,
|
|
536
534
|
})))
|
|
537
535
|
|
|
536
|
+
// gem_precursor_scan:阻塞前体分析(「模型为什么不长」的结构级定位)
|
|
537
|
+
disposers.push(ctx.tools.register(gemTool({
|
|
538
|
+
name: 'gem_precursor_scan',
|
|
539
|
+
description:
|
|
540
|
+
'阻塞前体分析(precursor_scan):定位**哪个 biomass 前体在卡住生长**。' +
|
|
541
|
+
'先测基线 biomass 通量——可生长则直接返回「无阻塞」(不做逐前体测试,天然零假阳性);' +
|
|
542
|
+
'不生长则逐一移除每个前体的需求,看 biomass 是否恢复通量:恢复者即阻塞点。' +
|
|
543
|
+
'verdict 三态:growable(该条件下可生长)/ blocked(列出阻塞前体)/ ' +
|
|
544
|
+
'infeasible_or_constrained(逐前体均不恢复 → 病灶在 biomass 方程整体而非单个前体)。' +
|
|
545
|
+
'用在 gem_validate 判定不生长之后、gem_gapfind 之前:先确认阻塞在哪一层,再谈补洞。' +
|
|
546
|
+
'与 gem_gapfind 区分:后者分级报「缺交换 / 缺转运 / 内部路径」,本工具报「卡在哪个前体」;' +
|
|
547
|
+
'模型数据质量存疑时(gem_validate 的 g0 非 PASS)本工具结论更贴近根因。' +
|
|
548
|
+
'触发词:模型为什么不长、哪个前体卡住、阻塞前体、生长为零诊断、precursor。',
|
|
549
|
+
parameters: {
|
|
550
|
+
model: { type: 'string', required: true, description: 'SBML 模型文件绝对路径' },
|
|
551
|
+
medium: {
|
|
552
|
+
type: 'object', additionalProperties: true,
|
|
553
|
+
description: '可选培养基:{"medium_name":"AB"/"M9"}(内置成分)或 {EX_id: lower_bound};缺省用模型自带边界',
|
|
554
|
+
},
|
|
555
|
+
max_precursors: { type: 'number', description: '逐前体测试上限(默认 200)' },
|
|
556
|
+
},
|
|
557
|
+
op: 'precursor_scan',
|
|
558
|
+
timeoutMs: 300_000,
|
|
559
|
+
})))
|
|
560
|
+
|
|
538
561
|
|
|
539
562
|
return () => disposers.forEach((d) => d())
|
|
540
563
|
}
|
|
541
564
|
|
|
542
|
-
export const gemToolNames = ['gem_report', 'gem_validate', 'gem_gapfind', 'gem_gapfill', 'gem_build',
|
|
543
|
-
'gem_gapseq', 'gem_phenotype', 'gem_essentiality', 'gem_annotate', 'gem_media_resolve', 'gem_l3_fix',
|
|
544
|
-
'gem_biomass', 'gem_fluxscan', 'gem_sensitivity', 'gem_ledger', 'gem_benchmark', 'gem_secretion',
|
|
545
|
-
'gem_double_knockout', 'gem_enrichment', 'gem_targets']
|