test_step6.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. """Step 6 人工验证:聚合 Step5 章节内容为最终投标 DOCX。
  2. 输入(均使用已生成中间产物,不重跑 Step1~Step5):
  3. - Step3 最终大纲与评分/废标映射(step5_outline.pkl,由 Step5 落盘,
  4. 内含 heading_mappings 与 evaluation_index_entries)
  5. - Step5 各章节 DOCX(step5_chapters/,含 manifest.json)
  6. - 正式模板 DOCX(提供样式、页眉页脚部件与封面/目录骨架)
  7. 输出:
  8. - 一份聚合 DOCX:封面 → 自动目录 → 商务部分与评标索引表 → 商务章 →
  9. 技术部分 → 技术章;并完成标题/字号/字体/颜色/行距/缩进检查与修复。
  10. 用法(按顺序运行):
  11. 1. uv run python scripts/test_step1.py;
  12. 2. uv run python scripts/test_step2.py;
  13. 3. uv run python scripts/test_step3.py;
  14. 4. uv run python scripts/test_step4.py;
  15. 5. uv run python scripts/test_step5.py;
  16. 6. uv run python scripts/test_step6.py。
  17. """
  18. import glob
  19. import json
  20. import logging
  21. import os
  22. import pickle
  23. import re
  24. import sys
  25. import zipfile
  26. import xml.etree.ElementTree as ET
  27. from _bootstrap import PROJECT_ROOT
  28. OUTPUT_DIR = os.environ.get(
  29. "PROPOSA_STEP6_OUTPUT_DIR",
  30. os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
  31. )
  32. STEP5_CHAPTERS_DIR = os.environ.get(
  33. "PROPOSA_STEP5_CHAPTERS_DIR",
  34. os.path.join(OUTPUT_DIR, "step5_chapters"),
  35. )
  36. STEP5_OUTLINE_FILE = os.environ.get(
  37. "PROPOSA_STEP5_REVIEWED_OUTLINE_FILE",
  38. os.path.join(OUTPUT_DIR, "step5_outline.pkl"),
  39. )
  40. STEP3_REPORT_FILE = os.environ.get(
  41. "PROPOSA_STEP3_REPORT_FILE",
  42. os.path.join(OUTPUT_DIR, "step3_outline_report.json"),
  43. )
  44. TEMPLATE_PATH = os.environ.get(
  45. "PROPOSA_TEMPLATE_PATH",
  46. "src/templates/申勤投标模板.docx",
  47. )
  48. REFERENCE_TABLES_DIR = os.environ.get(
  49. "PROPOSA_REFERENCE_TABLES_DIR",
  50. os.path.join(OUTPUT_DIR, "内容提取_参考投书"),
  51. )
  52. OUTPUT_FILE = os.environ.get(
  53. "PROPOSA_STEP6_OUTPUT",
  54. os.path.join(OUTPUT_DIR, "171-上海群众艺术馆.docx"),
  55. )
  56. _W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  57. def _w(tag: str) -> str:
  58. return f"{{{_W_NS}}}{tag}"
  59. def _element_text(element) -> str:
  60. return "".join(node.text or "" for node in element.iter(_w("t")))
  61. def _find_manifest(chapters_dir):
  62. if not os.path.isdir(chapters_dir):
  63. raise FileNotFoundError(
  64. f"Step5 章节目录不存在: {chapters_dir}\n"
  65. "请先按顺序运行 uv run python scripts/test_step1.py 至 "
  66. "uv run python scripts/test_step5.py。"
  67. )
  68. for candidate in glob.glob(
  69. os.path.join(chapters_dir, "chapters", "*", "manifest.json")
  70. ):
  71. return candidate
  72. return ""
  73. def _load_chapter_records(chapters_dir):
  74. manifest_path = _find_manifest(chapters_dir)
  75. if not manifest_path:
  76. raise FileNotFoundError(
  77. f"Step5 章节 manifest 不存在: {chapters_dir}\n"
  78. "请先运行 uv run python scripts/test_step5.py。"
  79. )
  80. with open(manifest_path, "r", encoding="utf-8") as file:
  81. manifest = json.load(file)
  82. records = [
  83. {
  84. "id": str(record.get("id", "")),
  85. "title": str(record.get("title", "")),
  86. "artifact_path": str(record.get("artifact_path", "")),
  87. "status": str(record.get("status", "complete")),
  88. }
  89. for record in manifest.get("chapters", [])
  90. ]
  91. if not records:
  92. raise RuntimeError(f"Step5 manifest 中没有章节记录: {manifest_path}")
  93. return records
  94. def _load_outline(path):
  95. if not os.path.isfile(path):
  96. raise FileNotFoundError(
  97. f"Step5 大纲/映射不存在: {path}\n"
  98. "请先运行 uv run python scripts/test_step5.py。"
  99. )
  100. with open(path, "rb") as file:
  101. return pickle.load(file)
  102. def _find_index_table_template(reference_tables_dir):
  103. """定位 Step1 从参考投书提取的评标索引表 DOCX。"""
  104. if not os.path.isdir(reference_tables_dir):
  105. return ""
  106. matches = []
  107. for candidate in glob.glob(
  108. os.path.join(reference_tables_dir, "*与评标有关的投标文件主要内容索引表*.docx")
  109. ):
  110. matches.append(candidate)
  111. if not matches:
  112. return ""
  113. matches.sort()
  114. return matches[0]
  115. def _assert_output_structure(output_path, outline):
  116. if not os.path.isfile(output_path):
  117. raise AssertionError(f"Step6 输出文件未生成: {output_path}")
  118. with zipfile.ZipFile(output_path, "r") as output_zip:
  119. names = set(output_zip.namelist())
  120. for required in ("word/document.xml", "word/header1.xml", "word/footer1.xml"):
  121. if required not in names:
  122. raise AssertionError(f"输出 DOCX 缺少部件: {required}")
  123. body = ET.fromstring(output_zip.read("word/document.xml")).find(_w("body"))
  124. if body is None:
  125. raise AssertionError("输出 DOCX 缺少 w:body")
  126. body_text = _element_text(body)
  127. instr_texts = [node.text or "" for node in body.iter(_w("instrText"))]
  128. instr_combined = " ".join(instr_texts)
  129. # 1. 每个数字顶层章必须作为 Heading 1 出现在正文中。
  130. numeric_chapters = [
  131. chapter
  132. for chapter in outline.chapters
  133. if str(getattr(chapter, "id", "")).isdigit()
  134. ]
  135. heading1_texts = []
  136. for paragraph in body.iter(_w("p")):
  137. p_pr = paragraph.find(_w("pPr"))
  138. style_id = ""
  139. if p_pr is not None:
  140. p_style = p_pr.find(_w("pStyle"))
  141. if p_style is not None:
  142. style_id = (p_style.get(_w("val")) or "").strip().lower()
  143. if style_id in ("2", "heading1"):
  144. heading1_texts.append(_element_text(paragraph).strip())
  145. missing = [
  146. f"{chapter.id} {chapter.title}"
  147. for chapter in numeric_chapters
  148. if not any(
  149. chapter.title in text and re.match(r"^第\s*[一二三四五六七八九十\d]+\s*章", text)
  150. for text in heading1_texts
  151. )
  152. ]
  153. if missing:
  154. raise AssertionError("最终 DOCX 缺少顶层章节: " + ", ".join(missing))
  155. # 2. 自动目录域必须存在。
  156. if "TOC" not in instr_combined:
  157. raise AssertionError("最终 DOCX 未插入 Word 自动目录(TOC 域)")
  158. # 3. 评标索引表行数必须与 Step3 持久化的评分项条数一致(废标项不进入索引表)。
  159. expected_rows = len(
  160. [
  161. entry
  162. for entry in (outline.evaluation_index_entries or [])
  163. if str(entry.get("entry_type")) == "scoring"
  164. ]
  165. )
  166. tables = list(body.iter(_w("tbl")))
  167. if not tables:
  168. raise AssertionError("最终 DOCX 缺少评标索引表")
  169. index_table = tables[0]
  170. index_rows = index_table.findall(_w("tr"))
  171. if len(index_rows) != expected_rows + 1:
  172. raise AssertionError(
  173. f"评标索引表行数 {len(index_rows)} 与 Step3 评分项条数 {expected_rows} 不一致"
  174. )
  175. # 4. 页眉/页脚应保留 Step5 章节的原生内容(项目名 + 模板页眉)。
  176. header1_text = _element_text(ET.fromstring(output_zip.read("word/header1.xml")))
  177. footer1_text = _element_text(ET.fromstring(output_zip.read("word/footer1.xml")))
  178. if not header1_text.strip():
  179. raise AssertionError("商务部分页眉为空")
  180. if (outline.project_name or "").strip() and outline.project_name not in header1_text:
  181. raise AssertionError(
  182. f"商务部分页眉缺少项目名: {outline.project_name!r}"
  183. )
  184. if "PAGE" not in " ".join(
  185. node.text or "" for node in ET.fromstring(output_zip.read("word/footer1.xml")).iter(_w("instrText"))
  186. ):
  187. raise AssertionError("页脚缺少 PAGE 页码域")
  188. return {
  189. "chapters": len(numeric_chapters),
  190. "index_rows": expected_rows,
  191. "tables": len(tables),
  192. "heading1_count": len(heading1_texts),
  193. }
  194. def main():
  195. logging.basicConfig(
  196. level=logging.INFO,
  197. format="%(asctime)s [%(levelname)s] %(message)s",
  198. datefmt="%H:%M:%S",
  199. stream=sys.stderr,
  200. )
  201. if not os.path.isfile(STEP3_REPORT_FILE):
  202. raise FileNotFoundError(
  203. f"Step3 大纲/评分映射不存在: {STEP3_REPORT_FILE}\n"
  204. "请先运行 uv run python scripts/test_step3.py。"
  205. )
  206. with open(STEP3_REPORT_FILE, "r", encoding="utf-8") as file:
  207. step3_report = json.load(file)
  208. outline = _load_outline(STEP5_OUTLINE_FILE)
  209. chapter_records = _load_chapter_records(STEP5_CHAPTERS_DIR)
  210. index_table_template_path = _find_index_table_template(REFERENCE_TABLES_DIR)
  211. print("=" * 60)
  212. print("Step 6: 聚合 Step5 章节内容为最终投标 DOCX")
  213. print("=" * 60)
  214. print(f"章节记录数: {len(chapter_records)}")
  215. print(f"Step3 评分/废标索引条数: {len(outline.evaluation_index_entries or [])}")
  216. print(f"Step3 标题关系映射条数: {len(outline.heading_mappings or [])}")
  217. print(
  218. f"Step3 报告索引条数: {len(step3_report.get('evaluation_index_entries', []) or [])}, "
  219. f"标题映射条数: {len(step3_report.get('heading_mappings', []) or [])}"
  220. )
  221. print(f"模板: {TEMPLATE_PATH}")
  222. print(
  223. f"评标索引表模板: {index_table_template_path or '未找到,使用兜底表格'}"
  224. )
  225. from step6_exporting.assembler import assemble_step5_document
  226. report = assemble_step5_document(
  227. chapter_records,
  228. outline,
  229. OUTPUT_FILE,
  230. template_path=TEMPLATE_PATH,
  231. index_table_template_path=index_table_template_path,
  232. project_name=outline.project_name,
  233. )
  234. print()
  235. print("=== 聚合结果 ===")
  236. print(f"输出文件: {report.output_path}")
  237. print(f"聚合章节数: {report.chapter_count}")
  238. print(f"索引表行数: {report.index_row_count}")
  239. print(f"格式修复 run 数: {report.fixed_runs}")
  240. for warning in report.warnings:
  241. print(f"[WARN] {warning}")
  242. structure = _assert_output_structure(report.output_path, outline)
  243. print()
  244. print("=== 结构校验 ===")
  245. print(
  246. f"[PASS] 顶层章 {structure['chapters']} 个,Heading 1 {structure['heading1_count']} 个,"
  247. f"索引 {structure['index_rows']} 行,表格 {structure['tables']} 张,自动目录已插入"
  248. )
  249. print("Step 6 测试完成 [OK]")
  250. if __name__ == "__main__":
  251. main()