pipeline-bidding/pipeline_bidding/bid_tender_capability.py

302 lines
15 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
import os
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]
# 创建时定死项目目录并写库(统一机制:解析只读库不推导,
# 保证上传落盘=agent工作目录=工作控件显示目录)
from pipeline_service.workspace import alloc_project_dir
ws_path, dname = await alloc_project_dir(sor, org_id or "0", PIPELINE_ID, pname, pid)
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"),
"workspace_dir": ws_path,
"directory_name": dname,
})
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="",
project_name="", org_id="", created_by="",
who=None, agent_id=None):
"""导入招标文件;未建项目时自动立项(产线新起点:收到招标书直接启动,2026-09-01)。
- 有 project_id:落库 bid_tender_files 并跳过人工上传步骤;
- 无 project_id:先自动创建投标项目(名称取 project_name,缺省用文件名/「投标项目」),
再导入招标文件——用户不必先走立项步骤,收到招标书即启动项目。
content_text 与 file_path 至少给一个。导入后上传任务自动完成,对账器即派解析。
"""
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:
pid = str(project_id or "").strip()
auto_created = False
if not pid:
# 自动立项:收到招标书直接启动,无需先建项目/配人
pname = str(project_name or "").strip()[:190]
if not pname:
pname = (str(file_name or "").strip() or (fp.split("/")[-1] if fp else "")
or txt.split("\n")[0].strip())[:190] or "投标项目"
pid = new_id()
# 创建时定死项目目录并写库(统一机制,同 create_bid_project)
from pipeline_service.workspace import alloc_project_dir
ws_path, dname = await alloc_project_dir(
sor, org_id or "0", PIPELINE_ID, pname, pid)
await sor.C("sd_projects", {
"id": pid,
"name": pname,
"description": "由导入招标文件自动立项(收到招标书直接启动)",
"project_type": "bidding",
"pipeline_id": PIPELINE_ID,
"status": "in_progress",
"org_id": org_id or "0",
"created_by": created_by or (who or "agent.main_agent"),
"workspace_dir": ws_path,
"directory_name": dname,
})
await sor.sqlExe("COMMIT", {})
await record(sor, pid, "sd_projects", pid, "auto_create_bid_project",
to_state="in_progress", who=who, agent_id=agent_id,
detail="auto-created on tender import")
auto_created = True
else:
p = await get_project(sor, pid)
if not p:
return False, "项目不存在: %s" % pid
# 2026-09-15 修复(bug A 根治半边):相对路径在落库前归一为磁盘绝对路径。
# 上传链路可能给出相对工作空间根的路径(如 "0/xxx.docx"),而读取侧
# _resolve_file_path 按项目目录解析——口径不一致导致文件在盘上却永远读不到正文
# (首医项目实测)。落库时磁盘绝对路径是已知的,存相对路径本身就是隐患。
if fp and not os.path.isabs(fp):
real = ""
try:
from .bid_analysis_capability import _resolve_file_path, _project_dir_of, _workspace_base_of
pdir = await _project_dir_of(sor, pid)
real = _resolve_file_path(pdir, fp, ws_base=await _workspace_base_of(sor))
except Exception as e:
logger.warning("tender path normalize failed fp=%s: %s", fp, str(e)[:120])
if real:
fp = real
else:
logger.warning("tender path not resolvable on disk, stored as-is: %s", fp)
await sor.C("bid_tender_files", {
"id": new_id(), "project_id": pid,
"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, pid, 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, pid, "bid_tender_files", pid, "import_tender_file",
to_state="uploaded", who=who, agent_id=agent_id,
detail=("text %d chars" % len(txt)) if txt else ("path %s" % fp))
base = ("正文 %d 字" % len(txt)) if txt else file_path
if auto_created:
return True, ("project_id=%s(已自动立项并导入招标文件:%s,"
"系统将自动开始解析)" % (pid, base))
return True, ("招标文件已导入(%s),上传步骤已跳过,系统将自动开始解析。" % base)
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):
"""(重新)发布「等待上传招标文件」人类任务。
硬门禁(2026-09-01,同一事项只发一次待办):
- 项目已有招标文件记录(bid_tender_files)→ 文件已到位,**拒绝发待办**,
提示用 list_tender_files 查看/解析会自动进行(防「文件已在还催人上传」);
- 已有 pending 上传待办 → 返回已有任务 id,不重复创建。
"""
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
n = await _count_files(sor, project_id)
if n > 0:
return False, ("项目已有 %d 个招标文件记录,无需再发上传待办"
"(list_tender_files 查看,解析自动进行)" % n)
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 _count_files(sor, project_id):
recs = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_tender_files WHERE project_id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
return to_int(getattr(recs[0], "c", 0) if recs else 0)
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)