pipeline-bidding/wwwroot/api/bid_task_tree.dspy
yumoqing 2fe7c784be feat(a3): 标准流程技术标模版裁剪链——bid_standard内置tech_analysis→tech_qc→template_confirm三段(与纯技术方案流程共用对账段,逻辑只此一份)
- bid_flow A3段: 链激活五条件(有招标文件+两阶段启用+骨架QC已过+technical章全pending+无kind='summary'摘要行),任一不满足整链跳过=存量行为(甘肃等已开写项目零影响);完成判定用摘要行(换代闭环:新招标文件reset清bid_tech_items+作废pending确认待办)
- C段门禁_tech_gate_on: 链激活只挡technical章,商务/报价章照常流转(HT_TEMPLATE_CONFIRM移出BLOCKING_HT_TYPES降级章节级)
- confirm_tech_template双模式分流: 有非technical章=标准流程只删建technical章+全序列重排order_no(走查修复:原min+idx顺延在新骨架章数多于原技术章时与后置商务/报价章撞号,合成ORDER BY会把报价章插进技术章中间);无=纯技术流程整表清落;重确认守卫收窄到待替换范围
- record_tech_summary: 裁剪摘要落bid_tech_items kind='summary'(六类覆盖+模版来源+章数),幂等覆盖,失败不阻塞确认
- _reset_analysis_for_new_file: bid_tech_items入清空表清单+作废pending骨架确认待办
- chapter_detail: technical章注入tech_summary+list_tech_items提示(老库无表静默降级)
- BID_FLOW_STAGES 14阶段: +tech_analysis(yes)/tech_qc(warn)/template_confirm(warn,dep=tech_analysis,裁头段必连带裁确认段)
- bid_task_tree: analyst加tech维度组/QC加tech_items组/DIM_QCS补tech
- README+DESIGN v1.6同步(3.7节A3设计+门禁表tech_template_confirm行)
2026-09-17 16:23:54 +08:00

528 lines
26 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# bid_task_tree.dspy - 投标产线任务树(懒加载,与开发产线 /task 同构)
# 树状结构:项目 → 阶段(角色分组) → 任务 → 步骤/交付件
# 协议:无 id → 根节点__root__ → 阶段role:xxx → 任务裸任务ID → 步骤+交付件
import json
uid = await get_user()
if not uid:
uid = 'user-01'
node_id = (params_kw or {}).get('id', '').strip()
dbname = get_module_dbname('pipeline-bidding')
_state_icons = {
'completed': '\u2705', 'approved': '\u2705', 'running': '\U0001f7e1',
'failed': '\u274c', 'waiting': '\u26a0\ufe0f', 'submitted': '\u2b1c',
'review': '\U0001f440', 'rejected': '\U0001f6ab', 'paused': '\u23f8\ufe0f',
'cancelled': '\U0001f6d1', 'qc_review': '\U0001f50d',
'qc_rejected': '\U0001f6ab', 'verify_failed': '\u26a0\ufe0f',
}
_state_zh = {
'completed': '已完成', 'approved': '已批准', 'running': '运行中',
'failed': '失败', 'waiting': '等待中', 'submitted': '已提交',
'review': '审核中', 'rejected': '已驳回', 'paused': '已暂停',
'cancelled': '已取消', 'qc_review': '审核中',
'qc_rejected': 'QC不通过', 'verify_failed': '落库核验不通过',
}
# 投标产线角色分组(按业务流转顺序)
_ROLE_GROUPS = [
('agent.tender_analyst', '\U0001f50d 招标解析'),
('agent.qc', '\U0001f4cb QC契合度审核'),
('agent.bid_prep', '\U0001f4c4 资料准备'),
('agent.bid_writer', '\u270d\ufe0f 技术章编写'),
('agent.bid_biz_writer', '\u270d\ufe0f 商务章编写'),
('agent.bid_reviewer', '\U0001f50e 章节评审'),
('agent.bid_compositor', '\U0001f4e6 标书合成'),
('agent.bid_scorer', '\U0001f3af 整书评分'),
('agent.pm', '\U0001f91d 项目管理'),
]
_ROLE_SET = set(r for r, _ in _ROLE_GROUPS)
async with DBPools().sqlorContext(dbname) as sor:
# ── 解析当前投标项目2026-09-11 收敛:统一走 get_session_project_id
# 含悬空自愈+产线隔离;删掉「最近一个进行中的投标项目」第三级兜底——
# 把别人的项目当当前项目违反「没项目要提示选择或新建」)──
_sid = (params_kw or {}).get('session_id', '') or ''
pid = ''
try:
from pipeline_service.workspace import get_session_project_id
pid = await get_session_project_id(sor, uid, _sid, 'bidding_general') or ''
except Exception:
pid = ''
if not pid:
if not node_id:
return json.dumps([{"id": "__root__", "label": "请先选择或新建投标项目", "is_leaf": True}], ensure_ascii=False)
return json.dumps([], ensure_ascii=False)
pname = ''
precs = await sor.sqlExe("SELECT name FROM sd_projects WHERE id=${p}$", {"p": pid})
if precs:
pname = getattr(precs[0], 'name', '') or ''
if not node_id:
# 根节点
return json.dumps([{"id": "__root__", "label": pname or "投标项目", "is_leaf": False}], ensure_ascii=False)
# 一次查全部任务 + 步骤数 + 交付件数项目内任务量不大Python 分组)
all_tasks = await sor.sqlExe(
"SELECT id, title, role, state, params, parent_id, created_at, updated_at "
"FROM pipeline_tasks WHERE tenant_id=${p}$ ORDER BY created_at ASC", {"p": pid}) or []
step_cnt = {}
for r in await sor.sqlExe(
"SELECT task_id, COUNT(*) AS c FROM pipeline_task_steps "
"WHERE task_id IN (SELECT id FROM pipeline_tasks WHERE tenant_id=${p}$) GROUP BY task_id",
{"p": pid}) or []:
step_cnt[getattr(r, 'task_id', '')] = int(getattr(r, 'c', 0) or 0)
del_cnt = {}
for r in await sor.sqlExe(
"SELECT task_id, COUNT(*) AS c FROM pipeline_deliverables WHERE project_id=${p}$ GROUP BY task_id",
{"p": pid}) or []:
del_cnt[getattr(r, 'task_id', '')] = int(getattr(r, 'c', 0) or 0)
if node_id == '__root__':
# 阶段列表(按角色分组,只列有任务的)
role_cnt = {}
for t in all_tasks:
if (getattr(t, 'parent_id', '') or '').strip():
continue
r = (getattr(t, 'role', '') or '').strip()
role_cnt[r] = role_cnt.get(r, 0) + 1
nodes = []
for rn, label in _ROLE_GROUPS:
if rn in role_cnt:
nodes.append({"id": "role:" + rn,
"label": "{} ({})".format(label, role_cnt[rn]), "is_leaf": False})
other_cnt = sum(v for k, v in role_cnt.items() if k not in _ROLE_SET)
if other_cnt > 0:
nodes.append({"id": "role:__other__",
"label": "\U0001f4ad 其他 ({})".format(other_cnt), "is_leaf": False})
if not nodes:
nodes = [{"id": "__empty__", "label": "暂无任务", "is_leaf": True}]
return json.dumps(nodes, ensure_ascii=False)
if node_id.startswith('role:'):
# 该角色分组下的任务。
# 维度型角色analyst 按 analysis_dim、qc 按 qc_type先按维度/类型聚合,
# 一次过=叶子节点;多轮=组节点子节点按轮次升序第1轮→第N轮
role = node_id[5:]
def _params_of(t):
try:
return json.loads(getattr(t, 'params', '') or '{}')
except Exception:
return {}
# QC 任务 → (qc_type, round) 映射review 表的 round 是权威轮次)
qc_map = {}
if role == 'agent.qc':
for r in await sor.sqlExe(
"SELECT task_id, qc_type, round FROM bid_qc_reviews WHERE project_id=${p}$",
{"p": pid}) or []:
qc_map[getattr(r, 'task_id', '')] = (getattr(r, 'qc_type', '') or '',
int(getattr(r, 'round', 0) or 0))
role_tasks = []
for t in all_tasks:
r = (getattr(t, 'role', '') or '').strip()
if role == '__other__':
if r in _ROLE_SET:
continue
elif r != role:
continue
if (getattr(t, 'parent_id', '') or '').strip():
continue
tid = getattr(t, 'id', '')
if not tid or not (getattr(t, 'title', '') or ''):
continue
role_tasks.append(t)
# ── 维度/类型分组 ──
if role == 'agent.tender_analyst':
# 各维度 QC 审核:最大轮次 + 最新一轮结果(组节点标签用,
# 轮次以审核记录为准——人工修正任务产出的第4轮也计入
DIM_QCS = {'scoring': ('scoring_items',), 'quals': ('qualifications',),
'reqs_outline': ('doc_requirements', 'chapter_outline'),
'cost_benefit': ('cost_benefit',)}
qc_by_type = {}
for rv in await sor.sqlExe(
"SELECT qc_type, fit_score, passed, round FROM bid_qc_reviews "
"WHERE project_id=${p}$ ORDER BY qc_type, round DESC", {"p": pid}) or []:
qt = getattr(rv, 'qc_type', '') or ''
if qt not in qc_by_type:
qc_by_type[qt] = rv
groups = {}
for t in role_tasks:
key = (_params_of(t).get('analysis_dim') or '').strip()
if not key:
p = _params_of(t)
title = getattr(t, 'title', '') or ''
if p.get('qc_redo'):
key = '__legacy__'
elif '修正' in title or '补录' in title:
# 人工修正任务归入它修正的维度组(标题关键词定维度),
# 不再单独成组——第4轮产出在维度组里可见
if 'scoring' in title or '评分项' in title:
key = 'scoring'
elif 'qual' in title or '资质' in title:
key = 'quals'
elif 'cost' in title or '成本收益' in title:
key = 'cost_benefit'
else:
key = 'reqs_outline'
else:
key = '__legacy__'
groups.setdefault(key, []).append(t)
for key in groups:
groups[key].sort(key=lambda x: str(getattr(x, 'created_at', '') or ''))
dim_labels = {
'scoring': '评分项与得分规则', 'quals': '资质清单与要求',
'reqs_outline': '投标文件要求与章节骨架', 'cost_benefit': '成本收益分析',
'tech': '技术评估项(技术方案裁剪依据)',
'__legacy__': '全量解析(维度拆分前)',
}
order = ['scoring', 'quals', 'reqs_outline', 'cost_benefit', 'tech', '__legacy__']
nodes = []
for key in order + [k for k in groups if k not in order]:
if key not in groups:
continue
ts = groups[key]
label = dim_labels.get(key, key)
if len(ts) == 1:
t = ts[0]
st = getattr(t, 'state', '') or ''
nodes.append({
"id": getattr(t, 'id', ''),
"label": "{} [{}] {}".format(_state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st), label),
"is_leaf": not (step_cnt.get(getattr(t, 'id', ''), 0) > 0 or
del_cnt.get(getattr(t, 'id', ''), 0) > 0),
})
else:
# 轮次 = 该维度 QC 审核记录最大轮次(含人工修正触发的复审)
max_round = 0
lq = None
for qt in DIM_QCS.get(key, ()):
rv = qc_by_type.get(qt)
if rv:
rr = int(getattr(rv, 'round', 0) or 0)
if rr > max_round:
max_round = rr
lq = rv
if not max_round:
max_round = len(ts)
if lq:
lq_passed = str(getattr(lq, 'passed', '0')) == '1'
tail = "QC %.1f 分 %s" % (float(getattr(lq, 'fit_score', 0) or 0),
'通过' if lq_passed else '未通过')
else:
tail = "任务" + _state_zh.get(getattr(ts[-1], 'state', '') or '', '')
nodes.append({
"id": "grp:" + role + ":" + key,
"label": "{}{} 轮)最新[{}]".format(label, max_round, tail),
"is_leaf": False,
})
return json.dumps(nodes, ensure_ascii=False)
if role == 'agent.qc':
# QC 按审核记录分组(同一任务可多轮复审):一次过=叶子,多轮=组节点。
# 2026-09-03正在执行/还没审核的任务也是任务——无审核记录的 QC 任务
# 按 params.qc_types 归入对应类型组,组标签带在办数,展开可见。
revs = await sor.sqlExe(
"SELECT id, task_id, qc_type, round, fit_score, passed FROM bid_qc_reviews "
"WHERE project_id=${p}$ ORDER BY qc_type, round ASC", {"p": pid}) or []
groups = {}
for rv in revs:
groups.setdefault(getattr(rv, 'qc_type', '') or '__none__', []).append(rv)
reviewed_tids = set(getattr(rv, 'task_id', '') for rv in revs)
open_tasks = {}
for t in role_tasks:
if getattr(t, 'id', '') in reviewed_tids:
continue
st = (getattr(t, 'state', '') or '').strip()
if st in ('cancelled', 'failed'):
continue # 失败/取消不算在办
try:
qts = json.loads(getattr(t, 'params', '') or '{}').get('qc_types') or []
except Exception:
qts = []
if st in ('approved', 'completed'):
# 早期完成但无审核记录(记录被清理)→ 归遗留组,不算在办
open_tasks.setdefault('__legacy__', []).append(t)
continue
for qt in qts:
open_tasks.setdefault(qt, []).append(t)
if not qts:
open_tasks.setdefault('__legacy__', []).append(t)
for qt in open_tasks:
groups.setdefault(qt, [])
legacy = open_tasks.get('__legacy__', [])
qc_labels = {
'scoring_items': '评分项 QC', 'qualifications': '资质 QC',
'doc_requirements': '投标文件要求 QC', 'chapter_outline': '章节骨架 QC',
'cost_benefit': '成本收益分析 QC', 'tech_items': '技术评估项 QC',
'__none__': 'QC 审核', '__legacy__': 'QC 审核(早期)',
}
order = ['scoring_items', 'qualifications', 'doc_requirements',
'chapter_outline', 'cost_benefit', 'tech_items',
'__none__', '__legacy__']
nodes = []
for key in order + [k for k in groups if k not in order]:
if key not in groups:
continue
rs = groups[key]
label = qc_labels.get(key, key)
n_open = len(open_tasks.get(key, []))
if legacy and key == '__legacy__':
if not rs and legacy:
# 只有遗留任务无审核记录:多任务=组,单任务=叶
if len(legacy) == 1:
t = legacy[0]
st = getattr(t, 'state', '') or ''
nodes.append({"id": getattr(t, 'id', ''),
"label": "{} [{}] {}".format(_state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st), label),
"is_leaf": True})
else:
nodes.append({"id": "grp:" + role + ":" + key,
"label": "{}{} 个早期任务)".format(label, len(legacy)),
"is_leaf": False})
continue
if len(rs) == 1 and str(getattr(rs[0], 'passed', '0')) == '1' and not n_open:
# 一次过且无在办:叶子
t_id = getattr(rs[0], 'task_id', '')
sc = getattr(rs[0], 'fit_score', '')
nodes.append({
"id": t_id,
"label": "\u2705 [通过] {}{:.1f} 分)".format(label, float(sc or 0)),
"is_leaf": not (del_cnt.get(t_id, 0) > 0),
})
else:
n_rounds = len(rs) if rs else len(legacy)
last_passed = str(getattr(rs[-1], 'passed', '0')) == '1' if rs else False
tail = '通过' if last_passed else '未通过'
if n_open:
tail += ' + %d在办' % n_open
nodes.append({
"id": "grp:" + role + ":" + key,
"label": "{}{} 轮)最新[{}]".format(label, n_rounds, tail),
"is_leaf": False,
})
return json.dumps(nodes, ensure_ascii=False)
# ── 非维度型角色:平铺任务列表(原逻辑)──
nodes = []
for t in role_tasks:
tid = getattr(t, 'id', '')
title = getattr(t, 'title', '') or ''
state = getattr(t, 'state', '') or ''
icon = _state_icons.get(state, '\u2b1c')
zh = _state_zh.get(state, state)
has_child = step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0 or \
any((getattr(c, 'parent_id', '') or '').strip() == tid for c in all_tasks)
nodes.append({
"id": tid,
"label": "{} [{}] {}".format(icon, zh, title[:60]),
"is_leaf": not has_child,
})
return json.dumps(nodes, ensure_ascii=False)
if node_id.startswith('grp:'):
# 轮次子节点按轮数升序第1轮→第N轮
_, role, key = node_id.split(':', 2)
def _params_of2(t):
try:
return json.loads(getattr(t, 'params', '') or '{}')
except Exception:
return {}
# 维度→QC类型映射用于给 analyst 轮次挂该轮 QC 结果)
DIM_QC = {'scoring': 'scoring_items', 'quals': 'qualifications',
'reqs_outline': 'doc_requirements', 'cost_benefit': 'cost_benefit'}
if role == 'agent.qc':
# QC 组:按审核记录轮次升序展开(同一任务多轮复审各占一行),
# 末尾追加该类型正在执行/还没审核的任务(也是任务,不能藏)。
revs = await sor.sqlExe(
"SELECT id, task_id, qc_type, round, fit_score, passed, created_at "
"FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${k}$ "
"ORDER BY round ASC", {"p": pid, "k": key}) or []
nodes = []
reviewed_tids = set()
for rv in revs:
reviewed_tids.add(getattr(rv, 'task_id', ''))
rnd = int(getattr(rv, 'round', 0) or 0)
passed = str(getattr(rv, 'passed', '0')) == '1'
sc = float(getattr(rv, 'fit_score', 0) or 0)
upd = str(getattr(rv, 'created_at', '') or '')[5:16]
icon = '\u2705' if passed else '\U0001f6ab'
nodes.append({
"id": "rev:" + getattr(rv, 'id', ''),
"label": "第%d轮 %s [%s] %.1f 分 %s" % (
rnd, icon, '通过' if passed else '未通过', sc, upd),
"is_leaf": True,
})
for t in all_tasks:
if (getattr(t, 'role', '') or '').strip() != role:
continue
if (getattr(t, 'parent_id', '') or '').strip():
continue
tid = getattr(t, 'id', '')
if tid in reviewed_tids:
continue
st0 = (getattr(t, 'state', '') or '').strip()
try:
qts = json.loads(getattr(t, 'params', '') or '{}').get('qc_types') or []
except Exception:
qts = []
if key == '__legacy__':
# 遗留组:无审核记录的早期终态任务(在办任务归各自类型组)
if st0 in ('submitted', 'running', 'review', 'qc_review', 'waiting'):
continue
else:
if key not in qts:
continue
if st0 in ('cancelled', 'failed', 'approved', 'completed'):
continue # 终态且有审核记录的已在上面;无记录的归遗留组
st = st0
nodes.append({
"id": tid,
"label": "%s [%s] %s" % (_state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st),
(getattr(t, 'title', '') or '')[:40]),
"is_leaf": not (del_cnt.get(tid, 0) > 0),
})
return json.dumps(nodes, ensure_ascii=False)
# analyst 组展开:与分组分支同逻辑过滤(人工修正任务按标题归维度组)。
# 轮次:系统任务按时间区间匹配审核记录;人工修正任务取该维度最大轮次。
ts = []
for t in all_tasks:
if (getattr(t, 'role', '') or '').strip() != role:
continue
if (getattr(t, 'parent_id', '') or '').strip():
continue
k = (_params_of2(t).get('analysis_dim') or '').strip()
if not k:
p = _params_of2(t)
title = getattr(t, 'title', '') or ''
if p.get('qc_redo'):
k = '__legacy__'
elif '修正' in title or '补录' in title:
if 'scoring' in title or '评分项' in title:
k = 'scoring'
elif 'qual' in title or '资质' in title:
k = 'quals'
elif 'cost' in title or '成本收益' in title:
k = 'cost_benefit'
else:
k = 'reqs_outline'
else:
k = '__legacy__'
if k == key:
ts.append(t)
ts.sort(key=lambda x: str(getattr(x, 'created_at', '') or ''))
# reqs_outline 维度含两类审核记录,合并取每轮最大
DIM_QCS = {'scoring': ('scoring_items',), 'quals': ('qualifications',),
'reqs_outline': ('doc_requirements', 'chapter_outline'),
'cost_benefit': ('cost_benefit',), 'tech': ('tech_items',)}
rev_by_round = {}
for qt in DIM_QCS.get(key, ()):
for rv in await sor.sqlExe(
"SELECT round, fit_score, passed, created_at FROM bid_qc_reviews "
"WHERE project_id=${p}$ AND qc_type=${k}$ ORDER BY round ASC",
{"p": pid, "k": qt}) or []:
rr = int(getattr(rv, 'round', 0) or 0)
if rr not in rev_by_round:
rev_by_round[rr] = rv
nodes = []
used_rounds = set()
max_round = max(rev_by_round) if rev_by_round else 0
for i, t in enumerate(ts):
tid = getattr(t, 'id', '')
st = getattr(t, 'state', '') or ''
t_upd = str(getattr(t, 'updated_at', '') or '')
upd = t_upd[5:16]
nxt_created = str(getattr(ts[i + 1], 'created_at', '') or '9999') if i + 1 < len(ts) else '9999'
is_manual = not (_params_of2(t).get('analysis_dim') or '').strip() and \
not _params_of2(t).get('qc_redo') and \
('修正' in (getattr(t, 'title', '') or '') or '补录' in (getattr(t, 'title', '') or ''))
matched = None
if not is_manual:
for rnd, rv in rev_by_round.items():
if rnd in used_rounds:
continue
rv_t = str(getattr(rv, 'created_at', '') or '')
if t_upd and rv_t >= t_upd and rv_t < nxt_created:
matched = (rnd, rv)
break
if matched:
rnd, rv = matched
used_rounds.add(rnd)
passed = str(getattr(rv, 'passed', '0')) == '1'
sc = float(getattr(rv, 'fit_score', 0) or 0)
qc_label = "→ QC %.1f 分 %s" % (sc, '通过' if passed else '未通过')
lbl = "第%d轮 %s [任务%s] %s %s" % (
rnd, _state_icons.get(st, '\u2b1c'), _state_zh.get(st, st), qc_label, upd)
elif is_manual:
# 修正任务不挂轮次号(轮次以 QC 审核记录为准,见 QC 组)
lbl = "修正 %s [任务%s] %s" % (_state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st),
(getattr(t, 'title', '') or '')[:40])
else:
lbl = "第%d轮 %s [任务%s] → 未审核/审核记录未关联 %s" % (
i + 1, _state_icons.get(st, '\u2b1c'), _state_zh.get(st, st), upd)
nodes.append({
"id": tid,
"label": lbl,
"is_leaf": not (step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0),
})
return json.dumps(nodes, ensure_ascii=False)
# ── 裸任务 ID子任务 + 执行步骤 + 交付件 ──
nodes = []
for t in all_tasks:
if (getattr(t, 'parent_id', '') or '').strip() != node_id:
continue
tid = getattr(t, 'id', '')
title = getattr(t, 'title', '') or ''
state = getattr(t, 'state', '') or ''
if not tid or not title:
continue
icon = _state_icons.get(state, '\u2b1c')
zh = _state_zh.get(state, state)
nodes.append({"id": tid, "label": "{} [{}] {}".format(icon, zh, title[:60]),
"is_leaf": not (step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0)})
steps = await sor.sqlExe(
"SELECT id, step_name, display_name, state FROM pipeline_task_steps "
"WHERE task_id=${t}$ ORDER BY step_order ASC", {"t": node_id}) or []
for s in steps:
sid = getattr(s, 'id', '')
label = getattr(s, 'display_name', '') or getattr(s, 'step_name', '') or ''
st = getattr(s, 'state', '') or ''
if label:
nodes.append({"id": "step:" + sid,
"label": " {} [{}] \u2699\ufe0f {}".format(_state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st), label[:50]),
"is_leaf": True})
dels = await sor.sqlExe(
"SELECT id, deliverable_type, title FROM pipeline_deliverables "
"WHERE task_id=${t}$ ORDER BY created_at DESC", {"t": node_id}) or []
for d in dels:
did = getattr(d, 'id', '')
dt = getattr(d, 'deliverable_type', '') or ''
dtitle = getattr(d, 'title', '') or ''
nodes.append({"id": "del:" + did,
"label": " \U0001f4e4 {}: {}".format(dt, (dtitle or '')[:40]),
"is_leaf": True})
return json.dumps(nodes, ensure_ascii=False)