yumoqing 5420bf49dd feat(bidding): 立项随附招标文件直接导入跳过人工上传——用户已提供招标书时不再卡在上传步骤
- create_bid_project 增加 tender_content 参数:带招标文件立项,落库+上传任务自动完成
- 新增 import_tender_file:已建项目补录招标文件正文/路径,跳过上传步骤
- 驾驶舱 prompt 硬规则:用户提供招标文件必须随立项/导入提交,不再让用户手动上传
2026-08-31 16:21:44 +08:00

507 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""投标产线能力包(bidding_general):主 agent(驾驶舱)工具 + 角色定义 + 菜单 + slash 命令。
与开发产线(sdlc_general)同构,通过 pipeline-core 的 PipelineAbility 注册表挂载,
零侵入 core / AgentExecutor:项目 sd_projects.pipeline_id='bidding_general' 时自动生效。
角色任务流转不用 RoleSpec.next_role(全部留空),一律由 bid_flow 状态对账器驱动 —— 因为投标
产线是「一章一任务 + 两级回退循环」的扇出结构,不是线性任务链。
"""
import json
import logging
from pipeline_core import (
ToolDefinition, PipelineAbility, RoleSpec, register_ability,
SlashCommand, register_slash_command,
)
logger = logging.getLogger("pipeline.bidding.ability")
PIPELINE_ID = "bidding_general"
# ══════════════════ 工具定义(主 agent / 驾驶舱)══════════════════
BID_TOOLS = [
# ── 立项 / 人员 / 招标文件(产线起点:从上传招标文件开始,无邮件采集)──
ToolDefinition(name="create_bid_project", description="创建投标项目(直接立项,不依赖招标信息)。用户说「创建/新建投标项目」时调用。若用户已提供招标文件(附件/正文),必须带上 tender_content,系统自动跳过上传步骤直接进入解析",
parameters={"name": "项目名称", "description": "项目描述(可选)",
"tender_content": "招标文件正文(用户已提供时必填,直接导入跳过上传步骤)",
"tender_file_name": "招标文件名(可选)"}, category="project"),
ToolDefinition(name="import_tender_file", description="导入招标文件(项目已建但还没上传时):把对话中用户提供的招标文件正文/文件路径直接导入,跳过人工上传步骤,系统自动开始解析",
parameters={"content_text": "招标文件正文", "file_path": "文件路径(与content_text二选一)",
"file_name": "文件名(可选)"}, category="project"),
ToolDefinition(name="assign_members", description="设定投标项目参与人员,如「张三:负责人,李四:writer」",
parameters={"members": "人员清单(姓名:角色 逗号分隔,或JSON数组)"}, category="project"),
ToolDefinition(name="list_members", description="查看投标项目参与人员", parameters={}, category="project"),
ToolDefinition(name="request_tender_file", description="发布/重发「等待上传招标文件」人类任务",
parameters={"note": "说明(可选)"}, category="project"),
ToolDefinition(name="list_tender_files", description="查看已上传的招标文件及抽取状态", parameters={}, category="project"),
# ── 解析结果 ──
ToolDefinition(name="list_scoring_items", description="查看评分项与得分规则(从招标文件抽取的结果)",
parameters={"section": "评分部分(可选 business/technical/price)"}, category="analysis"),
ToolDefinition(name="list_doc_requirements", description="查看投标文件要求(章节结构/格式/密封/份数/截止)",
parameters={"req_type": "类型(可选 structure/format/seal/copies/deadline)"}, category="analysis"),
ToolDefinition(name="list_qualifications", description="查看招标所需资质及匹配状态(matched/missing/human_required)",
parameters={"match_status": "匹配状态(可选)"}, category="analysis"),
ToolDefinition(name="qc_report", description="查看解析产出 QC 契合度审核总览(评分项/资质/投标文件要求/章节骨架各类的最新得分与放行状态)",
parameters={}, category="analysis"),
# ── 知识库 ──
ToolDefinition(name="search_bid_kb", description="检索公司投标知识库(资质证书/业绩案例/模板/财务/人员)",
parameters={"query": "关键词", "doc_type": "类型(可选 qualification/cert/case/template/finance/personnel)"},
category="kb"),
ToolDefinition(name="list_kb_docs", description="列出公司投标知识库文档",
parameters={"doc_type": "类型(可选)"}, category="kb"),
ToolDefinition(name="add_kb_doc", description="向公司投标知识库录入文档(资质证书/业绩案例等)",
parameters={"doc_name": "文档名称", "doc_type": "类型", "doc_no": "编号(可选)",
"tags": "标签(可选)", "file_path": "文件路径(可选)", "customer": "案例客户(可选)",
"contract_amount": "合同金额(可选)"}, category="kb"),
ToolDefinition(name="request_human_docs", description="发布「人类应提供的文件」清单任务(知识库缺失的资质由人工提供)",
parameters={"items": "清单(可选,默认自动汇总缺失资质)", "note": "说明(可选)"}, category="kb"),
# ── 章节 / 标书 / 评分 ──
ToolDefinition(name="list_chapters", description="查看投标文件章节及状态(pending/writing/written/approved/rejected)",
parameters={"status": "状态(可选)"}, category="bid"),
ToolDefinition(name="chapter_detail", description="查看章节详情(要点/对应评分项/评审意见/正文)",
parameters={"chapter_id": "章节ID"}, category="bid"),
ToolDefinition(name="list_bid_documents", description="查看已合成的标书版本及评分", parameters={}, category="bid"),
ToolDefinition(name="list_reviews", description="查看评审记录(章节级/整书级)",
parameters={"scope": "范围(可选 chapter/whole)", "chapter_id": "章节ID(可选)"}, category="bid"),
ToolDefinition(name="bid_score_report", description="查看最近一轮整书评分报告(总分+逐项明细+改进意见)",
parameters={}, category="bid"),
# ── 诊断 / 推进 ──
ToolDefinition(name="diagnose_bid", description="诊断投标项目:卡在哪个阶段、门禁、章节分布、在办任务、最近评分",
parameters={}, category="agent"),
ToolDefinition(name="advance_bid", description="立即触发投标流程状态对账(补派应有的角色任务,通常无需手动调用)",
parameters={}, category="agent"),
]
# ══════════════════ 产线 prompt 片段 ══════════════════
BID_PROMPT = """你是「投标产线」的驾驶舱 agent,负责从上传招标文件到标书交付的全流程管理:
创建投标项目 → 上传招标文件 → 解析(评分项/得分规则/资质/投标文件要求/章节骨架)
→ 解析产出 QC 契合度审核 → 资质文档准备 → 分章节编写 → 章节评审 → 合成标书 → 整书评分 → 交付确认。
招标信息的采集/摘要/审批归商机产线,本产线从「上传招标文件」起步;用户要采集招标信息时说明去商机产线。
## 流转由机制驱动(重要)
角色任务由系统状态对账器自动派发(每 15 秒一轮),你**不需要**手动为每个章节创建任务:
- 解析产出须经 QC 契合度审核:评分标准/得分规则/资质/投标文件要求/章节骨架逐类对照招标文件原文
按 10 分制打分,得分高于通过分(默认 9.5)才放行;不通过给改进意见 → 清空产出 → 解析重做 → 重审
- 章节评审不达标 → 章节自动退回重写
- 整书评分不达标 → 改进意见自动落到问题章节并退回重写 → 重新合成 → 重新评分
你的职责是:立项与配人、推动上传招标文件、处理卡点与提问、回答评审/评分中的人类决策。
只有诊断到「机制该派任务却没派」时,才用 advance_bid 手动触发一次对账,或用 diagnose_bid 查根因。
## 阻塞门禁(不要绕过)
以下人类任务 pending 时,系统不会给该项目派发任何 agent 任务:
- 等待上传招标文件(tender_file_upload,上传后自动完成)
- 等待人类提供资质文件(human_docs_request)
- 解析产出 QC 连续不达标(qc_escalation,人工修正产出或在审核记录页强制放行)
遇到项目"不动了",先 diagnose_bid 看是不是卡在门禁上,缺什么就催人补什么,不要硬推。
## 典型场景
- 用户说「创建一个投标项目,叫 XXX」→ create_bid_project(name=XXX)。**若用户同时提供了招标文件(附件/正文),必须把它放进 tender_content 一起提交**,系统直接导入并跳过人工上传步骤,自动开始解析——不要再让用户去界面手动上传。
- 项目已建、用户随后补交招标文件(对话正文或已写入工作空间的文件)→ import_tender_file 导入,跳过上传步骤。
- 用户问「招标文件上传了吗 / 解析出来了吗」→ list_tender_files 看上传与抽取状态。
- 用户问「解析质量怎么样 / QC 过了吗」→ qc_report,讲清哪类产出通过、哪类还在改。
- 用户问进展 → diagnose_bid(含章节分布、在办任务、最近评分),不要只报"正在进行"。
- 用户问「能得多少分 / 差在哪」→ bid_score_report + list_reviews,讲清失分项与改进意见落到了哪个章节。
- 用户要补资质/案例 → add_kb_doc 录入公司投标知识库;知识库缺的用 request_human_docs 让人工提供。
## 硬规则
- 招标金额、投标时间、资质要求这些要素,只能来自招标文件原文或人工确认,**禁止编造**;
抽取不到就如实说"文件未载明",必要时 ask_user。
- 标书内容的案例与业绩必须来自公司投标知识库的真实合同(bid_kb_docs 里 doc_type=case),
禁止虚构客户名、合同金额、历史成果数字。
- 章节没全部评审通过,不允许合成标书;评分没过门限,不允许当作可交付。"""
# ══════════════════ 角色集 ══════════════════
BID_ROLES = [
RoleSpec(
name="agent.tender_analyst",
description="招标文件分析师",
aliases=["tender_analyst", "analyst", "analysis"],
system_prompt="你是招标文件分析师。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。",
next_role="",
task_title="招标文件解析",
),
RoleSpec(
name="agent.bid_prep",
description="投标资料专员",
aliases=["bid_prep", "prep", "qualification"],
system_prompt="你是投标资料专员。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。",
next_role="",
task_title="投标资料准备",
),
RoleSpec(
name="agent.bid_writer",
description="标书撰写工程师",
aliases=["bid_writer", "writer", "write"],
system_prompt="你是标书撰写工程师。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。",
next_role="",
task_title="章节编写",
),
RoleSpec(
name="agent.bid_reviewer",
description="标书评审员",
aliases=["bid_reviewer", "reviewer", "chapter_review"],
system_prompt="你是标书评审员。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。",
next_role="",
task_title="章节评审",
),
RoleSpec(
name="agent.bid_compositor",
description="标书合成工程师",
aliases=["bid_compositor", "compositor", "compose"],
system_prompt="你是标书合成工程师。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。",
next_role="",
task_title="标书合成",
),
RoleSpec(
name="agent.bid_scorer",
description="标书评分员(模拟评标)",
aliases=["bid_scorer", "scorer", "score"],
system_prompt="你是标书评分员(模拟评标专家)。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。",
next_role="",
task_title="整书评审与评分",
),
RoleSpec(
name="agent.pm",
description="投标项目经理(任务验收)",
aliases=["pm", "manager"],
system_prompt="你是投标项目经理。先 load_skill 加载 role 技能,按其中的职责与规范验收任务。",
next_role="",
),
RoleSpec(
name="agent.qc",
description="投标质量控制(合规检查)",
aliases=["qc", "quality"],
system_prompt="你是投标质量控制工程师。先 load_skill 加载 role 技能,按其中的职责与规范检查交付件。",
next_role="",
),
]
# ══════════════════ handler(签名 async def handler(sor, params, ctx) -> str)══════════════════
def _need_project(ctx):
pid = ctx.get("project_id", "") or ""
return pid, ("请先切换到投标项目(用 switch_project 或在项目列表选择)" if not pid else "")
def _fmt(ok, msg):
return ("OK: " if ok else "ERROR: ") + str(msg)
def _fmt_rows(rows, empty="(空)"):
if not rows:
return empty
if isinstance(rows, str):
return rows
return "\n".join(json.dumps(r, ensure_ascii=False, default=str) for r in rows[:80])
async def _h_create_bid_project(sor, p, ctx):
from .bid_tender_capability import create_bid_project
ok, msg = await create_bid_project(p.get("name", ""), description=p.get("description", ""),
tender_content=p.get("tender_content", ""),
tender_file_name=p.get("tender_file_name", ""),
org_id=ctx.get("org_id", "") or "",
created_by=ctx.get("user_id", "") or "",
who="agent.main_agent")
return _fmt(ok, msg)
async def _h_import_tender_file(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_tender_capability import import_tender_file
ok, msg = await import_tender_file(pid, content_text=p.get("content_text", ""),
file_path=p.get("file_path", ""),
file_name=p.get("file_name", ""),
who="agent.main_agent")
return _fmt(ok, msg)
async def _h_assign_members(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_tender_capability import assign_members
ok, msg = await assign_members(pid, members=p.get("members", ""), who="agent.main_agent")
return _fmt(ok, msg)
async def _h_list_members(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_tender_capability import list_members
return _fmt_rows(await list_members(pid), "尚未设定参与人员")
async def _h_request_tender_file(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_tender_capability import request_tender_file
ok, msg = await request_tender_file(pid, note=p.get("note", ""), who="agent.main_agent")
return _fmt(ok, msg)
async def _h_list_tender_files(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_tender_capability import list_tender_files
return _fmt_rows(await list_tender_files(pid), "尚未上传招标文件")
async def _h_list_scoring_items(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_analysis_capability import list_scoring_items
return _fmt_rows(await list_scoring_items(pid, section=p.get("section", "") or ""),
"尚无评分项(等招标文件解析)")
async def _h_list_doc_requirements(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_analysis_capability import list_doc_requirements
return _fmt_rows(await list_doc_requirements(pid, req_type=p.get("req_type", "") or ""),
"尚无投标文件要求(等招标文件解析)")
async def _h_list_qualifications(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_kb_capability import list_qualifications
return _fmt_rows(await list_qualifications(pid, match_status=p.get("match_status", "") or ""),
"尚无资质要求(等招标文件解析)")
async def _h_search_bid_kb(sor, p, ctx):
from .bid_kb_capability import search_bid_kb
return _fmt_rows(await search_bid_kb(query=p.get("query", "") or "",
doc_type=p.get("doc_type", "") or "",
org_id=ctx.get("org_id", "") or ""),
"知识库无命中")
async def _h_list_kb_docs(sor, p, ctx):
from .bid_kb_capability import list_kb_docs
return _fmt_rows(await list_kb_docs(doc_type=p.get("doc_type", "") or "",
org_id=ctx.get("org_id", "") or ""),
"公司投标知识库为空(用 add_kb_doc 录入资质证书/业绩案例)")
async def _h_add_kb_doc(sor, p, ctx):
from .bid_kb_capability import add_kb_doc
ok, msg = await add_kb_doc(
p.get("doc_name", ""), doc_type=p.get("doc_type", "") or "qualification",
doc_no=p.get("doc_no", ""), tags=p.get("tags", ""), file_path=p.get("file_path", ""),
content_text=p.get("content_text", ""), issuer=p.get("issuer", ""),
customer=p.get("customer", ""), contract_amount=p.get("contract_amount", ""),
valid_until=p.get("valid_until", ""), org_id=ctx.get("org_id", "") or "0",
who="agent.main_agent")
return _fmt(ok, msg)
async def _h_request_human_docs(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_kb_capability import request_human_docs
ok, msg = await request_human_docs(pid, items=p.get("items", ""), note=p.get("note", ""),
who="agent.main_agent")
return _fmt(ok, msg)
async def _h_qc_report(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_qc_capability import qc_report
return await qc_report(pid)
async def _h_list_chapters(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_write_capability import list_chapters
return _fmt_rows(await list_chapters(pid, status=p.get("status", "") or ""),
"尚无章节(等招标文件解析生成章节骨架)")
async def _h_chapter_detail(sor, p, ctx):
from .bid_write_capability import chapter_detail
return await chapter_detail(p.get("chapter_id", ""))
async def _h_list_bid_documents(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_compose_capability import list_bid_documents
return _fmt_rows(await list_bid_documents(pid), "尚未合成标书")
async def _h_list_reviews(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_review_capability import list_reviews
return _fmt_rows(await list_reviews(pid, scope=p.get("scope", "") or "",
chapter_id=p.get("chapter_id", "") or ""),
"尚无评审记录")
async def _h_bid_score_report(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_score_capability import bid_score_report
return await bid_score_report(pid)
async def _h_diagnose_bid(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_flow import diagnose_bid_project
return await diagnose_bid_project(pid)
async def _h_advance_bid(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_flow import reconcile_project
acts = await reconcile_project(sor, pid)
return "对账结果:\n" + "\n".join("- " + a for a in (acts or ["无动作"]))
BID_HANDLERS = {
"create_bid_project": _h_create_bid_project,
"import_tender_file": _h_import_tender_file,
"assign_members": _h_assign_members,
"list_members": _h_list_members,
"request_tender_file": _h_request_tender_file,
"list_tender_files": _h_list_tender_files,
"list_scoring_items": _h_list_scoring_items,
"list_doc_requirements": _h_list_doc_requirements,
"list_qualifications": _h_list_qualifications,
"qc_report": _h_qc_report,
"search_bid_kb": _h_search_bid_kb,
"list_kb_docs": _h_list_kb_docs,
"add_kb_doc": _h_add_kb_doc,
"request_human_docs": _h_request_human_docs,
"list_chapters": _h_list_chapters,
"chapter_detail": _h_chapter_detail,
"list_bid_documents": _h_list_bid_documents,
"list_reviews": _h_list_reviews,
"bid_score_report": _h_bid_score_report,
"diagnose_bid": _h_diagnose_bid,
"advance_bid": _h_advance_bid,
}
def _merge_shared():
"""并入引擎级通用任务/交付件/问答/项目工具(不重复实现,见 pipeline_service.shared_ability)。"""
try:
from pipeline_service.shared_ability import SHARED_TOOLS, SHARED_HANDLERS
except Exception as e: # pipeline_service 未安装/版本旧 → 只用投标专属工具,诚实降级
logger.warning("shared_ability 不可用,投标产线仅挂载专属工具: %s", str(e)[:120])
return list(BID_TOOLS), dict(BID_HANDLERS)
names = {t.name for t in BID_TOOLS}
tools = list(BID_TOOLS) + [t for t in SHARED_TOOLS if t.name not in names]
handlers = dict(SHARED_HANDLERS)
handlers.update(BID_HANDLERS) # 同名以投标产线实现为准
return tools, handlers
def register_bid_ability():
"""注册投标产线能力包(幂等)。"""
tools, handlers = _merge_shared()
ability = PipelineAbility(
pipeline_id=PIPELINE_ID,
name="通用投标产线",
tools=tools,
system_prompt=BID_PROMPT,
handlers=handlers,
roles=BID_ROLES,
menus=[
{"label": "📄 招标文件", "icon": "", "url": "/pipeline-bidding/bid_tender_files/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "📑 评分项", "icon": "", "url": "/pipeline-bidding/bid_scoring_items/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "🔍 解析QC审核", "icon": "", "url": "/pipeline-bidding/bid_qc_reviews/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "📝 标书章节", "icon": "", "url": "/pipeline-bidding/bid_chapters/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "🎯 资质", "icon": "", "url": "/pipeline-bidding/bid_qualifications/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "📚 投标知识库", "icon": "", "url": "/pipeline-bidding/bid_kb_docs/index.ui",
"type": "popup", "width": "88%", "height": "82%"},
{"label": "📦 标书", "icon": "", "url": "/pipeline-bidding/bid_documents/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
],
)
register_ability(ability)
return ability
# ══════════════════ slash 命令(产线专属)══════════════════
async def _slash_files(args, ctx):
ex = ctx.get("executor")
if not ex:
return "无执行器上下文"
return await ex._execute_ability_tool("list_tender_files", {})
async def _slash_bid(args, ctx):
ex = ctx.get("executor")
if not ex:
return "无执行器上下文"
return await ex._execute_ability_tool("diagnose_bid", {})
async def _slash_chapters(args, ctx):
ex = ctx.get("executor")
if not ex:
return "无执行器上下文"
return await ex._execute_ability_tool("list_chapters", {"status": (args or "").strip()})
async def _slash_score(args, ctx):
ex = ctx.get("executor")
if not ex:
return "无执行器上下文"
return await ex._execute_ability_tool("bid_score_report", {})
def register_bid_slash_commands():
for cmd in [
SlashCommand("files", "列出招标文件及解析状态", _slash_files, "pipeline", PIPELINE_ID),
SlashCommand("bid", "诊断当前投标项目卡点", _slash_bid, "pipeline", PIPELINE_ID),
SlashCommand("chapters", "列出标书章节及状态", _slash_chapters, "pipeline", PIPELINE_ID),
SlashCommand("score", "查看最近一轮整书评分报告", _slash_score, "pipeline", PIPELINE_ID),
]:
register_slash_command(cmd)
# import 即注册(与 sdlc_ability 一致:宿主 import pipeline_bidding 即生效)
register_bid_ability()
register_bid_slash_commands()