pipeline-bidding/pipeline_bidding/bid_compose_capability.py

217 lines
9.5 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):
"""项目工作目录:统一走引擎 get_project_dir_by_id(读库,创建时已写死)。
不走 get_workspace_dir(那个依赖会话当前项目),角色 agent 场景下按项目自身字段解析。
旧实现自行拼 {base}/{org}/{pipeline_id}/{项目名}(旧平铺口径)导致与项目目录错位,
2026-09-01 收敛到引擎统一函数根治。
铁律(输出文件必须在项目空间内):解析失败绝不回退 /tmp——
标书是交付件,写到项目外就违反"输出文件必须在项目空间内"。宁可合成失败。
"""
from pipeline_service.workspace import get_project_dir_by_id
pdir, _ = await get_project_dir_by_id(sor, project_id)
if pdir:
os.makedirs(pdir, exist_ok=True)
return pdir
return ''
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)
if not ws:
return False, "无法解析项目工作目录(输出文件必须落项目空间内,已中止合成)"
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]))