feat(bidding): 投标产线接入流程裁剪——BID_FLOW_STAGES声明11阶段(不可裁4/警告3/自由裁4+deps+roles);bid_flow对账器全流程计划门禁(被裁维度不派/qc裁剪视为通过/prep/章节评审直通approved/整书评分直通passed/交付直通completed+驳回暂停);bid_common OPEN_TASK_STATES含waiting(防拆解子任务重复派发)+DIM_TO_STAGE映射;dispatch_analysis_dim拒派被裁维度

This commit is contained in:
yumoqing 2026-09-11 12:58:26 +08:00
parent 011b3996c3
commit e5516bedf3
4 changed files with 227 additions and 12 deletions

View File

@ -11,7 +11,7 @@ import json
import logging
from pipeline_core import (
ToolDefinition, PipelineAbility, RoleSpec, register_ability,
ToolDefinition, PipelineAbility, RoleSpec, FlowStage, register_ability,
SlashCommand, register_slash_command,
)
@ -19,6 +19,53 @@ logger = logging.getLogger("pipeline.bidding.ability")
PIPELINE_ID = "bidding_general"
# ══════════════════ 预制流程阶段声明流程裁剪机制pipeline_flow_plans 消费)══════════════════
# trim: no=不可裁(流程不成立)/ warn=可裁但确认待办里给风险提示(质量门禁类)/ yes=自由裁
# deps 只写「硬依赖」裁了下游不成立QC 类门禁的放行语义在 bid_flow 里处理
#qc_analysis 被裁 → 章节依赖自动视为通过),不进 deps。
# roles 用于:① 确认时收敛取消被裁阶段在办任务(仅当角色不属于任何保留阶段);
# ② 对账器按阶段门禁派发。
BID_FLOW_STAGES = [
FlowStage(key="analysis_scoring", label="评分项与得分规则解析",
description="从招标文件抽取评分项、得分规则、否决项——全流程交叉核对基准",
trim="no", roles=["agent.tender_analyst"]),
FlowStage(key="analysis_quals", label="资质清单解析",
description="抽取招标所需资质与要求(商务章与资料准备的上游)",
trim="warn", warn_note="裁掉资质解析后,商务章编写与资质文件准备失去依据,商务分可能失分",
roles=["agent.tender_analyst"], deps=["analysis_scoring"]),
FlowStage(key="analysis_reqs_outline", label="投标文件要求与章节骨架",
description="抽取投标文件格式/结构要求并生成章节骨架(评分项挂载)——所有章节编写的依据",
trim="no", roles=["agent.tender_analyst"], deps=["analysis_scoring"]),
FlowStage(key="analysis_cost_benefit", label="成本收益分析",
description="投标成本与收益测算(保证金/预算/付款条件),报价章的上游",
trim="yes", roles=["agent.tender_analyst"],
deps=["analysis_scoring", "analysis_reqs_outline"]),
FlowStage(key="qc_analysis", label="解析产出QC契合度审核",
description="QC 对照招标文件原文逐类审核解析产出10分制不过退回重做",
trim="warn", warn_note="裁掉解析QC后评分项/资质/骨架的抽取错误将不经核对直接进入编写阶段,可能整章返工",
roles=["agent.qc"], deps=["analysis_scoring", "analysis_reqs_outline"]),
FlowStage(key="prep", label="投标资料准备",
description="知识库资质匹配 + 缺失资质的人类文件清单(只阻塞商务章)",
trim="yes", roles=["agent.bid_prep"], deps=["analysis_quals"]),
FlowStage(key="chapter_write", label="分章节编写",
description="按章节骨架逐章编写(商务/技术分角色、并发派发)",
trim="no", roles=["agent.bid_writer", "agent.bid_biz_writer"],
deps=["analysis_reqs_outline"]),
FlowStage(key="chapter_review", label="章节评审",
description="每章对照评分项/投标文件要求评审打分,不达标退回重写(章节级质量循环)",
trim="warn", warn_note="裁掉章节评审后,章节写完即通过,无质量把关,整书评分(若保留)大概率反复打回",
roles=["agent.bid_reviewer"], deps=["chapter_write"]),
FlowStage(key="compose", label="标书合成",
description="全部章节通过后合成完整标书md+docx",
trim="no", roles=["agent.bid_compositor"], deps=["chapter_write"]),
FlowStage(key="whole_score", label="整书评分(模拟评标)",
description="按评分项+得分规则给整书打分,不达标改进意见落到章节退回重写",
trim="yes", roles=["agent.bid_scorer"], deps=["compose"]),
FlowStage(key="delivery_confirm", label="交付确认",
description="人工复核用印/密封/份数/递交后确认交付,项目完成",
trim="yes", roles=[], deps=["compose"]),
]
# ══════════════════ 工具定义(主 agent / 驾驶舱)══════════════════
BID_TOOLS = [
@ -117,6 +164,17 @@ BID_PROMPT = """你是「投标产线」的驾驶舱 agent负责从招标文
- 用户问能得多少分 / 差在哪 bid_score_report + list_reviews讲清失分项与改进意见落到了哪个章节
- 用户要补资质/案例 add_kb_doc 录入公司投标知识库知识库缺的用 request_human_docs 让人工提供
## 流程裁剪2026-09-11 新增)
用户提出简化流程/跳过某阶段/只编写不评分这类要求时
1. show_flow_template 列出预制流程各阶段含可裁性
2. 把用户要求映射为 trim_keys要裁掉的阶段 key propose_flow_plan 生成裁剪草案
合法性由系统硬校验不可裁阶段/依赖断裂会被拒绝并说明原因你把原因如实转告用户
3. 草案生成后系统自动发流程裁剪确认待办**只有用户在待办里确认才生效你没有确认权**
告知用户去待办确认并如实转述 warn 级阶段的风险提示
4. 用户驳回 get_flow_plan 读驳回意见 按意见修订 trim_keys 重新 proposeversion 自动+1
5. 裁剪只在项目启动阶段有效已有任务产出结果后拒绝确认后不可改
裁剪被驳回期间对账器暂停派发收到驳回后**主动**按意见修订重提不要等用户催
## 硬规则
- 招标金额投标时间资质要求这些要素只能来自招标文件原文或人工确认**禁止编造**
抽取不到就如实说"文件未载明"必要时 ask_user
@ -446,6 +504,7 @@ def register_bid_ability():
system_prompt=BID_PROMPT,
handlers=handlers,
roles=BID_ROLES,
flow_stages=BID_FLOW_STAGES,
menus=[
{"label": "📄 招标文件", "icon": "", "url": "/pipeline-bidding/bid_tender_files/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},

View File

@ -37,11 +37,14 @@ HT_TENDER_FILE = "tender_file_upload"
HT_HUMAN_DOCS = "human_docs_request"
HT_QC_ESCALATION = "qc_escalation"
HT_DELIVERY_CONFIRM = "bid_delivery_confirm"
HT_FLOW_CONFIRM = "flow_plan_confirm" # 流程裁剪确认引擎通用类型pipeline_flow_plans 机制)
BLOCKING_HT_TYPES = (HT_TENDER_FILE,)
BLOCKING_HT_TYPES = (HT_TENDER_FILE, HT_FLOW_CONFIRM)
# 2026-09-03 起只保留「等招标文件」为全局阻塞:
# - HT_HUMAN_DOCS等人类资质文件降级为章节级依赖——只阻塞商务章等资料准备技术/报价章不受影响;
# - HT_QC_ESCALATIONQC 轮次用尽)降级为章节级依赖——只阻塞依赖该产出类型的章节,其余章节照常流转。
# 2026-09-11 增 HT_FLOW_CONFIRM裁剪计划待用户确认期间全局阻塞确认后才按计划派发
# 防「用户在确认、对账器按旧流程抢跑」)。驳回后的暂停由 bid_flow 查 pipeline_flow_plans 状态兜住。
# ── QC 契合度审核类型(解析产出逐类审核)──
QC_TYPE_SCORING = "scoring_items" # 评分项 + 得分规则
@ -60,8 +63,30 @@ QC_OUTPUT_TABLE = {
QC_TYPE_COST: "bid_cost_benefit",
}
# ── 流程裁剪pipeline_flow_plans 机制2026-09-11──
# 裁剪计划阶段 keybid_ability.BID_FLOW_STAGES 声明)→ 分析维度映射。
# 对账器/PM 编排据此判定某维度是否执行、被裁维度的 QC 依赖是否视为通过。
STAGE_ANALYSIS = "analysis_scoring"
STAGE_QUALS = "analysis_quals"
STAGE_REQS_OUTLINE = "analysis_reqs_outline"
STAGE_COST_BENEFIT = "analysis_cost_benefit"
STAGE_QC = "qc_analysis"
STAGE_PREP = "prep"
STAGE_CH_REVIEW = "chapter_review"
STAGE_WHOLE_SCORE = "whole_score"
STAGE_DELIVERY = "delivery_confirm"
DIM_TO_STAGE = {
"scoring": STAGE_ANALYSIS,
"quals": STAGE_QUALS,
"reqs_outline": STAGE_REQS_OUTLINE,
"cost_benefit": STAGE_COST_BENEFIT,
}
# 任务未终结状态(判断「是否已有在办任务」用)
OPEN_TASK_STATES = ("submitted", "running", "review", "qc_review")
# waiting 计入在办2026-09-11PM 复杂度拆解的子任务带 depends_on 时为 waiting
# 不算在办会导致对账器重复派发整维度任务/对半成品提前派 QC09-04 编排堆积同款事故模式)。
OPEN_TASK_STATES = ("submitted", "running", "review", "qc_review", "waiting")
# failed 任务单独处理:角色 agent 执行失败(如机构未配 LLM→ 对账器不重复造任务,抛人工介入
# ── 门限默认值(可用 appbase params 表覆盖)──

View File

@ -26,7 +26,9 @@ from .bid_common import (
DOC_DRAFT, DOC_REVIEWING, DOC_PASSED, DOC_REJECTED, DOC_DELIVERED,
HT_TENDER_FILE, HT_HUMAN_DOCS, HT_QC_ESCALATION, HT_DELIVERY_CONFIRM,
QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPE_COST, QC_TYPES,
QC_OUTPUT_TABLE,
QC_OUTPUT_TABLE, DIM_TO_STAGE,
STAGE_ANALYSIS, STAGE_QUALS, STAGE_REQS_OUTLINE, STAGE_COST_BENEFIT,
STAGE_QC, STAGE_PREP, STAGE_CH_REVIEW, STAGE_WHOLE_SCORE, STAGE_DELIVERY,
)
logger = logging.getLogger("pipeline.bidding")
@ -428,6 +430,32 @@ async def _auto_import_workspace_tender(sor, project_id):
return ""
async def _flow_stage_table(project_id):
"""取项目裁剪计划的阶段启用表流程裁剪机制pipeline_flow_plans
返回 None = 无已确认计划标准全流程存量项目零影响
dict = {stage_key: bool}False 即该阶段被裁用户已在待办确认
引擎不可用时诚实降级 None不阻塞流转并记 warning
"""
try:
from pipeline_service.flow_plan_capability import plan_stage_enabled
return await plan_stage_enabled(project_id)
except Exception as e:
logger.warning("bid_flow: flow plan unavailable project=%s: %s",
project_id, str(e)[:160])
return None
async def _latest_flow_plan_status(project_id):
"""项目最新流程计划状态('' = 从未提案)。驳回后暂停派发用。"""
try:
from pipeline_service.flow_plan_capability import get_latest_plan
plan = await get_latest_plan(project_id)
return (plan or {}).get("status", "") or "", (plan or {}).get("reject_comment", "") or ""
except Exception:
return "", ""
async def reconcile_project(sor, project_id, project_name=""):
"""对单个投标项目做一次状态对账。返回动作说明列表(幂等,可反复调用)。"""
acts = []
@ -482,7 +510,38 @@ async def reconcile_project(sor, project_id, project_name=""):
await sor.sqlExe("COMMIT", {})
return acts + ["created: 上传招标文件任务 %s" % hid]
if await has_pending_blocking_human_task(sor, project_id):
return ["blocked: 有待办人类任务(等招标文件/等人类资料/等QC人工介入本轮不派发"]
return ["blocked: 有待办人类任务(等招标文件/等流程裁剪确认),本轮不派发"]
# ── 0c. 流程裁剪门禁2026-09-11──
# 最新计划被驳回 → 暂停派发,等会话 agent 按驳回意见修订重提(用户已表达要改流程,
# 不得按旧流程抢跑pending_confirm 由 0b 的阻塞待办挡住confirmed 计划在下方
# 各段按 stage_table 生效;从未提案('')→ 标准全流程(存量项目零影响)。
stage_table = await _flow_stage_table(project_id)
_fp_status, _fp_comment = await _latest_flow_plan_status(project_id)
if _fp_status == "rejected" and stage_table is None:
return acts + ["blocked: 流程裁剪方案被用户驳回(意见:%s),等会话 agent 修订重提,本轮不派发"
% (_fp_comment[:200] or "未填")]
def _stage_on(key):
"""阶段是否执行无确认计划None= 标准全流程都执行。"""
if stage_table is None:
return True
return bool(stage_table.get(key, True))
async def _qc_passed_eff():
"""章节依赖判定用的 QC 通过集裁剪语义2026-09-11
- qc_analysis 被裁 全部类型视为通过用户已确认放弃解析QC门禁
- 某分析维度被裁 该维度对应 QC 类型视为通过不会再有产出与审核
- 其余按 bid_qc_reviews 最新一轮实际判定
"""
if not _stage_on(STAGE_QC):
return set(QC_TYPES)
passed = await _qc_passed_types(sor, project_id)
for dim, stage in DIM_TO_STAGE.items():
if not _stage_on(stage):
for t in DIM_QC_TYPES.get(dim, ()):
passed.add(t)
return passed
th = await get_thresholds(sor)
@ -524,6 +583,12 @@ async def reconcile_project(sor, project_id, project_name=""):
if await _reset_analysis_for_new_file(sor, project_id, _ft):
acts.append("recovered: 检测到新招标文件,已清空旧分析残留,重新四维度分析")
missing = await _missing_dims(n_items, n_struct, n_quals, n_cost, chapters)
# 流程裁剪:被裁分析维度不派(用户已在确认待办里确认)
_trimmed_dims = [d for d, _ in missing if not _stage_on(DIM_TO_STAGE.get(d, ""))]
if _trimmed_dims:
missing = [(d, lb) for d, lb in missing
if _stage_on(DIM_TO_STAGE.get(d, ""))]
acts.append("trimmed: 分析维度「%s」已被用户裁剪,跳过" % "".join(_trimmed_dims))
created_dims = []
first_run_pending = []
for dim, label in missing:
@ -591,7 +656,7 @@ async def reconcile_project(sor, project_id, project_name=""):
# 过滤塞进 else 分支、创建挪出分支外,导致编排任务在办时仍用未过滤列表
# 每 15 秒重复建任务(实测 2 分钟堆 9 个)。
if first_run_pending:
qc_passed = await _qc_passed_types(sor, project_id)
qc_passed = await _qc_passed_eff()
dispatchable, waiting_up = [], []
for d, lb in first_run_pending:
up_ok = all(t in qc_passed
@ -637,7 +702,8 @@ async def reconcile_project(sor, project_id, project_name=""):
# 2026-09-03 章节级门禁:本段只负责「推进 QC」重做/审核/冒泡),不再提前 return
# 阻塞全局流转。哪些章节可以开写,由 C 段按章节依赖矩阵CH_SECTION_DEPS逐章判定——
# 某一维度卡住只阻塞依赖它的章节,其余章节照常流转。
qc_pending = await _qc_pending_types(sor, project_id)
# 2026-09-11 流程裁剪qc_analysis 阶段被裁 → 整段跳过(章节依赖由 _qc_passed_eff 视为全过)。
qc_pending = [] if not _stage_on(STAGE_QC) else await _qc_pending_types(sor, project_id)
over_types = set()
if qc_pending:
# 轮次用尽守卫:最新审核已达上限仍未通过且产出未更新 → 不再空转派审核任务,
@ -671,6 +737,8 @@ async def reconcile_project(sor, project_id, project_name=""):
dim = QC_TYPE_TO_DIM.get(t, "scoring")
if dim in redo_dims:
continue
if not _stage_on(DIM_TO_STAGE.get(dim, "")):
continue # 流程裁剪:被裁维度不派重做
if await _open_dim_tasks(sor, project_id, dim) > 0:
continue
n_redo_done = await _dim_redo_done_count(sor, project_id, dim)
@ -756,8 +824,9 @@ async def reconcile_project(sor, project_id, project_name=""):
# ── B. 资料准备(知识库资质匹配 + 人类文件清单)──
# 2026-09-03 章节级依赖资料准备是商务章business的上游依赖
# 不再阻塞全局流转——技术/报价章在上游 QC 通过后立即开写。
prep_done = await has_done_task(sor, project_id, R_PREP)
if n_quals > 0 and not prep_done:
# 2026-09-11 流程裁剪prep 被裁 → 不派;商务章 biz_ok 判定同步视为完成。
prep_done = (not _stage_on(STAGE_PREP)) or await has_done_task(sor, project_id, R_PREP)
if _stage_on(STAGE_PREP) and n_quals > 0 and not prep_done:
if await count_open_tasks(sor, project_id, role=R_PREP) > 0:
acts.append("waiting: 资料准备任务在办(只阻塞商务章)")
else:
@ -778,7 +847,7 @@ async def reconcile_project(sor, project_id, project_name=""):
# CH_SECTION_DEPS依赖满足立即派发——某一维度卡住只阻塞依赖它的章节
# 其余章节照常流转(根治:此前五类 QC 全过才放行cost_benefit 一个维度慢
# 导致全部章节冻结)。商务章额外等资料准备完成。
qc_passed = await _qc_passed_types(sor, project_id)
qc_passed = await _qc_passed_eff()
hd_pending = await find_human_task(sor, project_id, HT_HUMAN_DOCS, status="pending")
biz_ok = (prep_done or n_quals == 0) and not hd_pending
created_w = 0
@ -822,8 +891,29 @@ async def reconcile_project(sor, project_id, project_name=""):
await sor.sqlExe("COMMIT", {})
# ── D. 章节评审written 且无在办评审任务)──
# 2026-09-11 流程裁剪chapter_review 被裁 → written 章节直接置 approved用户已确认
# 放弃章节级质量把关,确认待办里有风险提示),不派评审任务。
if not _stage_on(STAGE_CH_REVIEW):
_auto_appr = [c for c in chapters if c.get("status") == CH_WRITTEN]
for c in _auto_appr:
await sor.sqlExe(
"UPDATE bid_chapters SET status=${st}$, updated_at=NOW() WHERE id=${i}$ "
"AND status=${old}$",
{"st": CH_APPROVED, "i": c["id"], "old": CH_WRITTEN})
if _auto_appr:
await sor.sqlExe("COMMIT", {})
acts.append("trimmed: chapter_review 已裁剪,%d 个章节 written→approved 直通"
% len(_auto_appr))
# 状态变了,本轮重读章节供 E 段判定
chs2 = await sor.sqlExe(
"SELECT id, chapter_no, title, section, status, revise_count FROM bid_chapters "
"WHERE project_id=${p}$ ORDER BY order_no, chapter_no", {"p": project_id})
await sor.sqlExe("COMMIT", {})
chapters = rows_to_dicts(chs2, limit=500)
created_r = 0
for c in chapters:
if not _stage_on(STAGE_CH_REVIEW):
break
if c.get("status") != CH_WRITTEN:
continue
if await count_open_tasks(sor, project_id, role=R_REVIEWER, chapter_id=c["id"]) > 0:
@ -929,7 +1019,17 @@ async def reconcile_project(sor, project_id, project_name=""):
return acts + ["created: 标书合成任务 %s" % tid]
# ── F. 整书评分 ──
# 2026-09-11 流程裁剪whole_score 被裁 → 合成后直接置 passed用户已确认
# 放弃模拟评标门禁),进入交付环节。
if doc.get("status") in (DOC_DRAFT, DOC_REVIEWING):
if not _stage_on(STAGE_WHOLE_SCORE):
await sor.sqlExe(
"UPDATE bid_documents SET status=${st}$, updated_at=NOW() WHERE id=${d}$ "
"AND status IN ('" + DOC_DRAFT + "','" + DOC_REVIEWING + "')",
{"st": DOC_PASSED, "d": doc.get("id")})
await sor.sqlExe("COMMIT", {})
return acts + ["trimmed: whole_score 已裁剪,标书 v%s 直接置 passed"
% doc.get("version")]
if await count_open_tasks(sor, project_id, role=R_SCORER) > 0:
return acts + ["waiting: 整书评分任务在办"]
tid = await create_role_task(
@ -942,6 +1042,16 @@ async def reconcile_project(sor, project_id, project_name=""):
# ── G. 通过 → 交付确认 → 项目完成 ──
if doc.get("status") == DOC_PASSED:
# 2026-09-11 流程裁剪delivery_confirm 被裁 → 跳过人工交付确认直接完结
if not _stage_on(STAGE_DELIVERY):
await sor.sqlExe(
"UPDATE bid_documents SET status=${st}$, updated_at=NOW() WHERE id=${d}$",
{"st": DOC_DELIVERED, "d": doc.get("id")})
await sor.sqlExe(
"UPDATE sd_projects SET status='completed', updated_at=NOW() "
"WHERE id=${p}$ AND status<>'completed'", {"p": project_id})
await sor.sqlExe("COMMIT", {})
return acts + ["trimmed: delivery_confirm 已裁剪,标书直接置 delivered项目完成"]
ht = await find_human_task(sor, project_id, HT_DELIVERY_CONFIRM)
if not ht:
hid = await create_human_task(
@ -1074,7 +1184,7 @@ def start_poller():
POLLER_STATE["last_error"] = ""
for pid, acts in (res or {}).items():
for a in acts:
if a.startswith(("created", "escalated", "completed", "error")):
if a.startswith(("created", "escalated", "completed", "error", "trimmed")):
try:
from appPublic.log import debug as _dbg
_dbg("bid_flow %s: %s" % (pid, a))

View File

@ -19,13 +19,24 @@ MAX_PM_INSTRUCTIONS = 2000
async def _progress(sor, project_id):
"""各维度进度:产出计数 / 最新 QC / 在办任务 / 依赖门禁状态"""
"""各维度进度:产出计数 / 最新 QC / 在办任务 / 依赖门禁状态 / 裁剪状态"""
from .bid_flow import (ANALYSIS_DIMS, DIM_QC_TYPES, DIM_UPSTREAM,
QC_OUTPUT_TABLE, _qc_passed_types, _open_dim_tasks)
from .bid_common import DIM_TO_STAGE
# 流程裁剪计划None = 无确认计划 = 标准全流程)
try:
from pipeline_service.flow_plan_capability import plan_stage_enabled
stage_table = await plan_stage_enabled(project_id)
except Exception:
stage_table = None
passed = await _qc_passed_types(sor, project_id)
rows = []
for dim, label, qc_ts in ANALYSIS_DIMS:
info = {"dim": dim, "label": label}
if stage_table is not None and not stage_table.get(DIM_TO_STAGE.get(dim, ""), True):
info["裁剪"] = "已被用户裁剪(不得派发)"
rows.append(info)
continue
cnt = 0
for t in qc_ts:
tbl = QC_OUTPUT_TABLE[t]
@ -85,6 +96,7 @@ async def dispatch_analysis_dim(dim, instructions="", project_id="",
"""
from .bid_flow import (ANALYSIS_DIMS, DIM_QC_TYPES, DIM_UPSTREAM,
R_ANALYST, _qc_passed_types, _open_dim_tasks)
from .bid_common import DIM_TO_STAGE
dims = {d: lb for d, lb, _ts in ANALYSIS_DIMS}
if dim not in dims:
return False, "未知维度 %s(可选:%s" % (dim, "".join(dims))
@ -92,6 +104,15 @@ async def dispatch_analysis_dim(dim, instructions="", project_id="",
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)[:200]
# 流程裁剪门禁2026-09-11被裁维度拒绝派发用户已确认裁剪PM 不得绕过)
try:
from pipeline_service.flow_plan_capability import plan_stage_enabled
_st = await plan_stage_enabled(project_id)
if _st is not None and not _st.get(DIM_TO_STAGE.get(dim, ""), True):
return False, ("维度「%s」已被用户在流程裁剪中确认裁掉,不得派发。"
"用 analysis_progress 查看当前裁剪后的维度清单。" % dims[dim])
except ImportError:
pass
label = dims[dim]
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor: