@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/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
- // 运行时探测 Python:优先 miniconda(本机分析环境,cobra 已装),回退 env GEM_PYTHON / PATH
12
- function pythonExe() {
13
- const cands = [
14
- process.env.GEM_PYTHON,
15
- 'C:/Users/shuai/miniconda3/python.exe',
16
- 'python',
17
- ]
18
- for (const c of cands) {
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
- try {
21
- if (c === 'python' || existsSync(c)) return c
22
- } catch { /* ignore */ }
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 'python'
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 { pythonExe, PYTHON_DIR }
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, pythonExe, PYTHON_DIR } from './python.js'
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']