pipeline-bidding/wwwroot/api/bid_task_tree.dspy

380 lines
18 KiB
Plaintext
Raw 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',
}
_state_zh = {
'completed': '已完成', 'approved': '已批准', 'running': '运行中',
'failed': '失败', 'waiting': '等待中', 'submitted': '已提交',
'review': '审核中', 'rejected': '已驳回', 'paused': '已暂停',
'cancelled': '已取消', 'qc_review': '审核中',
}
# 投标产线角色分组(按业务流转顺序)
_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:
# ── 解析当前投标项目(三级兜底) ──
pid = ''
# 1) 投标产线默认会话的项目绑定
recs = await sor.sqlExe(
"SELECT current_project_id FROM pipeline_session_settings "
"WHERE user_id=${u}$ AND session_id='default_bidding_general' LIMIT 1", {"u": uid})
if recs:
pid = getattr(recs[0], 'current_project_id', '') or ''
# 2) agent 全局设置
if not pid:
recs = await sor.sqlExe(
"SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$", {"u": uid})
if recs:
pid = getattr(recs[0], 'current_project_id', '') or ''
# 3) 最近一个进行中的投标项目
if not pid:
recs = await sor.sqlExe(
"SELECT id FROM sd_projects WHERE pipeline_id='bidding_general' "
"AND status='in_progress' ORDER BY updated_at DESC LIMIT 1")
if recs:
pid = getattr(recs[0], 'id', '') or ''
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':
groups = {}
for t in role_tasks:
key = (_params_of(t).get('analysis_dim') or '').strip() or '__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': '成本收益分析',
'__legacy__': '全量解析(维度拆分前)',
}
order = ['scoring', 'quals', 'reqs_outline', 'cost_benefit', '__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:
final_state = getattr(ts[-1], 'state', '') or ''
nodes.append({
"id": "grp:" + role + ":" + key,
"label": "{}{} 轮)最新[{}]".format(label, len(ts),
_state_zh.get(final_state, final_state)),
"is_leaf": False,
})
return json.dumps(nodes, ensure_ascii=False)
if role == 'agent.qc':
# QC 按审核记录分组(同一任务可多轮复审):一次过=叶子,多轮=组节点
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)
legacy = [t for t in role_tasks if getattr(t, 'id', '') not in reviewed_tids]
if legacy:
groups.setdefault('__legacy__', [])
qc_labels = {
'scoring_items': '评分项 QC', 'qualifications': '资质 QC',
'doc_requirements': '投标文件要求 QC', 'chapter_outline': '章节骨架 QC',
'cost_benefit': '成本收益分析 QC',
'__none__': 'QC 审核', '__legacy__': 'QC 审核(早期)',
}
order = ['scoring_items', 'qualifications', 'doc_requirements',
'chapter_outline', 'cost_benefit', '__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)
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':
# 一次过:叶子
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
nodes.append({
"id": "grp:" + role + ":" + key,
"label": "{}{} 轮)最新[{}]".format(label, n_rounds,
'通过' if last_passed else '未通过'),
"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 {}
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 []
if revs:
nodes = []
for rv in revs:
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,
})
return json.dumps(nodes, ensure_ascii=False)
# 无审核记录的早期 QC 任务:平铺
nodes = []
for t in all_tasks:
if (getattr(t, 'role', '') or '').strip() != role:
continue
if (getattr(t, 'parent_id', '') or '').strip():
continue
st = getattr(t, 'state', '') or ''
nodes.append({"id": getattr(t, 'id', ''),
"label": "{} [{}] {}".format(_state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st),
str(getattr(t, 'updated_at', '') or '')[5:16]),
"is_leaf": not (del_cnt.get(getattr(t, 'id', ''), 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() or '__legacy__'
if k == key:
ts.append(t)
ts.sort(key=lambda x: str(getattr(x, 'created_at', '') or ''))
nodes = []
for i, t in enumerate(ts):
tid = getattr(t, 'id', '')
st = getattr(t, 'state', '') or ''
upd = str(getattr(t, 'updated_at', '') or '')[5:16]
nodes.append({
"id": tid,
"label": "第%d轮 %s [%s] %s" % (i + 1, _state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st), upd),
"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)