pipeline-bidding/pipeline_bidding/bid_compose_capability.py
yumoqing 19a3d67c68 feat: 投标产线模块(bidding_general)
- 12张 bid_* 表(models+DDL+CRUD):招标信息/邮箱配置/人员/招标文件/评分项/资质/
  投标文件要求/章节/评审/评分/知识库/标书
- 7个角色能力模块(bid_*_capability):采集摘要/解析/知识库资质/编写/评审/合成/评分
- bid_flow 状态对账器:角色任务唯一创建者+阻塞门禁(源头守门,引擎零特判)
- bid_mail:阿里云企业邮 IMAP 采集(凭据存DB AES,客户端专用密码兼容)
- 能力包注册(bid_ability)+ 角色工具自注册(role_tool_schemas)
- 技能:6 common 状态机/规范 + 9 角色技能(pipelines/bidding_general,随 pipeline-core 部署)
- i18n(zh/en)、init/data.json(appcodes+pipelines记录)、build.sh、RBAC load_path
2026-08-26 23:45:03 +08:00

222 lines
9.6 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""标书合成能力(agent.bid_compositor):把已通过的章节合成完整标书(md + docx)。
对应业务:最后合成标书。
产出落 bid_documents(version 递增)+ 工作空间文件,并登记为交付件。
docx 生成用 python-docx;不可用时降级为 md(诚实降级,不假装成功)。
"""
import json
import logging
import os
from .bid_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, record,
get_project, get_tender_by_project, resolve_project_id, CH_APPROVED, DOC_DRAFT,
)
logger = logging.getLogger("pipeline.bidding.compose")
async def _workspace_dir(sor, project_id):
"""项目工作目录:复用引擎 build_workspace_path({base}/{org}/{pipeline_id}/{项目名})。
不走 get_workspace_dir(那个依赖会话当前项目),角色 agent 场景下按项目自身字段解析。
"""
try:
from pipeline_service.workspace import get_workspace_base, build_workspace_path
base = await get_workspace_base(sor)
recs = await sor.sqlExe(
"SELECT name, org_id, pipeline_id FROM sd_projects WHERE id=${p}$", {"p": project_id})
await sor.sqlExe("COMMIT", {})
if base and recs:
r = recs[0]
d = build_workspace_path(base, getattr(r, "org_id", "0") or "0",
getattr(r, "pipeline_id", "") or "bidding_general",
getattr(r, "name", "") or project_id, project_id)
os.makedirs(d, exist_ok=True)
return d
except Exception as e:
logger.warning("workspace resolve failed: %s", str(e)[:120])
d = os.path.join("/tmp", "pipeline_bidding", project_id)
os.makedirs(d, exist_ok=True)
return d
def _write_docx(path, title, subtitle, chapters):
"""用 python-docx 写标书。返回 (ok, page_hint_or_err)。"""
try:
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
return False, "python-docx 未安装"
try:
doc = Document()
# 中文字体(python-docx 对 CJK 需显式设置 eastasia)
style = doc.styles["Normal"]
style.font.name = "宋体"
style.font.size = Pt(12)
try:
from docx.oxml.ns import qn
style.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
except Exception:
pass
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run(title)
r.bold = True
r.font.size = Pt(26)
if subtitle:
p2 = doc.add_paragraph()
p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
r2 = p2.add_run(subtitle)
r2.font.size = Pt(14)
doc.add_page_break()
doc.add_heading("目 录", level=1)
for c in chapters:
doc.add_paragraph("%s %s" % (c.get("chapter_no", ""), c.get("title", "")))
doc.add_page_break()
for c in chapters:
doc.add_heading("%s %s" % (c.get("chapter_no", ""), c.get("title", "")), level=1)
for line in (c.get("content") or "").split("\n"):
t = line.rstrip()
if not t:
continue
if t.startswith("### "):
doc.add_heading(t[4:], level=3)
elif t.startswith("## "):
doc.add_heading(t[3:], level=2)
elif t.startswith("# "):
doc.add_heading(t[2:], level=2)
elif t.lstrip().startswith(("- ", "* ")):
doc.add_paragraph(t.lstrip()[2:], style="List Bullet")
else:
doc.add_paragraph(t)
doc.add_page_break()
doc.save(path)
return True, ""
except Exception as e:
return False, "%s: %s" % (type(e).__name__, str(e)[:200])
async def compose_bid(project_id, doc_name="", who=None, agent_id=None, task_id=""):
"""合成标书:要求全部章节 approved(否则拒绝,避免拿半成品合成)。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
proj = await get_project(sor, project_id)
if not proj:
return False, "项目不存在: %s" % project_id
recs = await sor.sqlExe(
"SELECT id, chapter_no, title, status, content, max_score, review_score "
"FROM bid_chapters WHERE project_id=${pid}$ ORDER BY order_no, chapter_no",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
chapters = rows_to_dicts(recs, limit=500)
if not chapters:
return False, "项目下无章节,无法合成(请先生成章节骨架并编写)"
bad = [c for c in chapters if c.get("status") != CH_APPROVED]
if bad:
names = ", ".join("%s %s(%s)" % (c.get("chapter_no"), c.get("title"), c.get("status"))
for c in bad[:8])
return False, ("仍有 %d 个章节未评审通过,不可合成:%s%s"
% (len(bad), names, " ..." if len(bad) > 8 else ""))
tender = await get_tender_by_project(sor, project_id)
vrec = await sor.sqlExe(
"SELECT COALESCE(MAX(version),0) AS v FROM bid_documents WHERE project_id=${pid}$",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
ver = to_int(getattr(vrec[0], "v", 0) if vrec else 0) + 1
ws = await _workspace_dir(sor, project_id)
title = doc_name or ("%s 投标文件" % (tender.get("title") or proj.get("name") or "项目"))
subtitle = "投标人:(本单位)  招标编号:%s" % (tender.get("tender_no") or "-")
md_lines = ["# " + title, "", subtitle, "", "## 目录", ""]
for c in chapters:
md_lines.append("- %s %s" % (c.get("chapter_no"), c.get("title")))
md_lines.append("")
for c in chapters:
md_lines.append("# %s %s" % (c.get("chapter_no"), c.get("title")))
md_lines.append("")
md_lines.append(c.get("content") or "")
md_lines.append("")
md = "\n".join(md_lines)
md_path = os.path.join(ws, "bid_v%d.md" % ver)
with open(md_path, "w", encoding="utf-8") as f:
f.write(md)
docx_path = os.path.join(ws, "bid_v%d.docx" % ver)
ok_docx, err = _write_docx(docx_path, title, subtitle, chapters)
file_path = docx_path if ok_docx else md_path
total_chars = sum(len(c.get("content") or "") for c in chapters)
page_est = max(1, total_chars // 900) # 约 900 字/页(中文小四单倍行距)
max_sum = sum(to_float(c.get("max_score"), 0.0) for c in chapters)
did = new_id()
await sor.C("bid_documents", {
"id": did, "project_id": project_id, "version": ver,
"doc_name": title[:250], "file_path": file_path[:480],
"page_count": page_est, "chapter_count": len(chapters),
"max_score": max_sum or None, "status": DOC_DRAFT,
"task_id": task_id or "", "created_by": who or "agent.bid_compositor",
})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_documents", did, "compose",
to_state=DOC_DRAFT, who=who, agent_id=agent_id,
detail="v%d %d章 %d字" % (ver, len(chapters), total_chars))
note = "" if ok_docx else ("(docx 生成失败,已产出 markdown:%s)" % err)
return True, ("标书已合成 v%d:%s\n章节 %d 个,约 %d 字,估算 %d 页,对应满分 %.1f%s\n"
"系统将自动派发整书评分任务。"
% (ver, file_path, len(chapters), total_chars, page_est, max_sum, note))
async def list_bid_documents(project_id, limit=20):
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return []
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, version, doc_name, file_path, page_count, chapter_count, "
"total_score, max_score, status, created_at FROM bid_documents "
"WHERE project_id=${pid}$ ORDER BY version DESC LIMIT " + str(to_int(limit, 20)),
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
async def read_bid_document(document_id, offset=0, length=15000):
"""读合成标书正文(分段),供整书评审用。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, project_id, version, file_path FROM bid_documents WHERE id=${did}$",
{"did": document_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return "标书不存在: %s" % document_id
d = rec_to_dict(recs[0])
path = d.get("file_path") or ""
md = os.path.splitext(path)[0] + ".md"
src = md if os.path.isfile(md) else path
if not src or not os.path.isfile(src):
return "标书文件不存在于磁盘: %s" % path
if src.endswith(".docx"):
from .bid_analysis_capability import _extract_file_text
txt = _extract_file_text(src)
else:
with open(src, "r", encoding="utf-8", errors="ignore") as f:
txt = f.read()
o = to_int(offset, 0)
n = to_int(length, 15000)
return ("【标书 v%s】字符 %d-%d / 共 %d\n%s"
% (d.get("version"), o, o + n, len(txt), txt[o:o + n]))