pipeline-bidding/pipeline_bidding/bid_tender_capability.py

466 lines
22 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.

"""招标信息能力:采集 / 摘要要素抽取 / 审批 / 存档作废 / 立项 / 人员 / 等待招标文件。
对应业务:从邮箱获取招标信息 → 内容摘要(投标开始时间/项目周期/招标金额…)→ 走审批流程
→ 不通过则存档作废;通过则创建项目、设定参与人员、等待上传招标文件。
状态机规范见技能 `tender`;本模块只固化操作原语(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,
llm_json, record, create_human_task, find_human_task, get_project,
PIPELINE_ID, TD_NEW, TD_SUMMARIZED, TD_PENDING_APPROVAL, TD_APPROVED,
TD_REJECTED, TD_ARCHIVED, HT_TENDER_APPROVAL, HT_TENDER_FILE,
)
logger = logging.getLogger("pipeline.bidding.tender")
TABLE = "bid_tenders"
SUMMARY_PROMPT = """你是招标信息分析员。下面是一封招标邮件的主题与正文,请抽取结构化要素。
【邮件主题】
__SUBJECT__
【邮件正文】
__BODY__
严格只输出一个 JSON 对象,字段如下(缺失或正文未提及的填空字符串,不要编造):
{
"title": "项目名称",
"purchaser": "招标人/采购人单位名称",
"tender_no": "招标编号",
"industry": "所属行业",
"summary": "内容摘要,200字以内,说明采购标的、范围、主要要求",
"bid_start_at": "投标开始/获取招标文件时间 YYYY-MM-DD",
"bid_deadline": "投标截止时间 YYYY-MM-DD",
"project_period": "项目周期,如 12个月 / 180日历天",
"budget_amount": "招标金额(仅数字,人民币元)",
"deposit_amount": "投标保证金(仅数字,人民币元)",
"qualification_req": "投标人资格/资质要求原文摘要",
"key_dates": [{"name": "节点名称", "date": "YYYY-MM-DD"}],
"risk_note": "风险提示:时间紧、资质缺口、否决项等;无则空"
}
注意:金额只填数字(如 1200000),不带单位和千分位;日期只填 YYYY-MM-DD;无法确定一律留空。"""
async def _get_tender(sor, tender_id):
recs = await sor.sqlExe("SELECT * FROM " + TABLE + " WHERE id=${tid}$", {"tid": tender_id})
await sor.sqlExe("COMMIT", {})
return rec_to_dict(recs[0]) if recs else {}
async def _set_status(sor, tender_id, from_status, to_status, action,
who=None, agent_id=None, detail=None, extra=None):
"""CAS 状态迁移(from_status 为 None 时不校验来源状态)+ 审计。"""
data = dict(extra or {})
data["status"] = to_status
sets = ", ".join("%s=${%s}$" % (k, k) for k in data)
p = dict(data)
p["tid"] = tender_id
sql = "UPDATE " + TABLE + " SET " + sets + ", updated_at=NOW() WHERE id=${tid}$"
if from_status:
sql += " AND status=${_from}$"
p["_from"] = from_status
await sor.sqlExe(sql, p)
cur = await _get_tender(sor, tender_id)
if not cur:
return False, "招标信息不存在: %s" % tender_id
if cur.get("status") != to_status:
return False, ("状态迁移失败(CAS): 期望 from=%s 实际 status=%s"
% (from_status, cur.get("status")))
await record(sor, cur.get("project_id") or tender_id, TABLE, tender_id, action,
from_state=from_status, to_state=to_status, who=who,
agent_id=agent_id, detail=detail)
return True, to_status
# ══════════════ 采集 ══════════════
async def fetch_tenders(org_id="", account_id="", days=None, who=None, agent_id=None):
"""从配置的招标邮箱采集招标信息入库(去重)。返回 (ok, message)。"""
from .bid_mail import fetch_tenders_to_db
return await fetch_tenders_to_db(org_id=org_id, account_id=account_id, days=days)
async def import_tender(title, content, purchaser="", tender_no="", org_id="0",
who=None, agent_id=None, created_by=""):
"""手工导入招标信息(邮箱不可用时的备用入口)。返回 (ok, tender_id)。"""
if not title or not str(title).strip():
return False, "缺少 title"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
tid = new_id()
await sor.C(TABLE, {
"id": tid, "org_id": org_id or "0", "source": "manual",
"title": str(title).strip()[:250], "mail_subject": str(title).strip()[:480],
"raw_content": content or "", "purchaser": purchaser or "",
"tender_no": tender_no or "", "status": TD_NEW,
"created_by": created_by or (who or ""),
})
await record(sor, tid, TABLE, tid, "import", to_state=TD_NEW,
who=who, agent_id=agent_id)
return True, tid
async def list_tenders(status="", org_id="", limit=50):
"""列出招标信息。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, title, purchaser, tender_no, status, bid_start_at, bid_deadline, "
"project_period, budget_amount, project_id, received_at "
"FROM " + TABLE + " WHERE 1=1")
p = {}
if status:
sql += " AND status=${st}$"
p["st"] = status
if org_id:
sql += " AND org_id=${oid}$"
p["oid"] = org_id
sql += " ORDER BY received_at DESC, created_at DESC LIMIT " + str(to_int(limit, 50))
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
async def tender_detail(tender_id):
"""招标信息详情(含正文,供分析用)。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
d = await _get_tender(sor, tender_id)
if not d:
return "招标信息不存在: %s" % tender_id
body = d.get("raw_content") or ""
d["raw_content"] = body[:8000]
return json.dumps(d, ensure_ascii=False, default=str)
# ══════════════ 摘要与要素抽取 ══════════════
async def summarize_tender(tender_id, model_name="", who=None, agent_id=None):
"""LLM 抽取摘要 + 投标开始时间/项目周期/招标金额等要素,落库并置 summarized。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
t = await _get_tender(sor, tender_id)
if not t:
return False, "招标信息不存在: %s" % tender_id
body = (t.get("raw_content") or "")[:20000]
if not body.strip():
return False, "招标信息正文为空,无法摘要(请先补充 raw_content 或上传招标文件)"
prompt = (SUMMARY_PROMPT
.replace("__SUBJECT__", (t.get("mail_subject") or t.get("title") or "")[:500])
.replace("__BODY__", body))
data, err = await llm_json(prompt, model_name=model_name, retries=1)
if data is None:
return False, "LLM 摘要失败: %s" % err
extra = {}
for k in ("title", "purchaser", "tender_no", "industry", "summary",
"project_period", "qualification_req", "risk_note"):
v = data.get(k)
if v:
extra[k] = str(v)[:2000] if k in ("summary", "qualification_req", "risk_note") else str(v)[:250]
for k in ("bid_start_at", "bid_deadline"):
v = (data.get(k) or "").strip()
if v and len(v) >= 8:
extra[k] = v[:10]
for k in ("budget_amount", "deposit_amount"):
v = data.get(k)
if v not in (None, "", "无"):
amt = to_float(str(v).replace(",", "").replace("元", "").strip(), 0.0)
if amt > 0:
extra[k] = amt
kd = data.get("key_dates")
if kd:
extra["key_dates"] = json.dumps(kd, ensure_ascii=False)[:4000]
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
ok, msg = await _set_status(sor, tender_id, None, TD_SUMMARIZED, "summarize",
who=who, agent_id=agent_id, extra=extra)
if not ok:
return False, msg
return True, ("摘要完成: %s | 投标开始=%s 截止=%s 周期=%s 金额=%s"
% (extra.get("title", t.get("title", "")),
extra.get("bid_start_at", ""), extra.get("bid_deadline", ""),
extra.get("project_period", ""), extra.get("budget_amount", "")))
async def set_tender_fields(tender_id, title="", purchaser="", tender_no="",
bid_start_at="", bid_deadline="", project_period="",
budget_amount="", deposit_amount="", summary="",
qualification_req="", who=None, agent_id=None):
"""人工/agent 修正招标要素(LLM 抽错时用)。"""
vals = {"title": title, "purchaser": purchaser, "tender_no": tender_no,
"bid_start_at": bid_start_at, "bid_deadline": bid_deadline,
"project_period": project_period, "summary": summary,
"qualification_req": qualification_req}
data = {k: v for k, v in vals.items() if v not in (None, "")}
for k, v in (("budget_amount", budget_amount), ("deposit_amount", deposit_amount)):
if v not in (None, ""):
data[k] = to_float(str(v).replace(",", ""), 0.0)
if not data:
return False, "未提供任何要修改的字段"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sets = ", ".join("%s=${%s}$" % (k, k) for k in data)
p = dict(data)
p["tid"] = tender_id
await sor.sqlExe("UPDATE " + TABLE + " SET " + sets + ", updated_at=NOW() WHERE id=${tid}$", p)
await sor.sqlExe("COMMIT", {})
await record(sor, tender_id, TABLE, tender_id, "set_fields",
who=who, agent_id=agent_id, detail=json.dumps(list(data), ensure_ascii=False))
return True, "已更新字段: %s" % ", ".join(data)
# ══════════════ 审批流程 ══════════════
async def submit_tender_approval(tender_id, assignee_role="owner.superuser",
assignee_id="", note="", who=None, agent_id=None):
"""提交招标信息审批:创建人类审批任务 + 状态置 pending_approval。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
t = await _get_tender(sor, tender_id)
if not t:
return False, "招标信息不存在: %s" % tender_id
if t.get("status") == TD_PENDING_APPROVAL:
return True, "已在审批中(human_task=%s)" % (t.get("approval_task_id") or "")
if t.get("status") not in (TD_NEW, TD_SUMMARIZED):
return False, "当前状态 %s 不可提交审批(需 new/summarized)" % t.get("status")
title = "招标信息审批:%s" % (t.get("title") or t.get("mail_subject") or tender_id)
desc_lines = [
"招标人:%s" % (t.get("purchaser") or "未识别"),
"招标编号:%s" % (t.get("tender_no") or "未识别"),
"投标开始:%s 截止:%s" % (t.get("bid_start_at") or "-", t.get("bid_deadline") or "-"),
"项目周期:%s" % (t.get("project_period") or "-"),
"招标金额:%s" % (t.get("budget_amount") or "-"),
"保证金:%s" % (t.get("deposit_amount") or "-"),
"资质要求:%s" % ((t.get("qualification_req") or "-")[:500]),
"摘要:%s" % ((t.get("summary") or "-")[:1000]),
"风险提示:%s" % ((t.get("risk_note") or "-")[:500]),
]
if note:
desc_lines.append("提交说明:%s" % note)
desc_lines.append("")
desc_lines.append("请决策:通过 → 立项投标;不通过 → 该招标信息存档作废。")
hid = await create_human_task(
sor, "", HT_TENDER_APPROVAL, title, "\n".join(desc_lines),
assignee_role=assignee_role, assignee_id=assignee_id,
form_schema={"tender_id": tender_id, "decision": ["approve", "reject"]})
ok, msg = await _set_status(sor, tender_id, None, TD_PENDING_APPROVAL,
"submit_approval", who=who, agent_id=agent_id,
extra={"approval_task_id": hid})
if not ok:
return False, msg
return True, "已提交审批(human_task=%s,指派 %s)" % (hid, assignee_role)
async def approve_tender(tender_id, comment="", who=None, agent_id=None,
auto_create_project=True):
"""审批通过:pending_approval → approved;默认自动立项 + 发上传招标文件任务。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
ok, msg = await _set_status(sor, tender_id, TD_PENDING_APPROVAL, TD_APPROVED,
"approve", who=who, agent_id=agent_id, detail=comment)
if not ok:
ok, msg = await _set_status(sor, tender_id, TD_SUMMARIZED, TD_APPROVED,
"approve", who=who, agent_id=agent_id, detail=comment)
if not ok:
return False, msg
await _close_approval_task(sor, tender_id, "done", comment)
if not auto_create_project:
return True, "审批通过(未自动立项)"
ok2, msg2 = await create_bid_project(tender_id, who=who, agent_id=agent_id)
if not ok2:
return True, "审批通过,但立项失败: %s" % msg2
return True, "审批通过并已立项: %s" % msg2
async def reject_tender(tender_id, comment="", who=None, agent_id=None):
"""审批不通过:pending_approval → rejected → 立即存档作废(业务要求)。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
ok, msg = await _set_status(sor, tender_id, TD_PENDING_APPROVAL, TD_REJECTED,
"reject", who=who, agent_id=agent_id, detail=comment)
if not ok:
return False, msg
await _close_approval_task(sor, tender_id, "rejected", comment)
ok2, msg2 = await _set_status(
sor, tender_id, TD_REJECTED, TD_ARCHIVED, "archive",
who=who, agent_id=agent_id, detail="审批不通过自动存档作废",
extra={"archive_reason": ("审批不通过:" + (comment or ""))[:480]})
if not ok2:
return False, "已驳回但存档失败: %s" % msg2
return True, "审批不通过,招标信息已存档作废"
async def archive_tender(tender_id, reason="", who=None, agent_id=None):
"""存档作废(任何状态 → archived)。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
return await _set_status(sor, tender_id, None, TD_ARCHIVED, "archive",
who=who, agent_id=agent_id, detail=reason,
extra={"archive_reason": (reason or "")[:480]})
async def reopen_tender(tender_id, reason="", who=None, agent_id=None):
"""存档/驳回后重新评估:archived/rejected → summarized。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
ok, msg = await _set_status(sor, tender_id, TD_ARCHIVED, TD_SUMMARIZED, "reopen",
who=who, agent_id=agent_id, detail=reason)
if ok:
return ok, msg
return await _set_status(sor, tender_id, TD_REJECTED, TD_SUMMARIZED, "reopen",
who=who, agent_id=agent_id, detail=reason)
async def _close_approval_task(sor, tender_id, status, comment=""):
"""关闭招标审批人类任务(同步审批结论)。"""
t = await _get_tender(sor, tender_id)
hid = t.get("approval_task_id") or ""
if not hid:
return
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status=${st}$, qc_comment=${cm}$, "
"submitted_at=NOW() WHERE id=${hid}$ AND status='pending'",
{"st": status, "cm": (comment or "")[:480], "hid": hid})
await sor.sqlExe("COMMIT", {})
# ══════════════ 立项 / 人员 / 招标文件 ══════════════
async def create_bid_project(tender_id, name="", org_id="", created_by="",
who=None, agent_id=None):
"""由招标信息立项:写 sd_projects(pipeline_id=bidding_general) + 发「上传招标文件」人类任务。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
t = await _get_tender(sor, tender_id)
if not t:
return False, "招标信息不存在: %s" % tender_id
if t.get("project_id"):
return True, "项目已存在: %s" % t.get("project_id")
if t.get("status") != TD_APPROVED:
return False, "招标信息未审批通过(当前 %s),不可立项" % t.get("status")
pid = new_id()
pname = (name or t.get("title") or t.get("mail_subject") or "投标项目")[:190]
await sor.C("sd_projects", {
"id": pid,
"name": pname,
"description": (t.get("summary") or "")[:4000],
"project_type": "bidding",
"pipeline_id": PIPELINE_ID,
"status": "in_progress",
"org_id": org_id or t.get("org_id") or "0",
"created_by": created_by or (who or "agent.tender_scout"),
})
await sor.sqlExe(
"UPDATE " + TABLE + " SET project_id=${pid}$, updated_at=NOW() WHERE id=${tid}$",
{"pid": pid, "tid": tender_id})
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="tender_id=" + tender_id)
hid = await create_human_task(
sor, pid, HT_TENDER_FILE,
"上传招标文件:%s" % pname,
("项目已立项,请上传招标文件(含答疑/补遗文件)。\n"
"上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求)。\n"
"上传入口:投标产线 → 招标文件(bid_tender_files)。"),
assignee_role="owner.superuser",
form_schema={"project_id": pid, "tender_id": tender_id})
return True, "project_id=%s(已发上传招标文件任务 %s)" % (pid, hid)
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})
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)