419 lines
19 KiB
Python
419 lines
19 KiB
Python
"""标书合成能力(agent.bid_compositor):把已通过的章节合成完整标书(md + docx)。
|
||
|
||
对应业务:最后合成标书。
|
||
产出落 bid_documents(version 递增)+ 工作空间文件,并登记为交付件。
|
||
docx 生成用 python-docx;不可用时降级为 md(诚实降级,不假装成功)。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
|
||
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 已在推理层落地,这里拿到的是
|
||
平台自身地址)。
|
||
|
||
解析优先级(2026-09-16 修复):
|
||
1. /idfile|/download 网关 URL → 直接映射本机 filesroot 读盘——合成进程无
|
||
登录态,HTTP 拉 logined-only 的 /idfile 必 401,图全变「下载失败」占位
|
||
(甘肃项目 bid_v1 docx 实测根因)。
|
||
2. 本地绝对路径直读。
|
||
3. 其余 http(s) 下载。
|
||
失败返回 None(诚实降级:docx 里保留文字说明,不假装成功)。
|
||
"""
|
||
try:
|
||
u = (url or '').strip()
|
||
if not u:
|
||
return None
|
||
# 网关 URL → filesroot 本地映射
|
||
if u.startswith('http://') or u.startswith('https://'):
|
||
from urllib.parse import urlparse, unquote
|
||
path = unquote(urlparse(u).path or '')
|
||
for lead in ('/idfile/', '/download/'):
|
||
if path.startswith(lead):
|
||
local = _filesroot_path(path[len(lead):])
|
||
if local and os.path.isfile(local):
|
||
with open(local, 'rb') as f:
|
||
return f.read()
|
||
break
|
||
if u.startswith('/') and os.path.isfile(u):
|
||
with open(u, 'rb') as f:
|
||
return f.read()
|
||
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
|
||
except Exception as e:
|
||
logger.warning("compose: 配图下载失败 url=%s: %s", (url or '')[:120], e)
|
||
return None
|
||
|
||
|
||
def _filesroot_path(rel):
|
||
"""/idfile 网关相对路径 → 本机 filesroot 绝对路径(防穿越:realpath 校验)。"""
|
||
try:
|
||
from appPublic.jsonConfig import getConfig
|
||
root = getattr(getConfig(), 'filesroot', '') or ''
|
||
if not root:
|
||
return ''
|
||
rel = (rel or '').lstrip('/')
|
||
if not rel or '..' in rel.split('/'):
|
||
return ''
|
||
full = os.path.realpath(os.path.join(root, rel))
|
||
if full.startswith(os.path.realpath(root) + os.sep):
|
||
return full
|
||
except Exception:
|
||
pass
|
||
return ''
|
||
|
||
|
||
# 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:
|
||
_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)
|
||
|
||
|
||
_MD_TABLE_SEP_RE = re.compile(r'^\s*\|?[\s:|-]+\|[\s:|-]*$')
|
||
|
||
|
||
def _is_table_sep(line):
|
||
"""markdown 表头分隔行(|---|---| 或 |:--|--:|)。"""
|
||
s = (line or '').strip()
|
||
return bool(s) and '-' in s and set(s) <= set('|-: ')
|
||
|
||
|
||
def _split_md_row(line):
|
||
"""拆分一行 markdown 表格为单元格列表(去首尾竖线、按 | 切、strip)。"""
|
||
s = (line or '').strip()
|
||
if s.startswith('|'):
|
||
s = s[1:]
|
||
if s.endswith('|'):
|
||
s = s[:-1]
|
||
return [c.strip() for c in s.split('|')]
|
||
|
||
|
||
def _clean_md_cell(text):
|
||
"""清理单元格内联 markdown(**粗体**/`code`/图片占位)→ 纯文本。"""
|
||
t = text or ''
|
||
t = re.sub(r'!\[([^\]]*)\]\([^)]*\)', r'\1', t) # 图片→alt
|
||
t = re.sub(r'\*\*([^*]+)\*\*', r'\1', t) # 粗体
|
||
t = re.sub(r'`([^`]+)`', r'\1', t) # 行内代码
|
||
t = re.sub(r'<br\s*/?>', ' ', t, flags=re.I) # 换行标签
|
||
return t.strip()
|
||
|
||
|
||
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
|
||
tbl_ok = 0
|
||
for c in chapters:
|
||
doc.add_heading("%s %s" % (c.get("chapter_no", ""), c.get("title", "")), level=1)
|
||
_lines = (c.get("content") or "").split("\n")
|
||
_i = 0
|
||
_n = len(_lines)
|
||
while _i < _n:
|
||
t = _lines[_i].rstrip()
|
||
# ── markdown 表格块 → Word 表格(2026-09-16 用户报障:表格散成文字)──
|
||
# 识别:当前行含 '|' 且下一行是分隔行(|---|)。缓冲连续表格行整体渲染。
|
||
if '|' in t and _i + 1 < _n and _is_table_sep(_lines[_i + 1]):
|
||
_rows = [_split_md_row(t)]
|
||
_i += 2 # 跳过表头行 + 分隔行
|
||
while _i < _n:
|
||
_rt = _lines[_i].rstrip()
|
||
if '|' not in _rt or not _rt.strip():
|
||
break
|
||
_rows.append(_split_md_row(_rt))
|
||
_i += 1
|
||
_ncol = max(len(r) for r in _rows)
|
||
try:
|
||
_tbl = doc.add_table(rows=0, cols=_ncol)
|
||
_tbl.style = 'Table Grid'
|
||
for _ri, _r in enumerate(_rows):
|
||
_cells = _tbl.add_row().cells
|
||
for _ci in range(_ncol):
|
||
_txt = _clean_md_cell(_r[_ci] if _ci < len(_r) else '')
|
||
_para = _cells[_ci].paragraphs[0]
|
||
_run = _para.add_run(_txt)
|
||
_run.font.size = Pt(9)
|
||
if _ri == 0:
|
||
_run.bold = True
|
||
tbl_ok += 1
|
||
except Exception as e:
|
||
doc.add_paragraph("[表格渲染失败:%s]" % str(e)[:80])
|
||
for _r in _rows:
|
||
doc.add_paragraph(" | ".join(_clean_md_cell(x) for x in _r))
|
||
continue
|
||
_i += 1
|
||
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")
|
||
elif _is_table_sep(t):
|
||
continue # 游离分隔行(无表头)跳过
|
||
elif t.strip().startswith('|') and t.strip().endswith('|'):
|
||
# 单行竖线内容但无分隔行跟随 → 按表格行渲染为单行表,避免散文字
|
||
_cells1 = [_clean_md_cell(x) for x in _split_md_row(t)]
|
||
try:
|
||
_t1 = doc.add_table(rows=1, cols=max(1, len(_cells1)))
|
||
_t1.style = 'Table Grid'
|
||
for _ci, _cv in enumerate(_cells1):
|
||
_t1.rows[0].cells[_ci].paragraphs[0].add_run(_cv).font.size = Pt(9)
|
||
tbl_ok += 1
|
||
except Exception:
|
||
doc.add_paragraph(t)
|
||
else:
|
||
doc.add_paragraph(t)
|
||
doc.add_page_break()
|
||
doc.save(path)
|
||
if img_ok or img_fail or tbl_ok:
|
||
logger.info("compose: docx 配图嵌入 成功%d 失败%d;表格%d个", img_ok, img_fail, tbl_ok)
|
||
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)
|
||
# ── 材料缺失清单(2026-09-16 占位符规范):合成后自动出清单文件 ──
|
||
# 正文占位符在写作期已自动登记 bid_material_gaps;合成后生成 md 清单落
|
||
# 项目工作空间,供人工补齐(fill_material_gap 自动替换正文占位符)。
|
||
gap_note = ""
|
||
try:
|
||
from .bid_material_capability import material_gap_report
|
||
_ok_r, _rep = await material_gap_report(project_id)
|
||
if _ok_r and _rep and not _rep.startswith("(无材料缺口)"):
|
||
gap_path = os.path.join(ws, "material_gaps_v%d.md" % ver)
|
||
with open(gap_path, "w", encoding="utf-8") as f:
|
||
f.write(_rep)
|
||
_n_pend = _rep.count("⏳待补")
|
||
gap_note = ("\n材料缺失清单已生成:%s(待补 %d 项)。"
|
||
"可人工直接编辑正文补齐,或提供材料后由产线自动补齐"
|
||
"(fill_material_gap 工具替换占位符)。" % (gap_path, _n_pend))
|
||
except Exception as _e:
|
||
logger.warning("material gap report failed: %s", str(_e)[:120])
|
||
return True, ("标书已合成 v%d:%s\n章节 %d 个,约 %d 字,估算 %d 页,对应满分 %.1f%s%s\n"
|
||
"系统将自动派发整书评分任务。"
|
||
% (ver, file_path, len(chapters), total_chars, page_est, max_sum,
|
||
note, gap_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]))
|