288 lines
12 KiB
Python
288 lines
12 KiB
Python
"""标书合成能力(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 _fetch_image_bytes(url, timeout=30):
|
||
"""下载配图(章节正文里的 markdown 图片 URL → 本地字节流)。
|
||
|
||
URL 是 invoke_model 产物经 downloadfile2url 落地的本地持久地址
|
||
(/idfile 静态路径,上游 24h 时效 URL 已在推理层落地,这里拿到的是
|
||
平台自身地址)。http(s) 下载;本地绝对路径直读。失败返回 None(诚实
|
||
降级:docx 里保留文字说明,不假装成功)。
|
||
"""
|
||
try:
|
||
u = (url or '').strip()
|
||
if not u:
|
||
return None
|
||
if u.startswith('http://') or u.startswith('https://'):
|
||
import urllib.request
|
||
req = urllib.request.Request(u, headers={'User-Agent': 'pipeline-bidding'})
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
data = resp.read()
|
||
return data if data else None
|
||
if u.startswith('/') and os.path.isfile(u):
|
||
with open(u, 'rb') as f:
|
||
return f.read()
|
||
except Exception as e:
|
||
logger.warning("compose: 配图下载失败 url=%s: %s", (url or '')[:120], e)
|
||
return None
|
||
|
||
|
||
# markdown 图片行:(章节写作者按角色 prompt 规范嵌入 invoke_model 产物)
|
||
_MD_IMG_RE = None
|
||
|
||
|
||
def _parse_md_image(line):
|
||
"""解析一行 markdown 图片语法,返回 (alt, url) 或 None。"""
|
||
global _MD_IMG_RE
|
||
if _MD_IMG_RE is None:
|
||
import re
|
||
_MD_IMG_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$')
|
||
m = _MD_IMG_RE.match(line or '')
|
||
if not m:
|
||
return None
|
||
return m.group(1), m.group(2)
|
||
|
||
|
||
def _write_docx(path, title, subtitle, chapters):
|
||
"""用 python-docx 写标书。返回 (ok, page_hint_or_err)。
|
||
|
||
章节正文支持 markdown 图片行 (写作者经 invoke_model 生成的
|
||
真实配图):下载后 add_picture 嵌入;下载失败保留文字占位(诚实降级)。
|
||
"""
|
||
try:
|
||
from docx import Document
|
||
from docx.shared import Pt, Inches
|
||
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()
|
||
|
||
img_ok = img_fail = 0
|
||
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
|
||
img = _parse_md_image(t)
|
||
if img:
|
||
alt, url = img
|
||
data = _fetch_image_bytes(url)
|
||
if data:
|
||
import io
|
||
pic_p = doc.add_paragraph()
|
||
pic_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
try:
|
||
pic_p.add_run().add_picture(io.BytesIO(data),
|
||
width=Inches(5.5))
|
||
if alt:
|
||
cap = doc.add_paragraph(alt)
|
||
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
img_ok += 1
|
||
except Exception as e:
|
||
doc.add_paragraph("[配图 %s 嵌入失败:%s]" % (alt or url[:60], str(e)[:80]))
|
||
img_fail += 1
|
||
else:
|
||
doc.add_paragraph("[配图缺失(下载失败):%s %s]" % (alt or "", url[:80]))
|
||
img_fail += 1
|
||
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)
|
||
if img_ok or img_fail:
|
||
logger.info("compose: docx 配图嵌入 成功%d 失败%d", img_ok, img_fail)
|
||
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]))
|