# 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)