"""招标信息能力:采集 / 摘要要素抽取 / 审批 / 存档作废 / 立项 / 人员 / 等待招标文件。 对应业务:从邮箱获取招标信息 → 内容摘要(投标开始时间/项目周期/招标金额…)→ 走审批流程 → 不通过则存档作废;通过则创建项目、设定参与人员、等待上传招标文件。 状态机规范见技能 `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)