"""标书合成能力(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 图片行:![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'', ' ', 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]))