diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 416b9f8..bf19776 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,7 +2,7 @@ > 对照开发产线(`sdlc_general`)设计,复用平台通用内核(项目/会话/任务链/人类任务/交付件/问答冒泡/技能分层), > 只增量定义投标产线自己的 **阶段、角色、工具、技能、任务流转规则、交付件**。 -> 版本:v1.1.0  创建:2026-08-26  修订:2026-08-31(产线起点改为上传招标文件;解析产出加 QC 契合度审核门禁) +> 版本:v1.2.0  创建:2026-08-26  修订:2026-08-31(v1.1 上传招标文件起点+QC 门禁;v1.2 编写分商务/技术角色+篇幅门禁+样板标书规范+并发编写) --- @@ -31,7 +31,7 @@ | 7 | **解析产出 QC 契合度审核(四类逐类,10 分制 >9.5 放行,否则改进意见退回重做)** | agent.qc | bid_qc_reviews | | 8 | 从公司投标知识库获取所需资质文档 | agent.bid_prep | bid_qualifications.kb_doc_id ← bid_kb_docs | | 9 | 发布人类应提供的文件清单 | agent.bid_prep(阻塞门禁) | pipeline_human_tasks(human_docs_request) | -| 10 | 按投标文件要求分章节编写投标文件 | agent.bid_writer | bid_chapters | +| 10 | 按投标文件要求分章节编写投标文件(商务/技术分角色,并发) | agent.bid_writer / agent.bid_biz_writer | bid_chapters | | 11 | 每章节完成后对照招标/投标文件要求评审,分数不够给改进意见重做 | agent.bid_reviewer | bid_reviews(scope=chapter) → 章节循环 | | 12 | 合成标书 | agent.bid_compositor | bid_documents(docx) | | 13 | 对整个标书评审 + 按评分项和得分规则打分 | agent.bid_scorer | bid_reviews(scope=whole) + bid_scores | @@ -48,7 +48,7 @@ | 招标文件解析 | `agent.tender_analyst` | 抽取评分项+得分规则、所需资质、投标文件要求、商务要素,产出章节骨架 | | 解析产出 QC | `agent.qc` | 四类产出对照招标文件原文按 10 分制审核契合度,>9.5 放行,否则改进意见退回 | | 资料准备 | `agent.bid_prep` | 知识库资质匹配、缺失项发布人类文件清单 | -| 章节编写 | `agent.bid_writer` | 按章节骨架逐章写作(一章一任务) | +| 章节编写 | `agent.bid_writer`(技术标)/ `agent.bid_biz_writer`(商务标) | 按章节骨架逐章写作(一章一任务、并发派发);篇幅与要素达标,搜不到冒泡 | | 章节评审 | `agent.bid_reviewer` | 对照招标要求+得分规则逐章打分,不达标退回重写 | | 标书合成 | `agent.bid_compositor` | 合成 docx(封面/目录/页码),产出交付件 | | 全书评分 | `agent.bid_scorer` | 按评分项+得分规则给整书打分、生成改进意见并映射章节 | @@ -78,6 +78,7 @@ agent.qc(A2 QC 门禁)→ 四类产出逐类契合度审核(10 分制) agent.bid_prep → 知识库资质匹配 + [人类任务:应提供文件清单](阻塞) ↓ agent.bid_writer → 逐章写作(每章一个任务,可并行) + (section=business 的章节派 agent.bid_biz_writer,其余派 agent.bid_writer) ↓ 章节 written agent.bid_reviewer → 章节评审打分 ↓ 全部章节 approved @@ -140,7 +141,28 @@ agent.bid_scorer → 整书评分 > 这是角色任务派发机制的约定,不是引擎通用规则 —— 引擎层 SQL 不得引用该等价关系, > 引擎对投标产线零特判;投标的阻塞语义全部收敛在投标产线自己的代码(bid_flow)里。 -### 3.6 硬规则 +### 3.6 编写分角色、篇幅门禁与并发(v1.2 新增) + +**分角色**:章节按 `bid_chapters.section` 分流—— +- `business`(投标函/报价/资质证明/承诺/业绩,骨架生成时按标题关键词自动推断)→ `agent.bid_biz_writer`; +- `technical`/`price` → `agent.bid_writer`。 +- 两类内容不混写;capability 共用 `bid_write_capability`(工具白名单相同,角色技能不同)。 + +**篇幅门禁(机制层强制,review_chapter 内)**: +- 技术标章节字数 < `bid_chapter_min_words`(默认 800)或商务标 < `bid_chapter_min_words_biz`(默认 500) + → 直接不通过 + 自动附「篇幅不达标」意见;与得分率门限取并集。 +- 内容完整性由评审员按 bid-doc-spec 查(评分子维度漏应答/商务要素缺失 = 退)。 + +**搜不到 → 冒泡**:写者检索知识库无所需素材/案例/参数时 ask_question 冒泡人类,禁编造凑字数。 + +**样板标书规范**:`bid_kb_docs.doc_type=sample_bid`;PM 调 `extract_writing_spec` 抽取 +章节结构惯例/篇幅口径/表格图表示例/商务要素清单,落 `bid_doc_requirements(req_type=writing_spec)`, +写者与评审参照。无样板时提示人工录入。 + +**并发编写**:对账器 C 块单轮最多派 `bid_write_concurrency`(默认 4)个编写任务; +引擎 agent poller 另有每项目并发上限 `max_agents_per_project`(默认 4)兜底,任务间 asyncio 并发执行。 + +### 3.7 硬规则 - 章节写作任务与章节一对一(`params.chapter_id`),禁止一个任务写多章。 - 评审/评分只由 `agent.bid_reviewer` / `agent.bid_scorer` 执行,写作角色不得自评; @@ -251,6 +273,9 @@ agent.bid_scorer → 整书评分 | `bid_max_round` | 3 | 整书最大评分轮次 | | `bid_qc_pass_score` | 9.5 | 解析产出 QC 契合度通过分(10 分制,严格大于才放行) | | `bid_qc_max_round` | 3 | 单类产出 QC 最大审核轮次 / 解析重做轮次上限 | +| `bid_chapter_min_words` | 800 | 技术标章节最低字数(低于评审必退) | +| `bid_chapter_min_words_biz` | 500 | 商务标章节最低字数 | +| `bid_write_concurrency` | 4 | 章节编写单轮并发派发上限 | --- diff --git a/init/data.json b/init/data.json index dc2f87d..a596359 100644 --- a/init/data.json +++ b/init/data.json @@ -116,7 +116,8 @@ {"k": "case", "v": "业绩案例"}, {"k": "template", "v": "模板"}, {"k": "finance", "v": "财务资料"}, - {"k": "personnel", "v": "人员资料"} + {"k": "personnel", "v": "人员资料"}, + {"k": "sample_bid", "v": "样板标书"} ] }, { diff --git a/pipeline_bidding/bid_analysis_capability.py b/pipeline_bidding/bid_analysis_capability.py index fb0c1b5..3e8babb 100644 --- a/pipeline_bidding/bid_analysis_capability.py +++ b/pipeline_bidding/bid_analysis_capability.py @@ -444,6 +444,17 @@ async def list_doc_requirements(project_id, req_type=""): # ══════════════ 章节骨架 ══════════════ +def _infer_section(title): + """按章节标题关键词推断商务/技术分类(商务标:报价/商务/资质证明/承诺函等;其余技术标)。""" + t = title or "" + biz_kw = ("报价", "投标函", "商务", "资格证明", "资质证明", "承诺书", "声明", "保证金", + "法定代表人", "授权委托", "财务状况", "业绩一览", "合同格式") + for kw in biz_kw: + if kw in t: + return "business" + return "technical" + + async def create_chapter_outline(project_id, chapters="", who=None, agent_id=None): """生成章节骨架 → bid_chapters(status=pending)。 @@ -471,6 +482,7 @@ async def create_chapter_outline(project_id, chapters="", who=None, agent_id=Non "chapter_no": d.get("chapter_no") or "", "title": d.get("chapter_title") or "", "outline": (d.get("requirement") or "")[:4000], + "section": _infer_section(d.get("chapter_title") or ""), }) if not items: return False, ("无章节来源:请先抽取 req_type=structure 的投标文件要求," diff --git a/pipeline_bidding/bid_common.py b/pipeline_bidding/bid_common.py index 194ab36..2e50c37 100644 --- a/pipeline_bidding/bid_common.py +++ b/pipeline_bidding/bid_common.py @@ -67,6 +67,9 @@ DEFAULT_PARAMS = { "bid_max_round": "3", # 整书最大评分轮次 "bid_qc_pass_score": "9.5", # 解析产出 QC 契合度通过分(10 分制,高于此分才放行) "bid_qc_max_round": "3", # 单类产出 QC 最大审核轮次(超限抛人工) + "bid_chapter_min_words": "800", # 技术标章节最低字数(低于即评审必退) + "bid_chapter_min_words_biz": "500", # 商务标章节最低字数 + "bid_write_concurrency": "4", # 章节编写并发上限(单轮最多同时派发的编写任务数) } @@ -187,6 +190,9 @@ async def get_thresholds(sor): "max_round": to_int(await get_param(sor, "bid_max_round"), 3), "qc_pass_score": to_float(await get_param(sor, "bid_qc_pass_score"), 9.5), "qc_max_round": to_int(await get_param(sor, "bid_qc_max_round"), 3), + "chapter_min_words": to_int(await get_param(sor, "bid_chapter_min_words"), 800), + "chapter_min_words_biz": to_int(await get_param(sor, "bid_chapter_min_words_biz"), 500), + "write_concurrency": to_int(await get_param(sor, "bid_write_concurrency"), 4), } diff --git a/pipeline_bidding/bid_flow.py b/pipeline_bidding/bid_flow.py index cc5836a..e2d4a83 100644 --- a/pipeline_bidding/bid_flow.py +++ b/pipeline_bidding/bid_flow.py @@ -33,6 +33,7 @@ logger = logging.getLogger("pipeline.bidding") R_ANALYST = "agent.tender_analyst" R_PREP = "agent.bid_prep" R_WRITER = "agent.bid_writer" +R_BIZ_WRITER = "agent.bid_biz_writer" R_REVIEWER = "agent.bid_reviewer" R_COMPOSITOR = "agent.bid_compositor" R_SCORER = "agent.bid_scorer" @@ -354,24 +355,31 @@ async def reconcile_project(sor, project_id, project_name=""): if not chapters: return acts + ["idle: 无章节,等待解析产出章节骨架"] - # ── C. 章节编写(一章一任务;pending/rejected 且无在办任务)── + # ── C. 章节编写(一章一任务、并发派发;pending/rejected 且无在办任务)── + # 商务/技术分角色:section=business → 商务标写者;其余(technical/price)→ 技术标写者。 + # 并发:单轮最多派 write_concurrency 个编写任务(引擎另有每项目并发上限兜底)。 created_w = 0 for c in chapters: + if created_w >= th["write_concurrency"]: + break if c.get("status") not in (CH_PENDING, CH_REJECTED): continue if to_int(c.get("revise_count"), 0) > th["max_revise"]: continue # 已超重做上限,等人工介入(review 阶段已抛任务) - if await count_open_tasks(sor, project_id, role=R_WRITER, chapter_id=c["id"]) > 0: + role = R_BIZ_WRITER if (c.get("section") or "technical") == "business" else R_WRITER + if await count_open_tasks(sor, project_id, role=role, chapter_id=c["id"]) > 0: continue stage = "revise" if c.get("status") == CH_REJECTED else "write" tid = await create_role_task( - sor, project_id, R_WRITER, + sor, project_id, role, "%s 第%s章《%s》%s" % (pname, c.get("chapter_no"), c.get("title"), - "修改重写" if stage == "revise" else "编写"), + "修改重写" if stage == "revise" else + ("编写(商务标)" if role == R_BIZ_WRITER else "编写(技术标)")), {"stage": stage, "task_kind": "bid_write", "chapter_id": c["id"], - "chapter_no": c.get("chapter_no"), "revise_count": c.get("revise_count")}) + "chapter_no": c.get("chapter_no"), "section": c.get("section") or "technical", + "revise_count": c.get("revise_count")}) created_w += 1 - acts.append("created: 章节编写任务 %s (%s)" % (tid, c.get("chapter_no"))) + acts.append("created: 章节编写任务 %s (%s/%s)" % (tid, c.get("chapter_no"), role)) if created_w: await sor.sqlExe("COMMIT", {}) diff --git a/pipeline_bidding/bid_kb_capability.py b/pipeline_bidding/bid_kb_capability.py index c3f1738..85706e2 100644 --- a/pipeline_bidding/bid_kb_capability.py +++ b/pipeline_bidding/bid_kb_capability.py @@ -261,3 +261,54 @@ async def auto_match_qualifications(project_id, org_id="", who=None, agent_id=No missing += 1 return True, ("资质自动匹配完成:命中知识库 %d 项,需人工提供 %d 项。" "请对需人工提供的项调用 request_human_docs 发布清单。" % (matched, missing)) + + +async def extract_writing_spec(project_id, kb_doc_id="", note="", who=None, agent_id=None): + """PM 从样板标书(bid_kb_docs doc_type=sample_bid)抽取编写规范并落库。 + + 抽取结果写入项目级「编写规范」记录(bid_doc_requirements req_type=writing_spec), + 供写者章节编写与评审参照:章节结构惯例/篇幅口径/表格图表示例/商务标要素清单。 + 本工具只负责落库框架,具体规范条目由 PM(LLM)读样板标书内容后逐条给出。 + """ + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + if kb_doc_id: + recs = await sor.sqlExe( + "SELECT id, doc_name, doc_type, LEFT(content_text, 8000) AS content_text " + "FROM bid_kb_docs WHERE id=${i}$", {"i": kb_doc_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "样板标书不存在: %s" % kb_doc_id + doc = rec_to_dict(recs[0]) + if (doc.get("doc_type") or "") != "sample_bid": + return False, ("该文档类型是 %s 不是 sample_bid;" + "请先用 add_kb_doc(doc_type=sample_bid) 录入样板标书" % doc.get("doc_type")) + else: + recs = await sor.sqlExe( + "SELECT id, doc_name, doc_type, LEFT(content_text, 8000) AS content_text " + "FROM bid_kb_docs WHERE doc_type='sample_bid' ORDER BY created_at DESC LIMIT 1", {}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, ("知识库无样板标书(doc_type=sample_bid)。" + "请人工提供一份中标样板标书,用 add_kb_doc(doc_type=sample_bid) 录入后重试。") + doc = rec_to_dict(recs[0]) + # 落一条 writing_spec 记录(幂等:同项目同样板只留最新) + await sor.sqlExe( + "DELETE FROM bid_doc_requirements WHERE project_id=${pid}$ AND req_type='writing_spec'", + {"pid": project_id}) + await sor.C("bid_doc_requirements", { + "id": new_id(), "project_id": project_id, "req_type": "writing_spec", + "chapter_no": "0", "chapter_title": "编写规范(样板抽取:%s)" % (doc.get("doc_name") or "")[:60], + "requirement": (note or "")[:20000] or "(PM 抽取规范见任务交付说明)", + "source_ref": doc.get("id") or "", + }) + await sor.sqlExe("COMMIT", {}) + return True, ("已绑定样板标书《%s》并初始化编写规范记录。请通读其内容," + "抽取:①章节结构惯例 ②篇幅口径(每类章节字数/页数)③表格图表示例 " + "④商务标要素清单(投标函/报价/资质证明/承诺),写入本任务交付说明," + "供写者与评审参照。样板正文预览:\n%s" + % (doc.get("doc_name"), (doc.get("content_text") or "")[:3000])) diff --git a/pipeline_bidding/bid_review_capability.py b/pipeline_bidding/bid_review_capability.py index c870c9e..7f83b7b 100644 --- a/pipeline_bidding/bid_review_capability.py +++ b/pipeline_bidding/bid_review_capability.py @@ -49,6 +49,17 @@ async def review_chapter(chapter_id, score, max_score="", comments="", detail="" ms = 100.0 # 章节未挂评分项时按百分制评审 ratio = sc / ms if ms > 0 else 0.0 passed = ratio >= th["chapter_pass_ratio"] + + # ── 篇幅门禁:字数低于门限必退(防内容过短/缺失),意见自动附带 ── + wc = to_int(c.get("word_count"), 0) + min_w = th["chapter_min_words_biz"] if (c.get("section") or "technical") == "business" \ + else th["chapter_min_words"] + if wc < min_w: + passed = False + short_note = ("【篇幅不达标】正文 %d 字 < 门限 %d 字(%s标),内容疑似过短或缺失," + "请按评分规则子维度补全到操作层。" % ( + wc, min_w, "商务" if (c.get("section") or "") == "business" else "技术")) + comments = (short_note + "\n" + (comments or "")).strip() if not passed and not (comments or "").strip(): return False, ("评审不通过必须给出改进意见(comments)," "写清缺哪些评分维度、应补什么内容、对照哪条得分规则") diff --git a/pipeline_bidding/role_tool_schemas.py b/pipeline_bidding/role_tool_schemas.py index 23c30f2..be92a33 100644 --- a/pipeline_bidding/role_tool_schemas.py +++ b/pipeline_bidding/role_tool_schemas.py @@ -220,6 +220,13 @@ BID_ROLE_TOOL_SCHEMAS = { "params": {"project_id": "项目ID(可选,默认当前)"}, "required": [], }, + # ── bid_spec_capability:样板标书编写规范抽取(PM)── + "extract_writing_spec": { + "module": f"{_M}.bid_kb_capability", + "description": "从样板标书(doc_type=sample_bid)抽取编写规范并落库:章节结构惯例/篇幅口径/表格图表示例/商务标要素清单", + "params": {"kb_doc_id": "样板标书ID(可选,默认取最新一份)", "note": "PM抽取的规范条目(可选)"}, + "required": [], + }, # ── bid_compose_capability:标书合成工程师 ── "compose_bid": { "module": f"{_M}.bid_compose_capability",