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

221 lines
10 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.

"""投标项目能力:立项 / 参与人员 / 等待上传招标文件。
产线起点是「上传招标文件」:招标信息的采集/摘要/审批归商机产线,本产线不再包含。
招标商务要素(项目名称/招标人/截止时间/预算等)由招标文件解析阶段从文件中抽取,
落 bid_tenders(source=tender_doc),见 bid_analysis_capability._upsert_tender_facts。
状态机规范见技能 `bid-workflow`;本模块只固化操作原语(CAS 迁移 + 审计),合法性由 LLM 读技能判断。
"""
import json
import logging
from .bid_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, json_loads, to_float, to_int,
record, create_human_task, find_human_task, get_project,
PIPELINE_ID, HT_TENDER_FILE,
)
logger = logging.getLogger("pipeline.bidding.tender")
# ══════════════ 立项 / 人员 / 招标文件 ══════════════
async def create_bid_project(name, description="", org_id="", created_by="",
tender_content="", tender_file_name="",
who=None, agent_id=None):
"""直接创建投标项目(产线起点)+ 发「上传招标文件」任务。
立项不经过邮件采集/审批(那是商机产线的职责):用户/agent 直接创建投标项目,
随后上传招标文件进入解析。
用户已提供招标文件(附件/正文)时传 tender_content:直接落库并跳过人工上传步骤,
对账器下一轮即派发解析。
"""
if not name or not str(name).strip():
return False, "缺少项目名称(name)"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
pid = new_id()
pname = str(name).strip()[:190]
await sor.C("sd_projects", {
"id": pid,
"name": pname,
"description": (description or "")[:4000],
"project_type": "bidding",
"pipeline_id": PIPELINE_ID,
"status": "in_progress",
"org_id": org_id or "0",
"created_by": created_by or (who or "agent.pm"),
})
await sor.sqlExe("COMMIT", {})
await record(sor, pid, "sd_projects", pid, "create_bid_project",
to_state="in_progress", who=who, agent_id=agent_id,
detail="direct create")
txt = str(tender_content or "").strip()
if txt:
# 用户已提供招标文件:直接落库,上传任务创建即完成(跳过人工上传步骤)
fname = str(tender_file_name or "招标文件(对话提供)")[:250]
await sor.C("bid_tender_files", {
"id": new_id(), "project_id": pid,
"file_name": fname, "file_type": "tender_doc",
"content_text": txt[:2000000], "file_size": len(txt.encode("utf-8", "ignore")),
"extract_status": "uploaded",
"uploaded_by": created_by or (who or "agent.main_agent"),
})
hid = await create_human_task(
sor, pid, HT_TENDER_FILE,
"上传招标文件:%s" % pname,
"立项时已随附招标文件,跳过人工上传步骤。",
assignee_role="owner.superuser",
form_schema={"project_id": pid})
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', qc_status='passed', "
"qc_comment='招标文件随立项提供,系统自动完成(跳过上传步骤)', "
"submitted_at=NOW() WHERE id=${h}$", {"h": hid})
await sor.sqlExe("COMMIT", {})
await record(sor, pid, "bid_tender_files", hid, "import_tender_file",
to_state="done", who=who, agent_id=agent_id,
detail="tender content provided at creation, upload skipped")
return True, ("project_id=%s(招标文件已导入 %d 字,上传步骤已跳过,"
"系统将自动开始解析)" % (pid, len(txt)))
hid = await create_human_task(
sor, pid, HT_TENDER_FILE,
"上传招标文件:%s" % pname,
("项目已创建,请上传招标文件(含答疑/补遗文件)。\n"
"上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求、章节骨架)。"),
assignee_role="owner.superuser",
form_schema={"project_id": pid})
return True, "project_id=%s(已发上传招标文件任务 %s)" % (pid, hid)
async def import_tender_file(project_id, content_text="", file_path="", file_name="",
who=None, agent_id=None):
"""导入招标文件(已创建的项目补录):落库 bid_tender_files 并跳过人工上传步骤。
content_text 与 file_path 至少给一个。导入后上传任务自动完成,对账器即派解析。
"""
if not project_id:
return False, "缺少 project_id"
txt = str(content_text or "").strip()
fp = str(file_path or "").strip()
if not txt and not fp:
return False, "缺少招标文件内容:请提供 content_text(正文)或 file_path(文件路径)"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
await sor.C("bid_tender_files", {
"id": new_id(), "project_id": project_id,
"file_name": str(file_name or ("招标文件(对话提供)" if txt else fp.split("/")[-1]))[:250],
"file_type": "tender_doc",
"content_text": txt[:2000000] if txt else "",
"file_path": fp[:500],
"file_size": len(txt.encode("utf-8", "ignore")) if txt else None,
"extract_status": "uploaded",
"uploaded_by": who or "agent.main_agent",
})
await sor.sqlExe("COMMIT", {})
ht = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending")
if ht:
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', qc_status='passed', "
"qc_comment='招标文件已导入,系统自动完成(跳过上传步骤)', "
"submitted_at=NOW() WHERE id=${h}$", {"h": ht.get("id")})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_tender_files", project_id, "import_tender_file",
to_state="uploaded", who=who, agent_id=agent_id,
detail=("text %d chars" % len(txt)) if txt else ("path %s" % fp))
return True, ("招标文件已导入(%s),上传步骤已跳过,系统将自动开始解析。"
% ("正文 %d 字" % len(txt) if txt else file_path))
async def assign_members(project_id, members="", who=None, agent_id=None):
"""设定参与人员。members 支持 JSON 数组或 "姓名:角色,姓名:角色" 简写。"""
if not project_id:
return False, "缺少 project_id"
items = []
ms = members
if isinstance(ms, str) and ms.strip():
parsed = json_loads(ms, None)
if isinstance(parsed, list):
items = parsed
else:
for seg in ms.split(","):
seg = seg.strip()
if not seg:
continue
if ":" in seg or ":" in seg:
nm, rl = seg.replace(":", ":").split(":", 1)
items.append({"user_name": nm.strip(), "member_role": rl.strip()})
else:
items.append({"user_name": seg, "member_role": "writer"})
elif isinstance(ms, list):
items = ms
if not items:
return False, "未解析出参与人员(示例:张三:负责人,李四:writer)"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
n = 0
for it in items:
if not isinstance(it, dict):
continue
nm = str(it.get("user_name") or it.get("name") or "").strip()
if not nm:
continue
exist = await sor.sqlExe(
"SELECT id FROM bid_members WHERE project_id=${pid}$ AND user_name=${nm}$ LIMIT 1",
{"pid": project_id, "nm": nm})
await sor.sqlExe("COMMIT", {})
if exist:
continue
await sor.C("bid_members", {
"id": new_id(), "project_id": project_id,
"user_id": str(it.get("user_id") or ""), "user_name": nm[:120],
"member_role": str(it.get("member_role") or "writer")[:60],
"duty": str(it.get("duty") or "")[:480],
"status": "active", "created_by": who or "",
})
n += 1
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_members", project_id, "assign_members",
who=who, agent_id=agent_id, detail="新增 %d 人" % n)
return True, "已设定参与人员 %d 人" % n
async def list_members(project_id):
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT user_name, member_role, duty, status FROM bid_members "
"WHERE project_id=${pid}$ ORDER BY created_at", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
async def request_tender_file(project_id, note="", who=None, agent_id=None):
"""(重新)发布「等待上传招标文件」人类任务。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
p = await get_project(sor, project_id)
if not p:
return False, "项目不存在: %s" % project_id
exist = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending")
if exist:
return True, "已有待办上传任务: %s" % exist.get("id")
hid = await create_human_task(
sor, project_id, HT_TENDER_FILE,
"上传招标文件:%s" % p.get("name", ""),
note or "请上传招标文件(含答疑/补遗),上传后自动进入解析阶段。")
await sor.sqlExe("COMMIT", {})
return True, "已发布上传招标文件任务: %s" % hid
async def list_tender_files(project_id):
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, file_name, file_type, extract_status, file_size, created_at "
"FROM bid_tender_files WHERE project_id=${pid}$ ORDER BY created_at",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)