pipeline-bidding/pipeline_bidding/bid_compose_capability.py

419 lines
19 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_documentsversion 递增)+ 工作空间文件,并登记为交付件。
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 图片行:![alt](url)(章节写作者按角色 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 图片行 ![alt](url)(写作者经 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]))