diff --git a/README.md b/README.md index e444cde..31238ea 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,26 @@ **产线起点 = 上传招标文件**(`import_tender_file` 无 project_id 时自动立项)。阶段链: > 收到招标文件(自动立项)→ 解析(评分项/资质/投标文件要求/商务要素/章节骨架) -> → QC 契合度审核(四类逐类,10 分制,>9.5 放行)→ 资料准备 → 分角色并发编写 +> → QC 契合度审核(四类逐类,10 分制,>9.5 放行)→ **技术方案部分:模版裁剪链(A3 段, +> 2026-09-17 起)** → 资料准备 → 分角色并发编写 > (business→bid_biz_writer / technical→bid_writer)→ 章节评审(含篇幅门禁)→ 合成 > → 整书评分 → 交付确认。 +**A3 段:标准流程技术标模版裁剪链(v1.6,2026-09-17)**:标准投标流程内置与纯技术方案 +流程同源的三段链 `tech_analysis(六类技术评估项,对标招标文件技术需求部分)→ tech_qc +(qc_type=tech_items)→ template_confirm(PM 检索模版按评估项裁剪骨架 → 用户确认待办)`。 +对账器共用段 `_reconcile_tech_analysis_qc` / `_reconcile_template_confirm`(两条流程逻辑 +只此一份,flow_key 写进任务 params 区分来源)。**链激活五条件**:有招标文件 + +tech_analysis/template_confirm 两阶段启用 + 骨架维度 QC 已过 + 存在 technical 章且全部 +pending + 无裁剪摘要行(`bid_tech_items kind='summary'`)——任一不满足整链跳过,技术章 +直接按招标骨架编写(存量项目零影响)。链激活期间 C 段只挡 technical 章(`_tech_gate_on`), +商务/报价章照常流转;用户确认后 `confirm_tech_template` **标准模式只替换 technical 章** +(新骨架落原技术章 min(order_no) 位置,商务/报价章不动),并落 kind='summary' 裁剪摘要行 +作为链完成的持久证据(confirm 落库、新招标文件 reset 清除;不用 done 待办/章节状态判完成 +——换代后会锁死新链或复活死循环)。BID_FLOW_STAGES 扩为 14 阶段(+tech_analysis +trim=yes / tech_qc trim=warn / template_confirm trim=warn;裁 tech_analysis 必连带裁 +template_confirm)。tech 维度不进 ANALYSIS_DIMS、不走 PM 编排,由对账器直接派发。 + **多流程模板(2026-09-15 批2)**:本产线声明两条流程(`BID_FLOW_TEMPLATES`,引擎 FlowTemplate 机制首个接入)——`bid_standard`(上述标准链)与 `bid_tech_proposal` (**纯技术方案流程**:输入=技术需求书而非招标文件,产出=技术方案书): @@ -68,7 +84,7 @@ FlowTemplate 机制首个接入)——`bid_standard`(上述标准链)与 ` | `bid_qc_reviews` | 解析产出 QC 审核记录 | project_id, qc_type, round, reviewer, review_type, fit_score, pass_score | | `bid_analysis_history` | QC 分析历史快照(每轮 QC 逐审核记录归档;2026-09-10 归籍补 models 定义,此前是孤儿表) | project_id, qc_type, round, task_id, fit_score, improvement, payload | | `bid_cost_benefit` | 成本收益分析(分析维度4,单项+summary 结论行;m0006 建表未进 models,2026-09-10 归籍) | project_id, kind, category, item_name, amount, confidence | -| `bid_tech_items` | 技术评估项(纯技术方案流程 tech_analysis 产出;kind=item/summary,category 六类;m0034 建表) | project_id, kind, category, item_name, requirement, mandatory, source_ref | +| `bid_tech_items` | 技术评估项(纯技术方案流程与标准流程 A3 裁剪链 tech_analysis 产出;kind=item/summary,summary=骨架裁剪摘要行,category 六类;m0034 建表) | project_id, kind, category, item_name, requirement, mandatory, source_ref | | `bid_kb_docs` | 公司投标知识库 | org_id, doc_type(sample_bid 等), doc_name, doc_no, tags, file_path, content_text | | `bid_material_gaps` | 投标材料缺口清单(占位符规范:正文 `【材料待补:…】` 自动登记,合成后出清单,人工/产线补齐;m0035 建表) | project_id, chapter_no, material_name, placeholder_text, fingerprint, status(pending/filled/waived) | | `bid_members` | 投标项目参与人员 | project_id, user_id, user_name, member_role, duty, status | @@ -89,10 +105,12 @@ bid_chapter_status / bid_review_scope / bid_kb_doc_type / bid_doc_status)。 最近 in_progress 项目。 - **api/bid_task_io.dspy** — 任务树右侧详情面板:输入 = 任务描述 + QC 改进意见, 执行步骤,输出 = 交付件(复用 `pipeline_service/task_io_render.py` 统一渲染规范)。 -- **api/tech_template_confirm.dspy** — 技术方案骨架用户确认/驳回端点(2026-09-15)。 - 入参 human_task_id/decision(confirm|reject)/comment(驳回必填)。用户专属:同机构+ - 超管/创建者校验复用引擎 `_check_confirm_operator`;agent 无对应工具无法代确认。 - 确认后骨架落 bid_chapters(section=technical) 开写;驳回意见由对账器注入重派模版任务。 +- **api/tech_template_confirm.dspy** — 技术方案骨架用户确认/驳回端点(2026-09-15; + 2026-09-17 双模式分流)。入参 human_task_id/decision(confirm|reject)/comment(驳回必填)。 + 用户专属:同机构+超管/创建者校验复用引擎 `_check_confirm_operator`;agent 无对应工具无法代确认。 + 确认后骨架落 bid_chapters(section=technical) 开写——**标准流程(项目存在非 technical 章) + 只替换技术章并落 kind='summary' 裁剪摘要行,商务/报价章原样保留;纯技术方案流程整表清落**; + 驳回意见由对账器注入重派模版任务。 - **bid_task_tree_popup.ui** — ResourceBrowser 复合 widget 弹窗(左树右详情联动)。 - **agent/index.ui** — 驾驶舱入口页(顶栏含任务树按钮)。 - **12 个 CRUD 目录**(bid_tenders / bid_tender_files / bid_scoring_items / diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bf19776..f210822 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,7 +2,7 @@ > 对照开发产线(`sdlc_general`)设计,复用平台通用内核(项目/会话/任务链/人类任务/交付件/问答冒泡/技能分层), > 只增量定义投标产线自己的 **阶段、角色、工具、技能、任务流转规则、交付件**。 -> 版本:v1.2.0  创建:2026-08-26  修订:2026-08-31(v1.1 上传招标文件起点+QC 门禁;v1.2 编写分商务/技术角色+篇幅门禁+样板标书规范+并发编写) +> 版本:v1.6.0  创建:2026-08-26  修订:2026-09-17(v1.1 上传招标文件起点+QC 门禁;v1.2 编写分商务/技术角色+篇幅门禁+样板标书规范+并发编写;v1.6 标准流程技术标模版裁剪链 A3 段,见 3.7) --- @@ -130,6 +130,7 @@ agent.bid_scorer → 整书评分 | `tender_file_upload` | 等待上传招标文件(上传后自动完成) | 项目全部角色任务 | | `human_docs_request` | 人类应提供的文件 | 项目全部角色任务 | | `qc_escalation` | 解析产出 QC 连续不达标 | 项目全部角色任务 | +| `tech_template_confirm` | 技术方案骨架用户确认(标准流程 A3 段链激活时;2026-09-17 起从全局阻塞降级为**章节级**:只挡 technical 章,商务/报价章照常) | technical 章节编写 | | `bid_delivery_confirm` | 最终标书交付确认 | 项目收尾(不阻塞派发) | **门禁的守门位置:投标产线的角色任务唯一创建者是 `bid_flow` 对账器**(每 15 秒状态对账)。 @@ -162,7 +163,47 @@ agent.bid_scorer → 整书评分 **并发编写**:对账器 C 块单轮最多派 `bid_write_concurrency`(默认 4)个编写任务; 引擎 agent poller 另有每项目并发上限 `max_agents_per_project`(默认 4)兜底,任务间 asyncio 并发执行。 -### 3.7 硬规则 +### 3.7 标准流程技术标模版裁剪链(A3 段,v1.6 新增) + +标准投标流程内置与纯技术方案流程**同源**的三段链,产出标书「技术方案部分」: + +``` +tech_analysis(agent.tender_analyst,analysis_dim=tech,六类技术评估项落 bid_tech_items, + 对标招标文件技术需求部分) + ↓ +tech_qc(agent.qc,qc_type=tech_items,对标招标原文,10 分制同款门禁) + ↓ +template_confirm(agent.pm,task_kind=tech_template:find_template_candidates 三级检索 + → propose_tech_template 骨架六类覆盖硬校验 → tech_template_confirm 用户确认待办) + ↓ 用户确认(confirm_tech_template 标准模式) +只替换 technical 章骨架(新章落原技术章 min(order_no) 位置,商务/报价章不动) ++ 落 kind='summary' 裁剪摘要行 → 技术章解禁开写 +``` + +**链激活五条件**(bid_flow A3 段,任一不满足整链跳过 = 存量行为,技术章直接按招标骨架编写): +1. 有招标文件(n_files > 0)且章节骨架已生成; +2. `tech_analysis` + `template_confirm` 两阶段都启用(BID_FLOW_STAGES 14 阶段, + 裁 tech_analysis 必连带裁 template_confirm——依赖断裂校验拒绝单裁); +3. 骨架维度 QC 已通过(QC_TYPE_OUTLINE ∈ _qc_passed_eff——outline 重做会清空 + bid_chapters,防把已确认模版骨架连锅端); +4. 项目存在 technical 章节且全部 pending(任一 writing/written/approved = 已按原骨架 + 推进或存量项目,不激活); +5. 无裁剪摘要行(`bid_tech_items kind='summary'`,confirm 时落库、新招标文件 reset 时清除)。 + +**完成判定定案用摘要行**,不用 done 待办或章节状态:换代后旧 done 待办仍在会永久锁死 +新代链;替换后的新骨架也是 pending,靠章节状态判完成会复活死循环。 + +**门禁语义**:链激活时 C 段只挡 technical 章(`_tech_gate_on`),商务/报价章照常流转—— +`tech_template_confirm` 待办已从全局阻塞降级为章节级依赖(bid_common BLOCKING/HT 定义同步)。 + +**共用段实现**:`_reconcile_tech_analysis_qc`(T1/T2:评估项派发+QC+升级)与 +`_reconcile_template_confirm`(T3:模版任务派发+确认待办补发+驳回重派)——纯技术方案分支 +`_reconcile_tech_proposal` 重构为调共用段,逻辑只此一份;`flow_key` 写进任务 params 区分来源。 +`confirm_tech_template` 按「项目是否存在非 technical 章」自动分流标准/纯技术方案双模式, +重确认守卫收窄到待替换范围。**tech 维度不进 ANALYSIS_DIMS、不走 PM 编排**(对账器直接派发, +避免编排误派/掉链误判)。新招标文件导入 reset:清 bid_tech_items + 作废 pending 骨架确认待办。 + +### 3.8 硬规则 - 章节写作任务与章节一对一(`params.chapter_id`),禁止一个任务写多章。 - 评审/评分只由 `agent.bid_reviewer` / `agent.bid_scorer` 执行,写作角色不得自评; diff --git a/pipeline_bidding/bid_ability.py b/pipeline_bidding/bid_ability.py index cc0d274..ef1c19a 100644 --- a/pipeline_bidding/bid_ability.py +++ b/pipeline_bidding/bid_ability.py @@ -44,6 +44,22 @@ BID_FLOW_STAGES = [ description="QC 对照招标文件原文逐类审核解析产出(10分制,不过退回重做)", trim="warn", warn_note="裁掉解析QC后,评分项/资质/骨架的抽取错误将不经核对直接进入编写阶段,可能整章返工", roles=["agent.qc"], deps=["analysis_scoring", "analysis_reqs_outline"]), + # ── 技术标模版裁剪链(2026-09-17 挂进标准流程)── + # 标准标书的技术方案部分不再直接按招标骨架开写:先从招标文件技术部分抽取 + # 六类技术评估项 → QC 对标 → PM 检索技术标模版并按评估项裁剪出技术章骨架 → + # 用户确认后只替换 technical 章节(商务/报价章原样保留)。与纯技术方案流程 + # 同源(bid_tech_capability),确认落库按「项目是否有非 technical 章节」自动分流。 + FlowStage(key="tech_analysis", label="技术评估项抽取(技术方案裁剪依据)", + description="从招标文件技术部分抽取六类技术评估项(功能清单/功能点/硬件配置/技术架构/功能架构/部署架构)落库 bid_tech_items——技术章骨架裁剪与编写的对标依据", + trim="yes", roles=["agent.tender_analyst"], deps=["analysis_reqs_outline"]), + FlowStage(key="tech_qc", label="技术评估项QC契合度审核", + description="QC 对照招标文件技术需求原文审核评估项(10分制,不过退回重做)", + trim="warn", warn_note="裁掉技术评估项QC后,抽取错误不经核对直接进入骨架裁剪,技术方案可能偏离招标要求", + roles=["agent.qc"], deps=["tech_analysis"]), + FlowStage(key="template_confirm", label="技术标模版裁剪+用户确认骨架", + description="预定模版(知识库)→ 无则 RAG → 无则网络检索,按评估项裁剪技术章骨架,发待办由用户确认后替换技术章(商务/报价章不受影响)", + trim="warn", warn_note="裁掉模版确认后,技术章直接按招标书骨架编写,跳过技术标模版裁剪,技术方案结构可能不符合行业惯例", + roles=["agent.pm"], deps=["tech_analysis"]), FlowStage(key="prep", label="投标资料准备", description="知识库资质匹配 + 缺失资质的人类文件清单(只阻塞商务章)", trim="yes", roles=["agent.bid_prep"], deps=["analysis_quals"]), diff --git a/pipeline_bidding/bid_flow.py b/pipeline_bidding/bid_flow.py index 3cf7477..491bbc4 100644 --- a/pipeline_bidding/bid_flow.py +++ b/pipeline_bidding/bid_flow.py @@ -31,7 +31,7 @@ from .bid_common import ( 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, - FLOW_KEY_TECH_PROPOSAL, STAGE_TECH_ANALYSIS, STAGE_TECH_QC, + FLOW_KEY_TECH_PROPOSAL, FLOW_KEY_STANDARD, STAGE_TECH_ANALYSIS, STAGE_TECH_QC, STAGE_TEMPLATE_CONFIRM, STAGE_TECH_CH_WRITE, STAGE_TECH_CH_QC, STAGE_TECH_COMPOSE, STAGE_TECH_DELIVERY, ) @@ -377,7 +377,9 @@ async def _reset_analysis_for_new_file(sor, project_id, since_ts): if not recs: return False for tbl in ("bid_scoring_items", "bid_qualifications", "bid_doc_requirements", - "bid_chapters", "bid_cost_benefit"): + "bid_chapters", "bid_cost_benefit", + # 2026-09-17:技术评估项/裁剪摘要同为旧文件产物,新文件重抽 + "bid_tech_items"): await sor.sqlExe( "DELETE FROM %s WHERE project_id=${p}$" % tbl, {"p": project_id}) await sor.sqlExe("COMMIT", {}) @@ -385,6 +387,13 @@ async def _reset_analysis_for_new_file(sor, project_id, since_ts): "DELETE FROM bid_qc_reviews WHERE project_id=${p}$ AND created_at < ${ts}$", {"p": project_id, "ts": since_ts}) await sor.sqlExe("COMMIT", {}) + # pending 的技术章骨架确认待办作废(骨架依据旧文件评估项,新文件要重裁剪) + await sor.sqlExe( + "UPDATE pipeline_human_tasks SET status='cancelled', " + "qc_comment='已上传新招标文件,骨架需按新技术评估项重新裁剪,本待办自动作废' " + "WHERE project_id=${p}$ AND task_type=${tt}$ AND status='pending'", + {"p": project_id, "tt": HT_TEMPLATE_CONFIRM}) + await sor.sqlExe("COMMIT", {}) ht = await find_human_task(sor, project_id, "general", status="pending") if ht and "解析" in (ht.get("title") or ""): await sor.sqlExe( @@ -1038,11 +1047,58 @@ async def reconcile_project(sor, project_id, project_name=""): await sor.sqlExe("COMMIT", {}) # 不提前 return:继续往下走章节级派发(依赖已满足的章节立即启动) + # ── A3. 技术方案部分:模版裁剪链(2026-09-17 挂进标准流程)── + # 用户要求:标准投标项目必须产出标书「技术方案部分」,技术章不再直接按招标 + # 骨架开写,而是走与纯技术方案流程同源的裁剪链(T1/T2/T3 共用段): + # tech_analysis(六类技术评估项)→ tech_qc(对标招标技术需求原文)→ + # template_confirm(PM 检索模版按评估项裁剪 → 用户确认)→ + # confirm_tech_template 标准模式(只替换 technical 章,商务/报价章原样保留)。 + # 链激活判定(存量兼容,甘肃等已开写项目零影响): + # - tech_analysis + template_confirm 阶段都启用(任一被裁 → 整链跳过, + # 评估项只为裁剪服务,裁了确认段还抽评估项是白烧 LLM); + # - 骨架维度 QC 已通过(outline 重做会清空 bid_chapters——防把已确认的 + # 模版骨架连锅端); + # - 项目存在 technical 章节且全部未开写(任一技术章 writing/written/approved + # = 已按原骨架推进或存量项目,链不激活); + # - 无「本文件代裁剪摘要」(bid_tech_items kind='summary',confirm 时落库、 + # 新招标文件 reset 时清除——摘要存在 = 当代链已走完。不用 done 待办判完成: + # 换代后旧 done 待办仍在,会把新文件代的链永久锁死;替换后的新骨架也是 + # pending,靠章节状态判完成会复活死循环)。 + # 门禁语义:链激活时 C 段只挡 technical 章(_tech_gate_on),商务/报价章照常 + # 流转——HT_TEMPLATE_CONFIRM 已从全局阻塞降级为章节级依赖(bid_common)。 + _tech_over_active = False + _tech_gate_on = False + if n_files > 0 and chapters and _stage_on(STAGE_TECH_ANALYSIS) \ + and _stage_on(STAGE_TEMPLATE_CONFIRM): + _tech_chs = [c for c in chapters + if (c.get("section") or "technical") == "technical"] + _n_summary = await _count( + sor, "SELECT COUNT(*) AS c FROM bid_tech_items " + "WHERE project_id=${p}$ AND kind='summary'", {"p": project_id}) + _qc_eff = await _qc_passed_eff() + _chain_open = bool(_tech_chs) and _n_summary == 0 \ + and all(c.get("status") == CH_PENDING for c in _tech_chs) \ + and QC_TYPE_OUTLINE in _qc_eff + if _chain_open: + _tech_gate_on = True # C 段技术章门禁:等模版确认完成 + _n_ti = await _count( + sor, "SELECT COUNT(*) AS c FROM bid_tech_items " + "WHERE project_id=${p}$ AND kind='item'", {"p": project_id}) + _ti_qc = await _tech_qc_latest(sor, project_id) + _tq_passed, _tech_over_active, _tblocked = await _reconcile_tech_analysis_qc( + sor, project_id, pname, _n_ti, _ti_qc, th, _stage_on, + FLOW_KEY_STANDARD, acts) + if _tq_passed and not _tblocked: + await _reconcile_template_confirm( + sor, project_id, pname, FLOW_KEY_STANDARD, acts) + # ── A2b. qc_escalation 待办自动关闭(2026-09-16 甘肃项目实测 bug)── # 升级待办发出后,人工修产出/记录页强制放行/重做通过 → 轮次用尽状态消失 # (_over_active=False),但 pending 的 qc_escalation 待办无人关闭就永远挂着, # 角标常驻误导「还有事没办」。逃逸阀闭环的另一半:问题解决 → 待办自动收。 - if not _over_active: + # 2026-09-17:tech_items 轮次用尽(A3 段)同样算「仍活跃」——本段只遍历 + # QC_TYPES 五类,缺 _tech_over_active 守卫会把技术评估项的升级待办秒关。 + if not _over_active and not _tech_over_active: _ht_esc = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending") if _ht_esc: await sor.sqlExe( @@ -1199,6 +1255,12 @@ async def reconcile_project(sor, project_id, project_name=""): role = R_BIZ_WRITER if section == "business" else R_WRITER if await count_open_tasks(sor, project_id, role=role, chapter_id=c["id"]) > 0: continue + # 技术章模版裁剪门禁(2026-09-17):A3 段链激活时,技术章等「模版确认 + # 完成、骨架已替换」才开写(confirm_tech_template 标准模式会替换 technical + # 章——提前开写的内容会被替换毁掉);商务/报价章不受影响照常流转。 + if section == "technical" and _tech_gate_on: + waiting_deps.setdefault("tech_template_confirm", []).append(str(c.get("chapter_no"))) + continue # 上游依赖门禁:该 section 依赖的所有产出类型最新一轮必须已通过契合度审核 deps = CH_SECTION_DEPS.get(section, CH_SECTION_DEPS["technical"]) unmet = [t for t in deps if t not in qc_passed] @@ -1448,6 +1510,120 @@ async def _open_tech_tasks(sor, project_id, task_kind): return to_int(getattr(recs[0], "c", 0) if recs else 0) +async def _reconcile_tech_analysis_qc(sor, project_id, pname, n_items, latest_qc, + th, stage_on, flow_key, acts): + """技术评估项抽取(S1)+ QC 审核(S2)共用段。 + + 2026-09-17 起两条流程同源:纯技术方案分支 T1/T2 与标准流程 A3 段都调本函数, + 逻辑只此一份(flow_key 写进任务 params 供任务树/审计区分来源)。 + 返回 (qc_passed, over_active, blocked): + - qc_passed:tech_items 最新一轮 QC 通过,或 tech_qc 阶段被裁(视为通过); + - over_active:轮次用尽等人工(标准流程 A2b 自动关闭 qc_escalation 待办的守卫, + 缺它会把技术评估项的升级待办秒关——A2b 只看 QC_TYPES 五类); + - blocked:本轮链路已有动作(派发/在办/升级),调用方不得继续模版段。 + """ + if n_items == 0: + if not stage_on(STAGE_TECH_ANALYSIS): + return False, False, False # 抽取被裁且无产出:链路无依据,静默跳过 + if await _open_tech_tasks(sor, project_id, "bid_tech_analysis") > 0: + acts.append("waiting: 技术评估项抽取任务在办") + return False, False, True + qc_imp = (latest_qc.get("improvement") or "") if ( + latest_qc and str(latest_qc.get("passed")) != "1") else "" + attempts = await _count( + sor, "SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ " + "AND pipeline_id='role_task' AND role=${r}$ AND state IN " + "('approved','completed','qc_rejected') AND params LIKE ${kw}$", + {"p": project_id, "r": R_ANALYST, "kw": '%bid_tech_analysis%'}) + if attempts >= MAX_ANALYST_ATTEMPT + th["qc_max_round"]: + ht = await find_human_task(sor, project_id, "general", status="pending") + if not ht: + await create_human_task( + sor, project_id, "general", + "技术评估项解析多次未产出,需人工介入:%s" % pname, + ("技术评估项解析任务已执行 %d 次,产出仍为空。\n" + "请检查招标文件技术部分是否可读(bid_tender_files.content_text)," + "或用 list_tech_items/add_tech_item 人工补录评估项。" % attempts), + assignee_role="owner.superuser") + await sor.sqlExe("COMMIT", {}) + acts.append("escalated: 技术评估项解析重试超限 → 人工介入") + return False, False, True + tparams = {"stage": "tech_analysis", "task_kind": "bid_tech_analysis", + "analysis_dim": TECH_KIND, "flow_key": flow_key} + if qc_imp: + tparams["qc_redo"] = 1 + tparams["qc_improvements"] = ["[tech_items] %s" % qc_imp[:2000]] + tid = await create_role_task( + sor, project_id, R_ANALYST, + "%s 技术评估项抽取(六类%s)" % (pname, ",QC退回重做" if qc_imp else ""), + tparams) + await sor.sqlExe("COMMIT", {}) + acts.append("created: 技术评估项抽取任务 %s" % tid) + return False, False, True + + if not stage_on(STAGE_TECH_QC): + return True, False, False # QC 被裁 → 视为通过(用户已确认放弃该门禁) + if latest_qc and str(latest_qc.get("passed")) == "1": + return True, False, False + + rnd = to_int(latest_qc.get("round"), 0) if latest_qc else 0 + if latest_qc and rnd >= th["qc_max_round"]: + # 轮次用尽:产出未更新则不空转,抛人工(同标准流程逃逸阀) + ht = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending") + if not ht: + await create_human_task( + sor, project_id, HT_QC_ESCALATION, + "技术评估项 QC 连续不达标需人工介入", + ("「tech_items」已审核 %d 轮仍未达标(最近改进意见:%s)。\n\n" + "请人工核对招标文件技术需求修正评估项(list_tech_items/add_tech_item)," + "或在 QC 审核记录页把该记录 passed 改为 1 强制放行。" + % (rnd, (latest_qc.get("improvement") or "")[:1500])), + assignee_role="owner.superuser") + await sor.sqlExe("COMMIT", {}) + acts.append("escalated: 技术评估项 QC 轮次用尽 → 人工介入") + return False, True, True + if await _open_tech_tasks(sor, project_id, "bid_qc") > 0: + acts.append("waiting: 技术评估项 QC 任务在办") + return False, False, True + # 竞态守卫:抽取/重做任务在办 → 不派审核(防对半成品打分) + if await _open_tech_tasks(sor, project_id, "bid_tech_analysis") > 0: + acts.append("waiting: 技术评估项抽取任务在办,暂不派 tech QC") + return False, False, True + tid = await create_role_task( + sor, project_id, R_QC, + "%s 技术评估项契合度审核(对标招标技术需求原文)" % pname, + {"stage": "tech_qc", "task_kind": "bid_qc", "qc_types": [QC_TYPE_TECH], + "flow_key": flow_key}) + await sor.sqlExe("COMMIT", {}) + acts.append("created: 技术评估项 QC 任务 %s" % tid) + return False, False, True + + +async def _reconcile_template_confirm(sor, project_id, pname, flow_key, acts): + """模版裁剪提案段(S3,两条流程同源):无 pending 待办且无在办任务时派 PM + 检索模版→裁剪技术章骨架→propose_tech_template 发用户确认待办(带驳回意见修订)。 + 返回 blocked(本轮有动作/等待中)。确认动作是用户专属(tech_template_confirm.dspy)。 + """ + ht_pend = await find_human_task(sor, project_id, HT_TEMPLATE_CONFIRM, status="pending") + if ht_pend: + acts.append("waiting: 技术章骨架待用户确认") + return True + if await _open_tech_tasks(sor, project_id, "tech_template") > 0: + acts.append("waiting: 模版/骨架拟定任务在办") + return True + from .bid_tech_capability import get_template_reject_comment + rej = await get_template_reject_comment(sor, project_id) + tid = await create_role_task( + sor, project_id, R_PM, + "%s 拟定技术方案模版骨架%s" % (pname, "(按驳回意见修订)" if rej else ""), + {"stage": "template_confirm", "task_kind": "tech_template", + "flow_key": flow_key, + **({"reject_comment": rej[:2000]} if rej else {})}) + await sor.sqlExe("COMMIT", {}) + acts.append("created: 模版骨架任务 %s(PM 检索模版→按评估项裁剪→发用户确认)" % tid) + return True + + async def _reconcile_tech_proposal(sor, project_id, pname, stage_table): """纯技术方案流程对账(幂等)。stage_table=已确认计划的阶段启用表。""" acts = [] @@ -1474,97 +1650,23 @@ async def _reconcile_tech_proposal(sor, project_id, pname, stage_table): # tech_qc 被裁 → T2 段的 _on(STAGE_TECH_QC) 判定即视为通过(用户已在确认待办里放弃该门禁), # 不再单独算 qc_eff 死变量(2026-09-15 清理:原变量算了未被引用)。 - # ── T1. 技术需求解析(六类评估项)── - if n_items == 0 and _on(STAGE_TECH_ANALYSIS): - if await _open_tech_tasks(sor, project_id, "bid_tech_analysis") > 0: - acts.append("waiting: 技术需求解析任务在办") - else: - qc_imp = (latest_qc.get("improvement") or "") if (latest_qc and str(latest_qc.get("passed")) != "1") else "" - attempts = await _count( - sor, "SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ " - "AND pipeline_id='role_task' AND role=${r}$ AND state IN " - "('approved','completed','qc_rejected') AND params LIKE ${kw}$", - {"p": project_id, "r": R_ANALYST, "kw": '%bid_tech_analysis%'}) - if attempts >= MAX_ANALYST_ATTEMPT + th["qc_max_round"]: - ht = await find_human_task(sor, project_id, "general", status="pending") - if not ht: - await create_human_task( - sor, project_id, "general", - "技术需求解析多次未产出,需人工介入:%s" % pname, - ("技术评估项解析任务已执行 %d 次,产出仍为空。\n" - "请检查需求书是否可读(bid_tender_files.content_text)," - "或用 list_tech_items/add_tech_item 人工补录评估项。" % attempts), - assignee_role="owner.superuser") - await sor.sqlExe("COMMIT", {}) - acts.append("escalated: 技术需求解析重试超限 → 人工介入") - else: - tparams = {"stage": "tech_analysis", "task_kind": "bid_tech_analysis", - "analysis_dim": TECH_KIND, "flow_key": FLOW_KEY_TECH_PROPOSAL} - if qc_imp: - tparams["qc_redo"] = 1 - tparams["qc_improvements"] = ["[tech_items] %s" % qc_imp[:2000]] - tid = await create_role_task( - sor, project_id, R_ANALYST, - "%s 技术需求解析(六类评估项%s)" % (pname, ",QC退回重做" if qc_imp else ""), - tparams) - await sor.sqlExe("COMMIT", {}) - acts.append("created: 技术需求解析任务 %s" % tid) - return acts or ["idle: 技术需求解析中"] + # ── T1/T2. 技术需求解析 + QC(共用段,2026-09-17 与标准流程 A3 同源)── + qc_passed, _tech_over, _blocked = await _reconcile_tech_analysis_qc( + sor, project_id, pname, n_items, latest_qc, th, _on, + FLOW_KEY_TECH_PROPOSAL, acts) + if not qc_passed: + return acts or ["idle: 技术需求解析/审核中"] + if _blocked: + return acts - # ── T2. 解析产出 QC(对标需求书原文,qc_type=tech_items)── - if _on(STAGE_TECH_QC) and n_items > 0 and not qc_passed: - rnd = to_int(latest_qc.get("round"), 0) if latest_qc else 0 - if latest_qc and rnd >= th["qc_max_round"] and str(latest_qc.get("passed")) != "1": - # 轮次用尽:产出未更新则不空转,抛人工(同标准流程逃逸阀) - ht = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending") - if not ht: - await create_human_task( - sor, project_id, HT_QC_ESCALATION, - "技术评估项 QC 连续不达标需人工介入", - ("「tech_items」已审核 %d 轮仍未达标(最近改进意见:%s)。\n\n" - "请人工核对技术需求书修正评估项(list_tech_items/add_tech_item)," - "或在 QC 审核记录页把该记录 passed 改为 1 强制放行。" - % (rnd, (latest_qc.get("improvement") or "")[:1500])), - assignee_role="owner.superuser") - await sor.sqlExe("COMMIT", {}) - acts.append("escalated: 技术评估项 QC 轮次用尽 → 人工介入") - return acts - if await _open_tech_tasks(sor, project_id, "bid_qc") > 0: - acts.append("waiting: 技术评估项 QC 任务在办") - return acts - # 竞态守卫:解析/重做任务在办 → 不派审核(防对半成品打分) - if await _open_tech_tasks(sor, project_id, "bid_tech_analysis") > 0: - acts.append("waiting: 解析任务在办,暂不派 tech QC") - return acts - tid = await create_role_task( - sor, project_id, R_QC, - "%s 技术评估项契合度审核(对标需求书原文)" % pname, - {"stage": "tech_qc", "task_kind": "bid_qc", "qc_types": [QC_TYPE_TECH], - "flow_key": FLOW_KEY_TECH_PROPOSAL}) - await sor.sqlExe("COMMIT", {}) - return acts + ["created: 技术评估项 QC 任务 %s" % tid] - - # ── T3. 模版确定 + 用户确认骨架 ── + # ── T3. 模版确定 + 用户确认骨架(共用段)── if not chapters: if not _on(STAGE_TEMPLATE_CONFIRM): # 被裁(trim=no 理论上裁不掉,防御):无人确认不得开写 return acts + ["blocked: 模版确认阶段被裁但骨架缺失,无法开写章节"] - # 阻塞门禁:pending 的模版确认待办在 0b 已全局阻塞,走到这里只处理「未发/被驳回」 - ht_pend = await find_human_task(sor, project_id, HT_TEMPLATE_CONFIRM, status="pending") - if ht_pend: - return acts + ["waiting: 技术方案骨架待用户确认"] - if await _open_tech_tasks(sor, project_id, "tech_template") > 0: - return acts + ["waiting: 模版/骨架拟定任务在办"] - from .bid_tech_capability import get_template_reject_comment - rej = await get_template_reject_comment(sor, project_id) - tid = await create_role_task( - sor, project_id, R_PM, - "%s 拟定技术方案模版骨架%s" % (pname, "(按驳回意见修订)" if rej else ""), - {"stage": "template_confirm", "task_kind": "tech_template", - "flow_key": FLOW_KEY_TECH_PROPOSAL, - **({"reject_comment": rej[:2000]} if rej else {})}) - await sor.sqlExe("COMMIT", {}) - return acts + ["created: 模版骨架任务 %s(PM 检索模版→拟骨架→发用户确认)" % tid] + await _reconcile_template_confirm(sor, project_id, pname, + FLOW_KEY_TECH_PROPOSAL, acts) + return acts or ["idle: 模版裁剪提案中"] # ── T4. 分章编写(骨架已确认;一章一任务并发派发)── created_w = 0 diff --git a/pipeline_bidding/bid_tech_capability.py b/pipeline_bidding/bid_tech_capability.py index ac4e78f..ccbabe0 100644 --- a/pipeline_bidding/bid_tech_capability.py +++ b/pipeline_bidding/bid_tech_capability.py @@ -472,26 +472,72 @@ async def confirm_tech_template(human_task_id, decision, comment="", chapters = schema.get("chapters") or [] if not chapters: return False, "待办里没有骨架数据(form_schema.chapters 空),无法落库" - # 幂等重落守卫:仅当既有章节全部未开写(pending)时清旧骨架—— + # 标准/纯技术方案双模式分流(2026-09-17 裁剪链挂标准流程): + # 项目存在非 technical 章节(商务/报价章)= 标准投标流程 → 只替换 + # technical 章(新骨架落原位 order_no,商务/报价章原样保留); + # 纯技术方案流程无其他章节 → 整表清落(原语义不变)。 + _all = await sor.sqlExe( + "SELECT id, section, status, order_no FROM bid_chapters " + "WHERE project_id=${p}$", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + _rows = rows_to_dicts(_all, limit=500) + _tech_rows = [r for r in _rows + if (r.get("section") or "technical") == "technical"] + _other_rows = [r for r in _rows if r not in _tech_rows] + std_mode = bool(_other_rows) + # 幂等重落守卫:仅当待替换章节全部未开写(pending)时清旧骨架—— # 已有 writing/written/approved 章节说明流程已推进,重新确认会毁掉 # 已写内容,拒绝并提示走驳回/人工处置(2026-09-15 加固)。 - busy = await sor.sqlExe( - "SELECT COUNT(*) AS c FROM bid_chapters WHERE project_id=${p}$ " - "AND status<>${s}$", {"p": project_id, "s": CH_PENDING}) - await sor.sqlExe("COMMIT", {}) - if to_int(getattr(busy[0], "c", 0) if busy else 0) > 0: - return False, ("项目已有开写/已完成的章节,不能重新确认骨架(会毁掉已写内容)。" - "如需换骨架请先驳回或在章节管理页人工处置既有章节。") + _scope = _tech_rows if std_mode else _rows + if any(str(r.get("status")) != CH_PENDING for r in _scope): + return False, ("项目已有开写/已完成的%s章节,不能重新确认骨架(会毁掉已写内容)。" + "如需换骨架请先驳回或在章节管理页人工处置既有章节。" + % ("技术" if std_mode else "")) # 幂等:清掉此前确认落库但尚未开写的旧骨架(驳回重提后再确认的场景) - await sor.sqlExe( - "DELETE FROM bid_chapters WHERE project_id=${p}$", {"p": project_id}) + if std_mode: + await sor.sqlExe( + "DELETE FROM bid_chapters WHERE project_id=${p}$ " + "AND (section='technical' OR section IS NULL OR section='')", + {"p": project_id}) + else: + await sor.sqlExe( + "DELETE FROM bid_chapters WHERE project_id=${p}$", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + # 标准模式:新骨架插回原技术章位置,全序列重排 order_no=1..N—— + # 不能只按 min(order_no)+idx 顺延:新骨架章数多于原技术章时会与排在 + # 技术部分之后的商务/报价章撞号,合成 ORDER BY order_no 会把报价章 + # 插进技术章中间(2026-09-17 状态机走查发现的边界缺陷)。 + _seq = [] # [(row_or_None, new_order)],None=新章占位 + if std_mode: + _others = sorted(_other_rows, key=lambda r: to_int(r.get("order_no"), 0)) + _tnums = [to_int(r.get("order_no"), 0) for r in _tech_rows + if to_int(r.get("order_no"), 0) > 0] + _ins = len(_others) + if _tnums: + _tmin = min(_tnums) + _ins = sum(1 for r in _others + if to_int(r.get("order_no"), 0) < _tmin) + _new_chs = [ch for ch in chapters if isinstance(ch, dict)] + _seq = ([(r, None) for r in _others[:_ins]] + + [(None, ch) for ch in _new_chs] + + [(r, None) for r in _others[_ins:]]) + else: + _seq = [(None, ch) for ch in chapters if isinstance(ch, dict)] n = 0 - for idx, ch in enumerate(chapters): + for idx, ent in enumerate(_seq): + row, ch = ent + if row is not None: + _no = idx + 1 + if to_int(row.get("order_no"), 0) != _no: + await sor.sqlExe( + "UPDATE bid_chapters SET order_no=${o}$ WHERE id=${i}$", + {"o": _no, "i": row.get("id")}) + continue if not isinstance(ch, dict): continue await sor.C("bid_chapters", { "id": new_id(), "project_id": project_id, - "chapter_no": str(ch.get("chapter_no") or (idx + 1))[:32], + "chapter_no": str(ch.get("chapter_no") or (n + 1))[:32], "title": str(ch.get("title") or "")[:255], "section": "technical", "outline": str(ch.get("outline") or "")[:60000], @@ -499,20 +545,36 @@ async def confirm_tech_template(human_task_id, decision, comment="", "order_no": idx + 1, }) n += 1 + await sor.sqlExe("COMMIT", {}) + # 裁剪摘要落库(kind='summary',list_tech_items 默认 kind='item' 不污染 + # 评估项清单):写者/QC 可追溯「技术章骨架按哪六类评估项裁剪、模版来源」, + # 也是 A3 段链激活过的持久证据(audit_log 之外的产出物侧记录)。 + try: + await record_tech_summary( + sor, project_id, n, + template_source=str(schema.get("template_source") or ""), + template_ref=str(schema.get("template_ref") or "")) + except Exception as _e: # 摘要失败不阻塞确认主流程 + logger.warning("record_tech_summary failed project=%s: %s", + project_id, str(_e)[:160]) await sor.sqlExe( "UPDATE pipeline_human_tasks SET status='done', qc_status='passed', " - "qc_comment='用户已确认技术方案骨架', result_data=${rd}$, " + "qc_comment=${cm}$, result_data=${rd}$, " "submitted_by=${u}$, submitted_at=NOW() WHERE id=${h}$", - {"rd": json.dumps({"decision": "confirm", "chapters": n}, ensure_ascii=False), + {"cm": "用户已确认技术方案骨架(标准流程:只替换技术章)" if std_mode + else "用户已确认技术方案骨架", + "rd": json.dumps({"decision": "confirm", "chapters": n, + "std_mode": std_mode}, ensure_ascii=False), "u": operator_id, "h": human_task_id}) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_chapters", project_id, "template_confirmed", to_state="pending", who=operator_id, - detail="骨架落库 %d 章" % n) + detail="骨架落库 %d 章%s" % (n, "(标准流程,替换技术章)" if std_mode else "")) await sor.sqlExe("COMMIT", {}) - logger.info("tech_template confirmed: project=%s chapters=%d by=%s", - project_id, n, operator_id) - return True, "骨架已确认并落库(%d 章),系统将自动开始分章编写。" % n + logger.info("tech_template confirmed: project=%s chapters=%d std_mode=%s by=%s", + project_id, n, std_mode, operator_id) + return True, ("骨架已确认并落库(%d 章技术章%s),系统将自动开始分章编写。" + % (n, ",商务/报价章未动" if std_mode else "")) # reject await sor.sqlExe( @@ -538,3 +600,44 @@ async def get_template_reject_comment(sor, project_id): if not isinstance(rd, dict): return "" return str(rd.get("comment") or "") + + +async def record_tech_summary(sor, project_id, n_chapters, template_source="", + template_ref=""): + """技术章骨架裁剪摘要落库(bid_tech_items kind='summary',幂等覆盖)。 + + 2026-09-17 裁剪链挂标准流程:用户确认骨架后把「按哪六类评估项裁剪、模版来源、 + 骨架章数」写成摘要行——写者/QC/审计可追溯技术方案部分的裁剪依据(list_tech_items + 默认 kind='item',摘要不污染评估项清单)。失败由调用方捕获,不阻塞确认主流程。 + """ + crecs = await sor.sqlExe( + "SELECT category, COUNT(*) AS c FROM " + TABLE + + " WHERE project_id=${p}$ AND kind='item' GROUP BY category", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + cats = {str(getattr(r, "category", "") or ""): to_int(getattr(r, "c", 0), 0) + for r in (crecs or [])} + lines = ["六类评估项覆盖:" + ("、".join( + "%s%d项" % (TECH_CATEGORY_LABELS.get(k, k), v) + for k, v in sorted(cats.items()) if v) or "(无)")] + if template_source: + lines.append("模版来源:%s%s" % ( + {"kb": "公司知识库预定模版", "rag": "RAG 知识库检索", + "web": "网络检索", "manual": "按行业惯例自拟"}.get( + template_source, template_source), + "(%s)" % template_ref[:200] if template_ref else "")) + lines.append("用户确认骨架:%d 章技术章(section=technical),裁剪链完成。" % n_chapters) + await sor.sqlExe( + "DELETE FROM " + TABLE + " WHERE project_id=${p}$ AND kind='summary'", + {"p": project_id}) + sid = new_id() + await sor.C(TABLE, { + "id": sid, "project_id": project_id, "kind": "summary", + "category": "", "item_name": "技术章骨架裁剪摘要", + "requirement": "\n".join(lines)[:20000], + "confidence": "high", "source_ref": "template_confirm", "order_no": 0, + }) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, TABLE, sid, "record_tech_summary", + who="system", detail="%d章 source=%s" % (n_chapters, template_source or "-")) + await sor.sqlExe("COMMIT", {}) + return sid diff --git a/pipeline_bidding/bid_write_capability.py b/pipeline_bidding/bid_write_capability.py index 0910451..c5dd848 100644 --- a/pipeline_bidding/bid_write_capability.py +++ b/pipeline_bidding/bid_write_capability.py @@ -130,6 +130,24 @@ async def chapter_detail(chapter_id, with_content="1", length=12000): "doc_requirements": reqs, "doc_requirements_binding": "explicit" if rq_bound else "fallback_by_no", } + # ── 技术方案部分:裁剪摘要注入(2026-09-17 裁剪链挂标准流程)── + # 技术章的骨架是按六类技术评估项裁剪出来的——写者必须知道裁剪依据才能 + # 对标编写(评估项=招标技术需求原文摘录,是本章内容的第一基准)。 + if (d.get("section") or "technical") == "technical": + try: + _sm = await sor.sqlExe( + "SELECT requirement FROM bid_tech_items WHERE project_id=${pid}$ " + "AND kind='summary' ORDER BY created_at DESC LIMIT 1", + {"pid": d.get("project_id")}) + await sor.sqlExe("COMMIT", {}) + if _sm: + out["tech_summary"] = str(getattr(_sm[0], "requirement", "") or "")[:2000] + out["tech_items_hint"] = ("本章骨架由技术标模版按招标技术需求裁剪而来——" + "编写时用 list_tech_items 查六类技术评估项" + "(功能清单/功能点/硬件配置/技术架构/功能架构/" + "部署架构),逐项对标响应,漏项=方案书必漏需求") + except Exception: + pass # 表不存在(老库未迁移)等场景静默降级,不阻塞写者 if rq_warn: out["doc_requirements_warning"] = rq_warn if str(with_content) in ("1", "true", "True"): diff --git a/wwwroot/api/bid_task_tree.dspy b/wwwroot/api/bid_task_tree.dspy index 36606ac..0694035 100644 --- a/wwwroot/api/bid_task_tree.dspy +++ b/wwwroot/api/bid_task_tree.dspy @@ -181,9 +181,10 @@ async with DBPools().sqlorContext(dbname) as sor: dim_labels = { 'scoring': '评分项与得分规则', 'quals': '资质清单与要求', 'reqs_outline': '投标文件要求与章节骨架', 'cost_benefit': '成本收益分析', + 'tech': '技术评估项(技术方案裁剪依据)', '__legacy__': '全量解析(维度拆分前)', } - order = ['scoring', 'quals', 'reqs_outline', 'cost_benefit', '__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: @@ -262,11 +263,12 @@ async with DBPools().sqlorContext(dbname) as sor: qc_labels = { 'scoring_items': '评分项 QC', 'qualifications': '资质 QC', 'doc_requirements': '投标文件要求 QC', 'chapter_outline': '章节骨架 QC', - 'cost_benefit': '成本收益分析 QC', + 'cost_benefit': '成本收益分析 QC', 'tech_items': '技术评估项 QC', '__none__': 'QC 审核', '__legacy__': 'QC 审核(早期)', } order = ['scoring_items', 'qualifications', 'doc_requirements', - 'chapter_outline', 'cost_benefit', '__none__', '__legacy__'] + '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: @@ -428,7 +430,7 @@ async with DBPools().sqlorContext(dbname) as sor: # reqs_outline 维度含两类审核记录,合并取每轮最大 DIM_QCS = {'scoring': ('scoring_items',), 'quals': ('qualifications',), 'reqs_outline': ('doc_requirements', 'chapter_outline'), - 'cost_benefit': ('cost_benefit',)} + 'cost_benefit': ('cost_benefit',), 'tech': ('tech_items',)} rev_by_round = {} for qt in DIM_QCS.get(key, ()): for rv in await sor.sqlExe(