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行)
This commit is contained in:
yumoqing 2026-09-17 16:23:54 +08:00
parent 6ef74388c1
commit 2fe7c784be
7 changed files with 419 additions and 119 deletions

View File

@ -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.62026-09-17**:标准投标流程内置与纯技术方案
流程同源的三段链 `tech_analysis六类技术评估项对标招标文件技术需求部分→ tech_qc
qc_type=tech_items→ template_confirmPM 检索模版按评估项裁剪骨架 → 用户确认待办)`。
对账器共用段 `_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 建表未进 models2026-09-10 归籍) | project_id, kind, category, item_name, amount, confidence |
| `bid_tech_items` | 技术评估项(纯技术方案流程 tech_analysis 产出kind=item/summarycategory 六类m0034 建表) | project_id, kind, category, item_name, requirement, mandatory, source_ref |
| `bid_tech_items` | 技术评估项(纯技术方案流程与标准流程 A3 裁剪链 tech_analysis 产出kind=item/summarysummary=骨架裁剪摘要行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 /

View File

@ -2,7 +2,7 @@
> 对照开发产线(`sdlc_general`)设计,复用平台通用内核(项目/会话/任务链/人类任务/交付件/问答冒泡/技能分层),
> 只增量定义投标产线自己的 **阶段、角色、工具、技能、任务流转规则、交付件**
> 版本v1.2.0  创建2026-08-26  修订2026-08-31v1.1 上传招标文件起点+QC 门禁v1.2 编写分商务/技术角色+篇幅门禁+样板标书规范+并发编写)
> 版本v1.6.0  创建2026-08-26  修订2026-09-17v1.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_analysisagent.tender_analystanalysis_dim=tech六类技术评估项落 bid_tech_items
对标招标文件技术需求部分)
tech_qcagent.qcqc_type=tech_items对标招标原文10 分制同款门禁)
template_confirmagent.pmtask_kind=tech_templatefind_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` 执行,写作角色不得自评;

View File

@ -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"]),

View File

@ -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_confirmPM 检索模版按评估项裁剪 → 用户确认)→
# 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-17tech_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-17A3 段链激活时,技术章等「模版确认
# 完成、骨架已替换」才开写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_passedtech_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: 模版骨架任务 %sPM 检索模版→按评估项裁剪→发用户确认)" % 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: 模版骨架任务 %sPM 检索模版→拟骨架→发用户确认)" % tid]
await _reconcile_template_confirm(sor, project_id, pname,
FLOW_KEY_TECH_PROPOSAL, acts)
return acts or ["idle: 模版裁剪提案中"]
# ── T4. 分章编写(骨架已确认;一章一任务并发派发)──
created_w = 0

View File

@ -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

View File

@ -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"):

View File

@ -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(