@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.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +116 -0
- package/docs/DECISIONS-2026-08-29.md +37 -0
- package/docs/DECISIONS-/351/230/266/346/256/265A.md +67 -0
- package/docs/DECISIONS-/351/230/266/346/256/265E.md +56 -0
- package/index.js +5 -0
- package/package.json +50 -0
- package/python/annotate.py +208 -0
- package/python/benchmark.py +591 -0
- package/python/biomass_tools.py +329 -0
- package/python/budget.py +53 -0
- package/python/build.py +343 -0
- package/python/build_whitelist.py +127 -0
- package/python/double_knockout.py +201 -0
- package/python/enrichment.py +182 -0
- package/python/essential_scan.py +195 -0
- package/python/fluxscan.py +302 -0
- package/python/gapfill.py +176 -0
- package/python/gapfind.py +397 -0
- package/python/gapseq_wsl.py +251 -0
- package/python/gem_ops.py +520 -0
- package/python/l3_fix.py +641 -0
- package/python/ledger.py +581 -0
- package/python/model_card.py +248 -0
- package/python/phenotype_fix.py +115 -0
- package/python/roundtrip_check.py +45 -0
- package/python/secretion.py +179 -0
- package/python/sensitivity.py +484 -0
- package/python/silentio.py +28 -0
- package/python/targets.py +151 -0
- package/python/validate.py +393 -0
- package/skills/gem-expert.md +88 -0
- package/src/index.js +19 -0
- package/src/jobs.js +152 -0
- package/src/python.js +64 -0
- package/src/skills.js +29 -0
- package/src/tools.js +545 -0
package/python/build.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# build.py — dsh-bio-gem M1:CarveMe 基因组→SBML 构建(纯 Windows)
|
|
2
|
+
# 流程: 输入(protein.faa 优先; accesson 与 GFF+fna 二期待支持) -> carve 子进程
|
|
3
|
+
# -> validate G1-G3 -> 若 G3 FAIL 且给了 medium -> gapfind/gapfill 闭环 -> 模型卡 sidecar
|
|
4
|
+
# 进度: 独立 CLI 模式将事件写入 <out>.progress.jsonl(TS 层 jobs.js 轮询);CARVE_CMD 可被 env 覆盖。
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
import datetime
|
|
12
|
+
|
|
13
|
+
# Python -I isolated 模式下脚本目录不进 sys.path——显式插入以导入同目录模块
|
|
14
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
|
|
16
|
+
DEFAULT_CARVE_VENV = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "venv-carveme")
|
|
17
|
+
MODEL_ROOT = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "models")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _log(progress_path, event):
|
|
21
|
+
ev = {"ts": time.time(), **event}
|
|
22
|
+
with open(progress_path, "a", encoding="utf-8") as f:
|
|
23
|
+
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _carve_exe(venv=None):
|
|
27
|
+
venv = venv or DEFAULT_CARVE_VENV
|
|
28
|
+
exe = os.path.join(venv, "Scripts", "carve.exe")
|
|
29
|
+
if not os.path.exists(exe):
|
|
30
|
+
exe = "carve" # 退回 PATH
|
|
31
|
+
return exe
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_input(input_spec, progress_path=None, engine="carveme"):
|
|
35
|
+
"""输入归一化。
|
|
36
|
+
engine=carveme: *.faa(蛋白);engine=gapseq: *.fna(核苷酸)——gapseq 吃 DNA。
|
|
37
|
+
accession 下载二期待支持。"""
|
|
38
|
+
if not input_spec:
|
|
39
|
+
raise ValueError("input required (protein.faa / accession / local files)")
|
|
40
|
+
if input_spec.lower().startswith(("gcf_", "gca_")):
|
|
41
|
+
raise NotImplementedError(
|
|
42
|
+
"accession 下载二期待支持:请先用 datasets CLI 或 NCBI 下载蛋白/基因组,再传入本地路径")
|
|
43
|
+
p = input_spec
|
|
44
|
+
if not os.path.exists(p):
|
|
45
|
+
d = os.path.dirname(p)
|
|
46
|
+
cands = []
|
|
47
|
+
if os.path.isdir(d):
|
|
48
|
+
cands = [f for f in os.listdir(d)
|
|
49
|
+
if f.lower().endswith((".fna", ".faa", ".fasta", ".fa"))][:6]
|
|
50
|
+
raise ValueError(f"输入文件不存在: {p};目录内候选: {cands or '无'}")
|
|
51
|
+
low = p.lower()
|
|
52
|
+
if engine == "gapseq":
|
|
53
|
+
if low.endswith((".fna", ".fasta", ".fa", ".fna.gz", ".fasta.gz")):
|
|
54
|
+
return p
|
|
55
|
+
raise ValueError(f"gapseq 引擎需要核苷酸 fasta(.fna/.fasta):{p}")
|
|
56
|
+
if low.endswith((".faa", ".fasta", ".fa", ".faa.gz", ".fasta.gz")):
|
|
57
|
+
return p
|
|
58
|
+
if engine == "carveme" and low.endswith((".fna", ".fna.gz")):
|
|
59
|
+
# 路线 P0:裸/带注释基因组 -> 注释层 -> 蛋白(官方优先 + pyrodigal 兜底)
|
|
60
|
+
from annotate import nucleotide_to_protein
|
|
61
|
+
faa, src, stats = nucleotide_to_protein(p)
|
|
62
|
+
print(f"[annotate] source={src} seqs={stats.get('seqs')} -> {faa}")
|
|
63
|
+
return faa
|
|
64
|
+
raise ValueError(f"unsupported input type: {p}(carveme 请提供 protein.faa 或 genomic.fna;gapseq 请提供 genomic.fna)")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_carve(proteins, out_xml, venv=None, progress_path=None, timeout=3600, gapfill_medium="M9"):
|
|
68
|
+
exe = _carve_exe(venv)
|
|
69
|
+
cmd = [exe, proteins, "-o", out_xml, "-g", gapfill_medium]
|
|
70
|
+
_log(progress_path, {"event": "carve_start", "cmd": " ".join(cmd)})
|
|
71
|
+
st = time.time()
|
|
72
|
+
env = dict(os.environ)
|
|
73
|
+
# carve 从 PATH 找 diamond(Windows venv 不激活时 Scripts 不在 PATH)——显式注入
|
|
74
|
+
script_dir = os.path.join(venv or DEFAULT_CARVE_VENV, "Scripts")
|
|
75
|
+
if script_dir and script_dir not in env.get("PATH", ""):
|
|
76
|
+
env["PATH"] = script_dir + os.pathsep + env.get("PATH", "")
|
|
77
|
+
try:
|
|
78
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
|
|
79
|
+
env=env, encoding="utf-8", errors="replace")
|
|
80
|
+
except subprocess.TimeoutExpired:
|
|
81
|
+
_log(progress_path, {"event": "carve_timeout", "s": int(timeout)})
|
|
82
|
+
raise
|
|
83
|
+
dt = time.time() - st
|
|
84
|
+
if r.returncode != 0:
|
|
85
|
+
_log(progress_path, {"event": "carve_fail", "rc": r.returncode,
|
|
86
|
+
"stderr_tail": (r.stderr or "")[-800:],
|
|
87
|
+
"stdout_tail": (r.stdout or "")[-400:]})
|
|
88
|
+
raise RuntimeError(f"carve failed rc={r.returncode}: {(r.stderr or '')[-800:]}")
|
|
89
|
+
_log(progress_path, {"event": "carve_done", "s": round(dt, 1),
|
|
90
|
+
"stdout_tail": (r.stdout or "")[-300:]})
|
|
91
|
+
return out_xml
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _active_medium(m):
|
|
95
|
+
"""模型当前打开的交换 -> {EX_id: lb}(carve gapfill 实际设置的介质,可溯源)。
|
|
96
|
+
注意:CarveMe 默认把所有 EX 设成开放(-1000),故全开交换数≠真介质成分;
|
|
97
|
+
精确介质请用 _media_db_exchanges()。"""
|
|
98
|
+
return {r.id: r.lower_bound for r in m.reactions
|
|
99
|
+
if r.id.startswith("EX_") and r.lower_bound < 0}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _media_db_exchanges(m, medium_name="M9", default_lb=-10.0):
|
|
103
|
+
"""从 carveme 自带 media_db.tsv 提取介质成分 -> 模型 EX 交换字典(精确介质)。"""
|
|
104
|
+
import csv
|
|
105
|
+
db = os.path.join(DEFAULT_CARVE_VENV, "Lib", "site-packages", "carveme",
|
|
106
|
+
"data", "input", "media_db.tsv")
|
|
107
|
+
if not os.path.exists(db):
|
|
108
|
+
return _active_medium(m) # 退化:全部开放交换
|
|
109
|
+
comps = set()
|
|
110
|
+
with open(db, encoding="utf-8") as f:
|
|
111
|
+
rd = csv.DictReader(f, delimiter="\t")
|
|
112
|
+
for row in rd:
|
|
113
|
+
if row.get("medium") == medium_name and row.get("compound"):
|
|
114
|
+
comps.add(row["compound"].strip())
|
|
115
|
+
out = {}
|
|
116
|
+
for c in sorted(comps):
|
|
117
|
+
exid = "EX_" + c + "_e"
|
|
118
|
+
if exid in m.reactions:
|
|
119
|
+
out[exid] = default_lb
|
|
120
|
+
return out or _active_medium(m)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build(input_spec, name=None, medium=None, venv=None, out_dir=None, progress_path=None,
|
|
124
|
+
engine="carveme"):
|
|
125
|
+
"""主入口。engine:
|
|
126
|
+
carveme(默认): protein.faa -> carve(M9 gapfill) -> validate M9 -> 目标介质闭环
|
|
127
|
+
gapseq(M2) : genomic.fna -> WSL2 gapseq doall(30-60min,需探测 OK)-> 模型 -> 目标介质验证
|
|
128
|
+
"""
|
|
129
|
+
if progress_path is None:
|
|
130
|
+
progress_path = os.path.join(tempfile.gettempdir(), "gem_build.progress.jsonl")
|
|
131
|
+
_log(progress_path, {"event": "build_start", "input": input_spec, "engine": engine})
|
|
132
|
+
if engine == "gapseq":
|
|
133
|
+
return _build_gapseq(input_spec, name=name, medium=medium, out_dir=out_dir,
|
|
134
|
+
progress_path=progress_path)
|
|
135
|
+
proteins = resolve_input(input_spec, progress_path, engine="carveme")
|
|
136
|
+
name = name or os.path.splitext(os.path.basename(proteins))[0]
|
|
137
|
+
out_dir = out_dir or MODEL_ROOT
|
|
138
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
139
|
+
out_xml = os.path.join(out_dir, name + ".xml")
|
|
140
|
+
|
|
141
|
+
# 1) carve(自带 M9 gapfill,CarveMe 原生最小培养基)
|
|
142
|
+
st = time.time()
|
|
143
|
+
if not (os.path.exists(out_xml) and os.path.getmtime(out_xml) > os.path.getmtime(proteins)):
|
|
144
|
+
run_carve(proteins, out_xml, venv=venv, progress_path=progress_path,
|
|
145
|
+
gapfill_medium="M9")
|
|
146
|
+
else:
|
|
147
|
+
_log(progress_path, {"event": "carve_skip_cached"})
|
|
148
|
+
|
|
149
|
+
from silentio import silent_read_sbml
|
|
150
|
+
m1 = silent_read_sbml(out_xml)
|
|
151
|
+
med_m9 = _media_db_exchanges(m1, "M9") # 精确 M9 成分(非全开近似)
|
|
152
|
+
|
|
153
|
+
# 2) validate G1-G3:M9 介质(构建产物实测)
|
|
154
|
+
from validate import validate_model
|
|
155
|
+
rep_m9 = validate_model(out_xml, medium=med_m9, reference_growth=None)
|
|
156
|
+
g3_m9 = rep_m9["g3"]
|
|
157
|
+
_log(progress_path, {"event": "validate_m9", "overall": rep_m9["overall"],
|
|
158
|
+
"g3": g3_m9["status"], "growth": g3_m9.get("growth_medium"),
|
|
159
|
+
"exch": len(med_m9)})
|
|
160
|
+
|
|
161
|
+
# 3) 用户目标介质(可选):preset 展开 + resolve -> G3;FAIL 时 L1/L2 规则补洞闭环
|
|
162
|
+
target = None
|
|
163
|
+
user_rep = None
|
|
164
|
+
if medium:
|
|
165
|
+
from gapfind import resolve_medium, expand_medium
|
|
166
|
+
medium_exp, preset_used = expand_medium(medium)
|
|
167
|
+
resolved, unresolved = resolve_medium(m1, medium_exp)
|
|
168
|
+
_log(progress_path, {"event": "target_medium", "preset": preset_used,
|
|
169
|
+
"resolved": len(resolved), "unresolved": unresolved})
|
|
170
|
+
user_rep = validate_model(out_xml, medium=resolved, reference_growth=None)
|
|
171
|
+
g3_user = user_rep["g3"]
|
|
172
|
+
gapfixes = []
|
|
173
|
+
if g3_user["status"] == "FAIL" and resolved:
|
|
174
|
+
_log(progress_path, {"event": "gapfill_start"})
|
|
175
|
+
from gapfill import apply_fixes
|
|
176
|
+
gf = apply_fixes(out_xml, medium=resolved, max_add=20,
|
|
177
|
+
out=out_xml[:-4] + "_gf.xml")
|
|
178
|
+
gapfixes = gf.get("applied", [])
|
|
179
|
+
if gapfixes:
|
|
180
|
+
user_rep = validate_model(gf["out"], medium=resolved, reference_growth=None)
|
|
181
|
+
out_xml = gf["out"]
|
|
182
|
+
g3_user = user_rep["g3"]
|
|
183
|
+
_log(progress_path, {"event": "gapfill_done", "applied": len(gapfixes),
|
|
184
|
+
"g3_after": g3_user["status"],
|
|
185
|
+
"growth_after": g3_user.get("growth_medium")})
|
|
186
|
+
target = {
|
|
187
|
+
"medium": medium, "resolved_exchanges": len(resolved),
|
|
188
|
+
"unresolved": unresolved,
|
|
189
|
+
"g3": user_rep["g3"]["status"],
|
|
190
|
+
"growth": user_rep["g3"].get("growth_medium"),
|
|
191
|
+
"gapfixes_applied": len(gapfixes if 'gapfixes' in dir() else []),
|
|
192
|
+
}
|
|
193
|
+
# 若 M9 已 PASS 而用户介质 FAIL:诚实保留(模型可用介质=M9)
|
|
194
|
+
dt = round(time.time() - st, 1)
|
|
195
|
+
# 4) 模型卡(schema v2 起步:supported_mediums 由验证结果得出)
|
|
196
|
+
supported = [
|
|
197
|
+
{"medium_name": "M9", "ex_reactions": sorted(med_m9),
|
|
198
|
+
"growth_rate": g3_m9.get("growth_medium"), "units": "mmol/gDW/h",
|
|
199
|
+
"validation_status": "verified_G3" if g3_m9.get("status") == "PASS" else "unverified"},
|
|
200
|
+
]
|
|
201
|
+
if target and target.get("g3") == "PASS":
|
|
202
|
+
tname = "custom"
|
|
203
|
+
if isinstance(medium, dict):
|
|
204
|
+
tname = medium.get("medium_name") or "custom"
|
|
205
|
+
supported.append({
|
|
206
|
+
"medium_name": tname, "ex_reactions": target.get("resolved_exchanges"),
|
|
207
|
+
"growth_rate": target.get("growth"), "units": "mmol/gDW/h",
|
|
208
|
+
"validation_status": "verified_G3_G4" if target.get("gapfixes_applied", 0) == 0 else "verified_G3_only",
|
|
209
|
+
})
|
|
210
|
+
# 模型卡(schema v2:init_card 统一基座 —— lineage v0.1.0 起始 + changelog=[build])
|
|
211
|
+
from model_card import init_card
|
|
212
|
+
card, card_path = init_card(
|
|
213
|
+
out_xml, name=name, engine="carveme", changelog_note="build",
|
|
214
|
+
engine_version=_carve_version(venv),
|
|
215
|
+
carve_cmd="carve INPUT -o OUT -g M9",
|
|
216
|
+
started=datetime.datetime.now().isoformat(timespec="seconds"),
|
|
217
|
+
elapsed_s=dt, model=out_xml,
|
|
218
|
+
validations_m9={k: rep_m9[k]["status"] for k in ("g1", "g2", "g3")},
|
|
219
|
+
growth_g3_m9=g3_m9.get("growth_medium"),
|
|
220
|
+
m9_exchanges=len(med_m9),
|
|
221
|
+
target=target,
|
|
222
|
+
supported_mediums=supported,
|
|
223
|
+
mapping={"protein_input": proteins})
|
|
224
|
+
_log(progress_path, {"event": "build_done", "model": out_xml, "card": card_path, "s": dt})
|
|
225
|
+
return {"model": out_xml, "card": card_path,
|
|
226
|
+
"validations_m9": card["validations_m9"],
|
|
227
|
+
"growth_g3_m9": card["growth_g3_m9"],
|
|
228
|
+
"target": target or {"note": "no target medium provided"},
|
|
229
|
+
"elapsed_s": dt}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_gapseq(input_fna, name=None, medium=None, out_dir=None, progress_path=None):
|
|
233
|
+
"""gapseq 引擎:WSL2 桥 doall(30-60min)→ 模型拷回 → 目标介质验证闭环 → 模型卡。"""
|
|
234
|
+
from gapseq_wsl import probe, run_gapseq
|
|
235
|
+
from silentio import silent_read_sbml
|
|
236
|
+
from validate import validate_model
|
|
237
|
+
from gapfind import expand_medium, resolve_medium
|
|
238
|
+
|
|
239
|
+
_log(progress_path, {"event": "gapseq_probe"})
|
|
240
|
+
p = probe()
|
|
241
|
+
if not p.get("capable"):
|
|
242
|
+
_log(progress_path, {"event": "gapseq_unavailable", "level": p.get("level"),
|
|
243
|
+
"detail": (p.get("detail") or "")[:300]})
|
|
244
|
+
raise RuntimeError(
|
|
245
|
+
f"gapseq 引擎不可用(level={p.get('level')}):{(p.get('detail') or '')[:300]}。"
|
|
246
|
+
"请先配置 WSL2 + gapseq 环境,或改用 carveme 引擎。")
|
|
247
|
+
|
|
248
|
+
input_fna = resolve_input(input_fna, progress_path, engine="gapseq")
|
|
249
|
+
name = name or os.path.splitext(os.path.basename(input_fna))[0]
|
|
250
|
+
out_dir = out_dir or MODEL_ROOT
|
|
251
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
252
|
+
_log(progress_path, {"event": "gapseq_start", "note": "doall 30-60min,后台等待不误判超时"})
|
|
253
|
+
st = time.time()
|
|
254
|
+
model = None
|
|
255
|
+
try:
|
|
256
|
+
model, log_tail = run_gapseq(input_fna, out_dir, name=name,
|
|
257
|
+
progress=(lambda ev: _log(progress_path, ev)))
|
|
258
|
+
except Exception as e:
|
|
259
|
+
_log(progress_path, {"event": "gapseq_fail", "err": str(e)[:300]})
|
|
260
|
+
raise
|
|
261
|
+
dt = round(time.time() - st, 1)
|
|
262
|
+
_log(progress_path, {"event": "gapseq_done", "model": model, "s": dt})
|
|
263
|
+
|
|
264
|
+
# 目标介质验证(gapseq 模型用 AB 自然名/用户 medium;无 medium 时 M9 兜底)
|
|
265
|
+
target = None
|
|
266
|
+
med_exp, preset_used = expand_medium(medium) if medium else ({}, None)
|
|
267
|
+
if medium:
|
|
268
|
+
m1 = silent_read_sbml(model)
|
|
269
|
+
resolved, unresolved = resolve_medium(m1, med_exp)
|
|
270
|
+
rep = validate_model(model, medium=resolved, reference_growth=None)
|
|
271
|
+
g3 = rep["g3"]
|
|
272
|
+
gapfixes = []
|
|
273
|
+
if g3["status"] == "FAIL" and resolved:
|
|
274
|
+
_log(progress_path, {"event": "gapseq_gapfill_start"})
|
|
275
|
+
from gapfill import apply_fixes
|
|
276
|
+
gf = apply_fixes(model, medium=resolved, max_add=20,
|
|
277
|
+
out=model[:-4] + "_gf.xml")
|
|
278
|
+
gapfixes = gf.get("applied", [])
|
|
279
|
+
if gapfixes:
|
|
280
|
+
model = gf["out"]
|
|
281
|
+
rep = validate_model(model, medium=resolved, reference_growth=None)
|
|
282
|
+
g3 = rep["g3"]
|
|
283
|
+
_log(progress_path, {"event": "gapseq_gapfill_done", "applied": len(gapfixes),
|
|
284
|
+
"g3_after": g3["status"]})
|
|
285
|
+
target = {"medium": medium, "preset": preset_used, "g3": g3["status"],
|
|
286
|
+
"growth": g3.get("growth_medium"), "unresolved": unresolved,
|
|
287
|
+
"gapfixes_applied": len(gapfixes)}
|
|
288
|
+
else:
|
|
289
|
+
_log(progress_path, {"event": "gapseq_no_medium"})
|
|
290
|
+
rep = None
|
|
291
|
+
|
|
292
|
+
from model_card import init_card
|
|
293
|
+
card, card_path = init_card(
|
|
294
|
+
model, name=name, engine="gapseq", changelog_note="build",
|
|
295
|
+
engine_version=p.get("gapseq_version"),
|
|
296
|
+
gapseq_probe=p.get("level"),
|
|
297
|
+
started=datetime.datetime.now().isoformat(timespec="seconds"),
|
|
298
|
+
elapsed_s=dt, model=model,
|
|
299
|
+
validations={k: rep[k]["status"] for k in ("g1", "g2", "g3")} if rep else None,
|
|
300
|
+
growth_g3=rep["g3"].get("growth_medium") if rep else None,
|
|
301
|
+
target=target,
|
|
302
|
+
mapping={"genome_input": input_fna})
|
|
303
|
+
_log(progress_path, {"event": "build_done", "model": model, "card": card_path, "s": dt})
|
|
304
|
+
return {"model": model, "card": card_path,
|
|
305
|
+
"validations": card["validations"], "growth_g3": card["growth_g3"],
|
|
306
|
+
"target": target or {"note": "no target medium provided"},
|
|
307
|
+
"engine": "gapseq", "elapsed_s": dt}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _carve_version(venv=None):
|
|
311
|
+
try:
|
|
312
|
+
venv = venv or DEFAULT_CARVE_VENV
|
|
313
|
+
py = os.path.join(venv, "Scripts", "python.exe")
|
|
314
|
+
r = subprocess.run([py, "-c",
|
|
315
|
+
"import importlib.metadata as im; print(im.version('carveme'))"],
|
|
316
|
+
capture_output=True, text=True, timeout=60)
|
|
317
|
+
return (r.stdout or "").strip() or "unknown"
|
|
318
|
+
except Exception:
|
|
319
|
+
return "unknown"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
import argparse
|
|
324
|
+
ap = argparse.ArgumentParser()
|
|
325
|
+
ap.add_argument("--input", required=True)
|
|
326
|
+
ap.add_argument("--name")
|
|
327
|
+
ap.add_argument("--engine", default="carveme", choices=["carveme", "gapseq"])
|
|
328
|
+
ap.add_argument("--medium-json")
|
|
329
|
+
ap.add_argument("--out-dir")
|
|
330
|
+
ap.add_argument("--progress")
|
|
331
|
+
a = ap.parse_args()
|
|
332
|
+
medium = json.loads(a.medium_json) if a.medium_json else None
|
|
333
|
+
try:
|
|
334
|
+
res = build(a.input, name=a.name, medium=medium, out_dir=a.out_dir,
|
|
335
|
+
progress_path=a.progress, engine=a.engine)
|
|
336
|
+
print(json.dumps({"ok": True, "result": res}, ensure_ascii=False))
|
|
337
|
+
except Exception as e:
|
|
338
|
+
import traceback
|
|
339
|
+
sys.stderr.write("Traceback (most recent call last):\n")
|
|
340
|
+
traceback.print_exc(file=sys.stderr)
|
|
341
|
+
print(json.dumps({"ok": True, "result": None,
|
|
342
|
+
"error_hint": f"build failed: {type(e).__name__}: {e}"},
|
|
343
|
+
ensure_ascii=False))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# build_whitelist.py — B' 白名单 B0/B1:gapseq rxn 库 -> 反应白名单(本地基准,license 守则:不分发)
|
|
2
|
+
# B0: 聚合 rxn/*.fasta(非空)→ rxn_proteins.fa + mapping(序列header -> 反应ID)
|
|
3
|
+
# B1: diamond makedb + blastp 目标物种 faa → 命中反应集(EVIDENCE_sequence 候选池)
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
SEQDB = os.environ.get("GEM_GAPSEQ_DB", r"F:\Datasets\gapseq\db\Bacteria")
|
|
9
|
+
DIAMOND = r"C:\Users\shuai\.dsh\dsh-bio-gem\venv-carveme\Scripts\diamond.exe"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_rxn_fasta(out_fa, out_map=None, min_size=100):
|
|
13
|
+
"""聚合 rxn/ 非空文件(文件名=反应 ID)→ 序列 fasta + mapping。返回 (seqs, files_used)。"""
|
|
14
|
+
rxn_dir = os.path.join(SEQDB, "rxn")
|
|
15
|
+
n_seq = 0
|
|
16
|
+
n_file = 0
|
|
17
|
+
with open(out_fa, "w", encoding="utf-8", errors="ignore") as fo:
|
|
18
|
+
if out_map:
|
|
19
|
+
fm = open(out_map, "w", encoding="utf-8")
|
|
20
|
+
fm.write("reaction_id\tseq_id\n")
|
|
21
|
+
for fn in os.listdir(rxn_dir):
|
|
22
|
+
if not fn.endswith(".fasta"):
|
|
23
|
+
continue
|
|
24
|
+
p = os.path.join(rxn_dir, fn)
|
|
25
|
+
if os.path.getsize(p) < min_size:
|
|
26
|
+
continue
|
|
27
|
+
rxn_id = fn[:-6] # 文件名去 .fasta -> 反应 ID
|
|
28
|
+
with open(p, encoding="utf-8", errors="ignore") as f:
|
|
29
|
+
cur = None
|
|
30
|
+
buf = []
|
|
31
|
+
for line in f:
|
|
32
|
+
line = line.rstrip("\n")
|
|
33
|
+
if line.startswith(">"):
|
|
34
|
+
if cur is not None:
|
|
35
|
+
fo.write(f">{cur}\n{''.join(buf)}\n")
|
|
36
|
+
if out_map:
|
|
37
|
+
fm.write(f"{rxn_id}\t{cur}\n")
|
|
38
|
+
n_seq += 1
|
|
39
|
+
cur = f"{rxn_id}|{line[1:].split()[0]}"
|
|
40
|
+
buf = []
|
|
41
|
+
elif line.strip():
|
|
42
|
+
buf.append(line.strip())
|
|
43
|
+
if cur is not None and buf:
|
|
44
|
+
fo.write(f">{cur}\n{''.join(buf)}\n")
|
|
45
|
+
if out_map:
|
|
46
|
+
fm.write(f"{rxn_id}\t{cur}\n")
|
|
47
|
+
n_seq += 1
|
|
48
|
+
n_file += 1
|
|
49
|
+
if out_map:
|
|
50
|
+
fm.close()
|
|
51
|
+
return n_seq, n_file
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def diamond_whitelist(faa, out_dir, db_path=None, rxn_fa=None, out_tsv=None,
|
|
55
|
+
evalue=1e-5, min_bitscore=60, max_target_seqs=5):
|
|
56
|
+
"""B1: diamond blastp 目标物种 faa vs rxn_all 数据库 -> 命中反应集。
|
|
57
|
+
db_path 缺省用 out_dir/rxn_all.dmnd(无则由 rxn_fa 建,rxn_fa 再缺则用 GEM_GAPSEQ_DB 的 B0 产物)。
|
|
58
|
+
返回 {"rxn_hits": {rxn_id: [seq_hit_desc...]}, "n_hits": N, "hits_tsv": path, "db": path}。
|
|
59
|
+
License 守则: rxn 库/命中集仅本地使用,不进 git/发布包(调用方负责落在 ~/.dsh 下)。"""
|
|
60
|
+
import subprocess
|
|
61
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
62
|
+
if not os.path.exists(DIAMOND):
|
|
63
|
+
raise FileNotFoundError(f"diamond not found: {DIAMOND}")
|
|
64
|
+
if db_path is None:
|
|
65
|
+
db_path = os.path.join(out_dir, "rxn_all.dmnd")
|
|
66
|
+
if rxn_fa is None:
|
|
67
|
+
rxn_fa = os.path.join(out_dir, "rxn_all.fa")
|
|
68
|
+
if not os.path.exists(db_path):
|
|
69
|
+
if not os.path.exists(rxn_fa):
|
|
70
|
+
# B0 现场聚合(SEQDB rxn/ 目录 -> rxn_all.fa)
|
|
71
|
+
n_seq, n_file = build_rxn_fasta(rxn_fa)
|
|
72
|
+
if n_seq == 0:
|
|
73
|
+
raise FileNotFoundError(f"no rxn fasta built from {SEQDB}/rxn (GEM_GAPSEQ_DB?)")
|
|
74
|
+
rc, so, se, secs = subprocess_run([DIAMOND, "makedb", "--in", rxn_fa, "--db", db_path])
|
|
75
|
+
if rc != 0:
|
|
76
|
+
raise RuntimeError(f"diamond makedb failed rc={rc}: {se}")
|
|
77
|
+
if out_tsv is None:
|
|
78
|
+
out_tsv = os.path.join(out_dir, os.path.splitext(os.path.basename(faa))[0] + "_hits.tsv")
|
|
79
|
+
rc, so, se, secs = subprocess_run(
|
|
80
|
+
[DIAMOND, "blastp", "-d", db_path, "-q", faa, "-o", out_tsv,
|
|
81
|
+
"--evalue", str(evalue), "--max-target-seqs", str(max_target_seqs),
|
|
82
|
+
"--outfmt", "6", "qseqid", "sseqid", "pident", "evalue", "bitscore"],
|
|
83
|
+
timeout=3600)
|
|
84
|
+
if rc != 0:
|
|
85
|
+
raise RuntimeError(f"diamond blastp failed rc={rc}: {se}")
|
|
86
|
+
rxn_hits = {}
|
|
87
|
+
with open(out_tsv, encoding="utf-8", errors="ignore") as f:
|
|
88
|
+
for line in f:
|
|
89
|
+
p = line.rstrip("\r\n").split("\t")
|
|
90
|
+
if len(p) < 5:
|
|
91
|
+
continue
|
|
92
|
+
qseqid, sseqid = p[0], p[1]
|
|
93
|
+
try:
|
|
94
|
+
if float(p[4]) < min_bitscore:
|
|
95
|
+
continue
|
|
96
|
+
except ValueError:
|
|
97
|
+
continue
|
|
98
|
+
rxn_id = sseqid.split("|")[0].strip() # header 约定: RXNID|uniprot...
|
|
99
|
+
if not rxn_id:
|
|
100
|
+
continue
|
|
101
|
+
rxn_hits.setdefault(rxn_id, [])
|
|
102
|
+
if qseqid not in rxn_hits[rxn_id]:
|
|
103
|
+
rxn_hits[rxn_id].append(qseqid)
|
|
104
|
+
return {"rxn_hits": rxn_hits, "n_hits": len(rxn_hits),
|
|
105
|
+
"hits_tsv": out_tsv, "db": db_path, "evalue": evalue,
|
|
106
|
+
"min_bitscore": min_bitscore}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def subprocess_run(cmd, timeout=3600):
|
|
110
|
+
import subprocess
|
|
111
|
+
st = time.time()
|
|
112
|
+
r = subprocess.run(cmd, capture_output=True, timeout=timeout)
|
|
113
|
+
return r.returncode, (r.stdout or b"").decode("utf-8", "ignore")[-800:], (r.stderr or b"").decode("utf-8", "ignore")[-800:], round(time.time() - st, 1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
import json
|
|
118
|
+
fa = sys.argv[1] if len(sys.argv) > 1 else r"D:\Program\hermes\temp\gem_whitelist\rxn_all.fa"
|
|
119
|
+
out_dir = os.path.dirname(fa) or "."
|
|
120
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
121
|
+
map_p = os.path.join(out_dir, "rxn_map.tsv")
|
|
122
|
+
t0 = time.time()
|
|
123
|
+
n_seq, n_file = build_rxn_fasta(fa, map_p)
|
|
124
|
+
print(json.dumps({"seqs": n_seq, "files_used": n_file, "out_fa": fa,
|
|
125
|
+
"map": map_p, "elapsed_s": round(time.time() - t0, 1)},
|
|
126
|
+
ensure_ascii=False, indent=2))
|
|
127
|
+
print(f"文件大小: {os.path.getsize(fa)/1e6:.1f} MB")
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# double_knockout.py — 阶段C-C2 双敲 v1(合成致死预测,L2 非平凡)
|
|
2
|
+
# 候选池(预算可控):① GPR 结构先验——纯 or 型且恰 2 基因的反应 = 穷尽型同工酶对(廉价必做);
|
|
3
|
+
# ② FVA 预筛活性反应关联基因中"共享反应"的基因对(复用 essential_scan.prescreen_candidates +
|
|
4
|
+
# scan_essentiality;全扫受 max_pairs 预算上限,默认 5000,超限截断+报告)。
|
|
5
|
+
# 判定:单敲双活(>EPS)且双敲死(<=EPS)→ 合成致死对。单敲生长值按对惰性计算并缓存。
|
|
6
|
+
# 假设声明(方案文件要求,内置于输出与 description):细菌双敲验证率无大规模实验数据支撑,
|
|
7
|
+
# 本结果=假设生成,供实验设计参考非结论。
|
|
8
|
+
# 退化护栏(阶段 A/B 教训):wt<=EPS(介质下不生长)→ 不扫描不登记账本,degenerate:true + 介质适配提示。
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import csv
|
|
13
|
+
import time
|
|
14
|
+
import json
|
|
15
|
+
from itertools import combinations
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
18
|
+
|
|
19
|
+
from silentio import silent_read_sbml
|
|
20
|
+
from essential_scan import setup_model_medium, scan_essentiality, EPS
|
|
21
|
+
|
|
22
|
+
ASSUMPTION_NOTE = "细菌双敲验证率无大规模实验数据支撑,本结果=假设生成,供实验设计参考非结论"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _gpr_or_pairs(m):
|
|
26
|
+
"""穷尽型 or 同工酶对:纯 or GPR 且恰 2 个不同基因的反应。返回去重 {(a,b): [rxn...]}(a<b)。"""
|
|
27
|
+
out = {}
|
|
28
|
+
for r in m.reactions:
|
|
29
|
+
gpr = str(r.gpr or "").strip()
|
|
30
|
+
if not gpr or " and " in gpr.lower():
|
|
31
|
+
continue
|
|
32
|
+
genes = sorted({g.strip() for g in re.split(r"\bor\b", gpr, flags=re.I) if g.strip()})
|
|
33
|
+
if len(genes) == 2:
|
|
34
|
+
out.setdefault((genes[0], genes[1]), []).append(r.id)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _double_growth(m, ga, gb):
|
|
39
|
+
with m:
|
|
40
|
+
m.genes.get_by_id(ga).knock_out()
|
|
41
|
+
m.genes.get_by_id(gb).knock_out()
|
|
42
|
+
v = m.optimize().objective_value
|
|
43
|
+
return round(float(v), 6) if (v is not None and v == v) else 0.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _single_growth(m, gid):
|
|
47
|
+
with m:
|
|
48
|
+
m.genes.get_by_id(gid).knock_out()
|
|
49
|
+
v = m.optimize().objective_value
|
|
50
|
+
return round(float(v), 6) if (v is not None and v == v) else 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def double_knockout(model_path, medium=None, max_pairs=5000, export_csv=None,
|
|
54
|
+
ledger_refs=True, ledger_path=None, progress=None):
|
|
55
|
+
log = progress or (lambda s: sys.stderr.write(str(s) + "\n"))
|
|
56
|
+
medium = medium or {"medium_name": "AB"}
|
|
57
|
+
t0 = time.time()
|
|
58
|
+
m = silent_read_sbml(model_path)
|
|
59
|
+
resolved, unresolved, preset = setup_model_medium(m, medium)
|
|
60
|
+
with m:
|
|
61
|
+
wt = m.optimize().objective_value
|
|
62
|
+
wt = round(float(wt), 6) if wt is not None else 0.0
|
|
63
|
+
log(f"[dk] {model_path} medium={medium} wt={wt} max_pairs={max_pairs}")
|
|
64
|
+
|
|
65
|
+
out = {"model": model_path, "medium": medium, "medium_preset": preset,
|
|
66
|
+
"wt_growth": wt, "units": "mmol/gDW/h", "assumption_note": ASSUMPTION_NOTE,
|
|
67
|
+
"max_pairs": max_pairs, "eps": EPS,
|
|
68
|
+
"degenerate": wt <= EPS}
|
|
69
|
+
if out["degenerate"]:
|
|
70
|
+
out["degenerate_note"] = (f"wt_growth={wt}<=EPS:被测模型在指定介质下不生长,双敲判定无意义,"
|
|
71
|
+
"未扫描、未登记账本。提示:内置介质预设为根瘤菌科(C58)调校,"
|
|
72
|
+
"非根瘤菌模型需先做介质适配(阶段B-B3 molybdate 教训)。")
|
|
73
|
+
try:
|
|
74
|
+
from benchmark import medium_adaptation_hints
|
|
75
|
+
out["medium_adaptation_hints"] = medium_adaptation_hints(model_path, medium)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
sys.stderr.write(f"[dk] hints WARN: {type(e).__name__}: {e}" + "\n")
|
|
78
|
+
log(f"[dk] DEGENERATE wt={wt} <= EPS:不扫描不登记")
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
# 1) 全量必需性扫描(复用 essential_scan 核心):essential 集 + FVA 活性候选基因
|
|
82
|
+
t_scan = time.time()
|
|
83
|
+
scan = scan_essentiality(m, return_candidates=True)
|
|
84
|
+
essential = set(scan["essential_genes"])
|
|
85
|
+
alive = set(scan["candidate_genes"]) - essential
|
|
86
|
+
log(f"[dk] scan done ({round(time.time()-t_scan,1)}s): tested={scan['tested_genes']} "
|
|
87
|
+
f"essential={len(essential)} alive_singles={len(alive)}")
|
|
88
|
+
|
|
89
|
+
# 2) 候选池
|
|
90
|
+
prior = _gpr_or_pairs(m)
|
|
91
|
+
pool = [] # (a, b, rationale)
|
|
92
|
+
seen = set()
|
|
93
|
+
for (a, b), rxns in sorted(prior.items()):
|
|
94
|
+
if a in alive and b in alive:
|
|
95
|
+
pool.append((a, b, f"GPR先验(穷尽型or同工酶,反应:{','.join(sorted(rxns)[:3])})"))
|
|
96
|
+
seen.add((a, b))
|
|
97
|
+
n_prior = len(pool)
|
|
98
|
+
# ② 共享反应的存活基因对(E4c 先验排序:共享活性反应数降序——冗余暴露越多 SL 先验越高,
|
|
99
|
+
# 字母序破平;max_pairs 截断从"字母序前 N"变为"先验驱动 top-N")
|
|
100
|
+
budget = max_pairs - n_prior
|
|
101
|
+
scan_pairs_total = 0
|
|
102
|
+
active_rxns = scan.get("active_rxn_ids") or []
|
|
103
|
+
if budget > 0:
|
|
104
|
+
shared_count = {}
|
|
105
|
+
for rid in sorted(active_rxns):
|
|
106
|
+
gset = sorted(g for g in (x.id for x in m.reactions.get_by_id(rid).genes)
|
|
107
|
+
if g in alive)
|
|
108
|
+
for a, b in combinations(gset, 2):
|
|
109
|
+
if (a, b) not in seen:
|
|
110
|
+
shared_count[(a, b)] = shared_count.get((a, b), 0) + 1
|
|
111
|
+
shared = sorted(shared_count, key=lambda p: (-shared_count[p], p[0], p[1]))
|
|
112
|
+
scan_pairs_total = len(shared)
|
|
113
|
+
for a, b in shared[:budget]:
|
|
114
|
+
pool.append((a, b, f"全扫(共享活性反应×{shared_count[(a, b)]})"))
|
|
115
|
+
truncated = scan_pairs_total > max(0, budget)
|
|
116
|
+
log(f"[dk] pool: prior={n_prior} scan_total={scan_pairs_total} "
|
|
117
|
+
f"scan_tested={min(scan_pairs_total, max(0, budget))} truncated={truncated}")
|
|
118
|
+
|
|
119
|
+
# 3) 逐对判定:双敲 LP;SL -> 补算两个单敲值(缓存)
|
|
120
|
+
results = []
|
|
121
|
+
singles = {}
|
|
122
|
+
tested = 0
|
|
123
|
+
for a, b, rationale in pool:
|
|
124
|
+
tested += 1
|
|
125
|
+
gd = _double_growth(m, a, b)
|
|
126
|
+
if gd <= EPS:
|
|
127
|
+
for g in (a, b):
|
|
128
|
+
if g not in singles:
|
|
129
|
+
singles[g] = _single_growth(m, g)
|
|
130
|
+
results.append({"pair": [a, b], "single_a_growth": singles[a],
|
|
131
|
+
"single_b_growth": singles[b], "double_growth": gd,
|
|
132
|
+
"rationale": rationale, "source": "gem_double_knockout"})
|
|
133
|
+
log(f"[dk] SL {a} x {b} (double={gd}, {rationale[:24]})")
|
|
134
|
+
if tested % 500 == 0:
|
|
135
|
+
log(f"[dk] tested {tested}/{len(pool)}, SL so far {len(results)}")
|
|
136
|
+
|
|
137
|
+
# 4) 账本登记(每对一条 type=synthetic_lethal;幂等)
|
|
138
|
+
reg = None
|
|
139
|
+
if ledger_refs and results:
|
|
140
|
+
try:
|
|
141
|
+
import ledger as _ledger
|
|
142
|
+
from model_card import load_card as _load_card
|
|
143
|
+
lineage_v = ((_load_card(model_path) or {}).get("model_lineage") or {}).get("version")
|
|
144
|
+
cond = preset or (f"custom({len(resolved)} EX)" if resolved else "unspecified")
|
|
145
|
+
pair_rows = [{"gene_a": r["pair"][0], "gene_b": r["pair"][1]} for r in results]
|
|
146
|
+
reg = _ledger.register_synthetic_lethal(model_path, pair_rows, condition=cond,
|
|
147
|
+
lineage_version=lineage_v, path=ledger_path)
|
|
148
|
+
log(f"[dk] ledger: appended={reg['appended']} skipped={reg['skipped_duplicates']}")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
sys.stderr.write(f"[dk] ledger registration WARN: {type(e).__name__}: {e}\n")
|
|
151
|
+
|
|
152
|
+
out.update({
|
|
153
|
+
"genes_tested_single": scan["tested_genes"],
|
|
154
|
+
"essential_count": len(essential),
|
|
155
|
+
"alive_singles": len(alive),
|
|
156
|
+
"pairs_tested": tested,
|
|
157
|
+
"pairs_found": len(results),
|
|
158
|
+
"scan_ordering": "shared_rxn_count_desc_then_alpha (E4c)",
|
|
159
|
+
"budget": {"prior_pairs": n_prior, "scan_pairs_total": scan_pairs_total,
|
|
160
|
+
"scan_pairs_tested": min(scan_pairs_total, max(0, budget)),
|
|
161
|
+
"truncated": truncated},
|
|
162
|
+
"results": results,
|
|
163
|
+
"timing_seconds": round(time.time() - t0, 1),
|
|
164
|
+
})
|
|
165
|
+
if reg is not None:
|
|
166
|
+
out["ledger_registration"] = reg
|
|
167
|
+
if export_csv:
|
|
168
|
+
n = _export_csv(export_csv, results, out)
|
|
169
|
+
out["export_csv"] = export_csv
|
|
170
|
+
out["export_csv_rows"] = n
|
|
171
|
+
out["export_csv_bytes"] = os.path.getsize(export_csv)
|
|
172
|
+
log(f"[dk] CSV {export_csv}: {n} rows")
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _export_csv(path, results, out):
|
|
177
|
+
n = 0
|
|
178
|
+
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
|
179
|
+
w = csv.writer(f)
|
|
180
|
+
w.writerow(["# assumption", out["assumption_note"]])
|
|
181
|
+
w.writerow(["gene_a", "gene_b", "single_a_growth", "single_b_growth",
|
|
182
|
+
"double_growth", "rationale", "source"])
|
|
183
|
+
for r in results:
|
|
184
|
+
w.writerow([r["pair"][0], r["pair"][1], r["single_a_growth"],
|
|
185
|
+
r["single_b_growth"], r["double_growth"], r["rationale"], r["source"]])
|
|
186
|
+
n += 1
|
|
187
|
+
return n
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
args = {}
|
|
192
|
+
if len(sys.argv) > 1:
|
|
193
|
+
with open(sys.argv[1], encoding="utf-8") as f:
|
|
194
|
+
args = json.load(f)
|
|
195
|
+
elif not sys.stdin.isatty():
|
|
196
|
+
args = json.loads(sys.stdin.read())
|
|
197
|
+
a = args.get("args", args)
|
|
198
|
+
print(json.dumps({"ok": True, "result": double_knockout(
|
|
199
|
+
a.get("model"), medium=a.get("medium"), max_pairs=a.get("max_pairs", 5000),
|
|
200
|
+
export_csv=a.get("export_csv"), ledger_refs=a.get("ledger_refs", True),
|
|
201
|
+
ledger_path=a.get("ledger_path"))}, ensure_ascii=False))
|