test_step4.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. """Step 4 人工验证:按 Step3 最终目录逐章生成并输出块式写作报告。
  2. 本脚本会调用当前配置的 LLM。运行:uv run python scripts/test_step4.py
  3. """
  4. import json
  5. import logging
  6. import os
  7. import pickle
  8. import re
  9. import sys
  10. import zipfile
  11. import xml.etree.ElementTree as ET
  12. from collections import Counter
  13. from _bootstrap import PROJECT_ROOT
  14. REFERENCE_BID = os.environ.get(
  15. "PROPOSA_REFERENCE_BID",
  16. "test_data/171-上海群众艺术馆/参考投标文件/物业管理费项目投标文件.docx",
  17. )
  18. TEMPLATE_PATH = os.environ.get(
  19. "PROPOSA_TEMPLATE_PATH", "src/templates/申勤投标模板.docx"
  20. )
  21. OUTPUT_DIR = os.environ.get(
  22. "PROPOSA_STEP4_OUTPUT_DIR",
  23. os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
  24. )
  25. STEP2_INFO_FILE = os.environ.get(
  26. "PROPOSA_STEP2_INFO_FILE",
  27. os.path.join(OUTPUT_DIR, "step2_info.pkl"),
  28. )
  29. STEP3_OUTLINE_FILE = os.environ.get(
  30. "PROPOSA_STEP3_OUTLINE_FILE",
  31. os.path.join(OUTPUT_DIR, "step3_outline.pkl"),
  32. )
  33. CHAPTER_OUTPUT_DIR = os.path.join(OUTPUT_DIR, "step4_chapters")
  34. os.makedirs(OUTPUT_DIR, exist_ok=True)
  35. os.environ["BID_OUTLINE_CACHE_DIR"] = os.path.join(OUTPUT_DIR, ".outline_cache")
  36. os.environ["BID_LLM_CACHE_DIR"] = os.path.join(OUTPUT_DIR, ".llm_cache")
  37. logging.basicConfig(
  38. level=logging.INFO,
  39. format="%(asctime)s [%(levelname)s] %(message)s",
  40. datefmt="%H:%M:%S",
  41. stream=sys.stderr,
  42. )
  43. def _load_reference_bid(path, project_data, output_dir):
  44. """参考投书载入入口,保留给既有回归测试兼容。
  45. 当前手工流程已在 test_step2.py 完成参考投书正文与表格载入并写入
  46. step2_info.pkl;test_step4.py 主流程不再调用本函数。
  47. """
  48. if not path or not os.path.isfile(path):
  49. return 0
  50. from doc_reader.reader import read_docx_paragraph_texts_et
  51. from models import ParsedDocument
  52. from step1_parsing.table_extractor import extract_reference_tables_with_llm
  53. reference_content = "\n".join(read_docx_paragraph_texts_et(path))
  54. if reference_content:
  55. project_data.reference_bids.append(ParsedDocument(
  56. file_path=path,
  57. file_name=os.path.basename(path),
  58. content=reference_content,
  59. doc_type="reference_bid",
  60. ))
  61. reference_table_dir = os.path.join(output_dir, "内容提取_参考投书")
  62. project_data.reference_tables = extract_reference_tables_with_llm(
  63. path, output_dir=reference_table_dir
  64. )
  65. return len(project_data.reference_tables)
  66. def _walk(chapters):
  67. for chapter in chapters:
  68. yield chapter
  69. yield from _walk(chapter.children)
  70. def _inspect_chapter_artifact(path):
  71. result = {
  72. "native_table_count": 0,
  73. "native_image_count": 0,
  74. "unresolved_placeholders": [],
  75. }
  76. if not path or not os.path.isfile(path):
  77. return result
  78. word_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  79. with zipfile.ZipFile(path, "r") as package:
  80. root = ET.fromstring(package.read("word/document.xml"))
  81. text = "".join(
  82. node.text or "" for node in root.iter(f"{{{word_ns}}}t")
  83. )
  84. result["native_table_count"] = sum(
  85. 1 for _ in root.iter(f"{{{word_ns}}}tbl")
  86. )
  87. result["native_image_count"] = sum(
  88. 1 for _ in root.iter(f"{{{word_ns}}}drawing")
  89. )
  90. result["unresolved_placeholders"] = sorted(set(
  91. re.findall(r"%%[^%\n]{1,80}%%", text)
  92. ))
  93. return result
  94. def _chapter_record(chapter, entry_counts):
  95. blocks = list(chapter.content_blocks or [])
  96. supplements = [
  97. node for node in _walk([chapter]) if node.supplement_content.strip()
  98. ]
  99. record = {
  100. "id": chapter.id,
  101. "title": chapter.title,
  102. "chapter_type": chapter.chapter_type.value,
  103. "template_original_id": chapter.template_original_id,
  104. "template_original_title": chapter.template_original_title,
  105. "preserve_template_layout": bool(chapter.preserve_template_layout),
  106. "template_fill_completed": chapter.template_fill_completed,
  107. "generated_chars": len(chapter.generated_content or ""),
  108. "scoring_binding_count": sum(
  109. len(node.direct_scoring_bindings or node.direct_scoring_criteria)
  110. for node in _walk([chapter])
  111. ),
  112. "rejection_binding_count": sum(
  113. len(node.direct_rejection_bindings) for node in _walk([chapter])
  114. ),
  115. "index_entry_count": entry_counts.get(chapter.id, 0),
  116. "supplement_node_count": len(supplements),
  117. "supplement_chars": sum(len(node.supplement_content) for node in supplements),
  118. "content_blocks": blocks,
  119. "artifact_path": chapter.artifact_path,
  120. "index_deferred_until_pagination": any(
  121. block.get("block_type") == "deferred_evaluation_index"
  122. for block in blocks
  123. ),
  124. "nodes": [
  125. {
  126. "id": node.id,
  127. "title": node.title,
  128. "template_original_id": node.template_original_id,
  129. "content_generation_mode": node.content_generation_mode,
  130. "scoring_bindings": list(
  131. node.direct_scoring_bindings or node.direct_scoring_criteria
  132. ),
  133. "rejection_bindings": list(node.direct_rejection_bindings),
  134. "supplement_chars": len(node.supplement_content or ""),
  135. "supplement_preview": (node.supplement_content or "")[:240],
  136. }
  137. for node in _walk([chapter])
  138. if (
  139. node.direct_scoring_bindings
  140. or node.direct_scoring_criteria
  141. or node.direct_rejection_bindings
  142. )
  143. ],
  144. }
  145. record.update(_inspect_chapter_artifact(chapter.artifact_path))
  146. return record
  147. def _build_report(outline, analysis=None):
  148. nodes = {node.id: node for node in _walk(outline.chapters)}
  149. errors = []
  150. entry_counts = Counter()
  151. for entry in outline.evaluation_index_entries:
  152. heading_id = str(entry.get("final_heading_id", ""))
  153. entry_counts[heading_id.split(".", 1)[0]] += 1
  154. node = nodes.get(heading_id)
  155. if node is None:
  156. errors.append(f"索引落点不存在: {entry.get('entry_id')} -> {heading_id}")
  157. elif not node.supplement_content.strip():
  158. errors.append(
  159. f"直接落点缺少补充正文: {entry.get('entry_id')} -> "
  160. f"{heading_id} {node.title}"
  161. )
  162. heading_pattern = re.compile(
  163. r"(?m)^\s*(?:#{1,6}\s+|[一二三四五六七八九十]+、|"
  164. r"([一二三四五六七八九十]+)|\d+[.、])"
  165. )
  166. for node in nodes.values():
  167. supplement = node.supplement_content or ""
  168. if supplement and heading_pattern.search(supplement):
  169. errors.append(f"补充块含疑似新标题: {node.id} {node.title}")
  170. if supplement and re.search(r"(?m)^\s*\|.*\|\s*$", supplement):
  171. errors.append(f"补充块含 Markdown 表格: {node.id} {node.title}")
  172. for chapter in outline.chapters:
  173. if not chapter.preserve_template_layout:
  174. continue
  175. kinds = [str(block.get("block_type", "")) for block in chapter.content_blocks]
  176. if not chapter.template_fill_completed:
  177. errors.append(f"模板章未完成项目信息填充: {chapter.id} {chapter.title}")
  178. allowed_prefixes = (
  179. ["template_base", "native_table_plan"],
  180. ["template_base", "deferred_evaluation_index"],
  181. )
  182. if kinds[:2] not in allowed_prefixes:
  183. errors.append(f"模板章块顺序错误: {chapter.id} {chapter.title}: {kinds}")
  184. records = [_chapter_record(chapter, entry_counts) for chapter in outline.chapters]
  185. for record in records:
  186. if record["unresolved_placeholders"]:
  187. errors.append(
  188. f"章节产物仍有未解析占位符: {record['id']} {record['title']}: "
  189. + ", ".join(record["unresolved_placeholders"][:10])
  190. )
  191. project_fields = dict(
  192. getattr(analysis, "project_fields", {}) or {}
  193. ) if analysis is not None else {}
  194. if analysis is not None and getattr(analysis, "agency_name", ""):
  195. project_fields["采购机构名称"] = analysis.agency_name
  196. return {
  197. "passed": not errors,
  198. "errors": errors,
  199. "summary": {
  200. "chapter_count": len(outline.chapters),
  201. "heading_count": len(nodes),
  202. "evaluation_index_entry_count": len(outline.evaluation_index_entries),
  203. "template_preserved_chapter_count": sum(
  204. 1 for chapter in outline.chapters if chapter.preserve_template_layout
  205. ),
  206. "supplement_node_count": sum(
  207. 1 for node in nodes.values() if node.supplement_content.strip()
  208. ),
  209. },
  210. "project_fields": project_fields,
  211. "chapters": records,
  212. }
  213. def _write_report(report):
  214. json_path = os.path.join(OUTPUT_DIR, "step4_generation_report.json")
  215. md_path = os.path.join(OUTPUT_DIR, "step4_generation_report.md")
  216. with open(json_path, "w", encoding="utf-8") as file:
  217. json.dump(report, file, ensure_ascii=False, indent=2)
  218. summary = report["summary"]
  219. lines = [
  220. "# Step4 逐章生成检查报告", "",
  221. f"- 门禁结果:{'通过' if report['passed'] else '失败'}",
  222. f"- 一级章:{summary['chapter_count']};全部标题:{summary['heading_count']}",
  223. f"- 评分/废标索引条目:{summary['evaluation_index_entry_count']}",
  224. f"- 模板保留章:{summary['template_preserved_chapter_count']};"
  225. f"实际补充节点:{summary['supplement_node_count']}", "",
  226. ]
  227. if report["errors"]:
  228. lines.extend(["## 门禁错误", ""])
  229. lines.extend(f"- {error}" for error in report["errors"])
  230. lines.append("")
  231. if report.get("project_fields"):
  232. lines.extend(["## Step2 项目填表字段", ""])
  233. lines.extend(
  234. f"- {key}:{value or '未提取'}"
  235. for key, value in report["project_fields"].items()
  236. )
  237. lines.append("")
  238. lines.extend(["## 各章生成情况", ""])
  239. for chapter in report["chapters"]:
  240. block_order = ", ".join(
  241. str(block.get("block_type", "")) for block in chapter["content_blocks"]
  242. ) or "无"
  243. lines.extend([
  244. f"### {chapter['id']} {chapter['title']}", "",
  245. f"- 类型:{chapter['chapter_type']};模板保留:{chapter['preserve_template_layout']};"
  246. f"项目填充完成:{chapter['template_fill_completed']}",
  247. f"- 模板原节点:{chapter['template_original_id']} {chapter['template_original_title']}",
  248. f"- 基础正文:{chapter['generated_chars']} 字;补充节点:"
  249. f"{chapter['supplement_node_count']};补充正文:{chapter['supplement_chars']} 字",
  250. f"- 评分绑定:{chapter['scoring_binding_count']};废标绑定:"
  251. f"{chapter['rejection_binding_count']};索引条目:{chapter['index_entry_count']}",
  252. f"- 内容块顺序:{block_order}",
  253. f"- 原生表格:{chapter['native_table_count']};原生图片:"
  254. f"{chapter['native_image_count']};未解析占位符:"
  255. f"{', '.join(chapter['unresolved_placeholders']) or '无'}",
  256. f"- 最终分页后构建索引:{chapter['index_deferred_until_pagination']}",
  257. f"- 章节产物:{chapter['artifact_path'] or '无'}", "",
  258. ])
  259. for node in chapter["nodes"]:
  260. preview = node["supplement_preview"].replace("\n", " ") or "无"
  261. lines.extend([
  262. f"- `{node['id']}` {node['title']}(模板原节点 "
  263. f"`{node['template_original_id'] or '-'}`;模式 "
  264. f"`{node['content_generation_mode'] or '-'}`;评分 "
  265. f"{', '.join(node['scoring_bindings']) or '-'};废标 "
  266. f"{', '.join(node['rejection_bindings']) or '-'};补充 "
  267. f"{node['supplement_chars']} 字)",
  268. f" - 预览:{preview}",
  269. ])
  270. if chapter["nodes"]:
  271. lines.append("")
  272. with open(md_path, "w", encoding="utf-8") as file:
  273. file.write("\n".join(lines).rstrip() + "\n")
  274. return md_path, json_path
  275. def main():
  276. from step4_writing import write_content
  277. print("=" * 60)
  278. print("Step 2: 复用已落盘的分析与项目资料(Step 4 的前置)")
  279. print("=" * 60)
  280. if not os.path.isfile(STEP2_INFO_FILE):
  281. raise FileNotFoundError(
  282. f"Step2 分析结果不存在: {STEP2_INFO_FILE}\n"
  283. "请先运行 uv run python scripts/test_step2.py。"
  284. )
  285. with open(STEP2_INFO_FILE, "rb") as file:
  286. step2_payload = pickle.load(file)
  287. analysis = step2_payload["analysis"]
  288. project_data = step2_payload["project_data"]
  289. print(f"通用资料: {len(project_data.general_materials)} 个文件")
  290. print(f"参考投标书: {len(project_data.reference_bids)} 篇")
  291. print(f"参考投书表格: {len(project_data.reference_tables)} 张")
  292. print(
  293. f"复用 Step2 分析: {len(analysis.scoring_criteria)} 评分项, "
  294. f"{len(analysis.rejection_items)} 废标项"
  295. )
  296. print("\n" + "=" * 60)
  297. print("Step 3: 复用已落盘的最终目录(Step 4 的唯一结构来源)")
  298. print("=" * 60)
  299. if not os.path.isfile(STEP3_OUTLINE_FILE):
  300. raise FileNotFoundError(
  301. f"Step3 大纲结果不存在: {STEP3_OUTLINE_FILE}\n"
  302. "请先运行 uv run python scripts/test_step3.py。"
  303. )
  304. with open(STEP3_OUTLINE_FILE, "rb") as file:
  305. outline = pickle.load(file)
  306. print(
  307. f"最终目录: {len(outline.chapters)} 章, "
  308. f"{len(outline.evaluation_index_entries)} 条索引映射"
  309. )
  310. print("\n" + "=" * 60)
  311. print("Step 4: 按模板基块 → 项目/表格填充 → 评分/废标补充块生成")
  312. print("=" * 60)
  313. outline = write_content(
  314. outline, analysis, project_data,
  315. template_path=TEMPLATE_PATH,
  316. reference_bid_path=REFERENCE_BID,
  317. chapter_output_dir=CHAPTER_OUTPUT_DIR,
  318. )
  319. report = _build_report(outline, analysis)
  320. md_path, json_path = _write_report(report)
  321. print(f"逐章可读报告: {md_path}")
  322. print(f"机器可读报告: {json_path}")
  323. for chapter in report["chapters"]:
  324. print(
  325. f" {chapter['id']} {chapter['title']}: 基础 {chapter['generated_chars']:,} 字, "
  326. f"补充 {chapter['supplement_chars']:,} 字/{chapter['supplement_node_count']} 节点, "
  327. f"评分 {chapter['scoring_binding_count']}, 废标 {chapter['rejection_binding_count']}"
  328. )
  329. if report["errors"]:
  330. raise AssertionError(
  331. "Step4 块式生成门禁失败;详见报告:\n- "
  332. + "\n- ".join(report["errors"][:30])
  333. )
  334. print("\nStep 4 测试完成 [OK]")
  335. if __name__ == "__main__":
  336. main()