test_step5.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. """Step 5 人工验证:对 Step4 实际落盘的章节内容做审核与修复。
  2. 输入(均使用已生成中间产物,不重跑 Step1~Step4):
  3. - Step2 分析结果 step2_info.pkl(评分项、废标项)
  4. - Step3 大纲与评分/废标映射 step3_outline_report.json
  5. - Step4 实际章节 DOCX step4_chapters/
  6. 用法(按顺序运行):
  7. 1. uv run python scripts/test_step1.py;
  8. 2. uv run python scripts/test_step2.py;
  9. 3. uv run python scripts/test_step3.py;
  10. 4. uv run python scripts/test_step4.py;
  11. 5. uv run python scripts/test_step5.py。
  12. """
  13. import glob
  14. import json
  15. import logging
  16. import os
  17. import pickle
  18. import re
  19. import sys
  20. import xml.etree.ElementTree as ET
  21. from collections import Counter
  22. from _bootstrap import PROJECT_ROOT
  23. from models import BidOutline, Chapter, ChapterType
  24. OUTPUT_DIR = os.environ.get(
  25. "PROPOSA_STEP5_OUTPUT_DIR",
  26. os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
  27. )
  28. os.environ.setdefault("BID_LLM_CACHE_DIR", os.path.join(OUTPUT_DIR, ".llm_cache"))
  29. os.environ.setdefault("BID_LLM_CACHE", "0")
  30. STEP2_INFO_FILE = os.environ.get(
  31. "PROPOSA_STEP2_INFO_FILE",
  32. os.path.join(OUTPUT_DIR, "step2_info.pkl"),
  33. )
  34. STEP3_REPORT_FILE = os.environ.get(
  35. "PROPOSA_STEP3_REPORT_FILE",
  36. os.path.join(OUTPUT_DIR, "step3_outline_report.json"),
  37. )
  38. STEP4_CHAPTERS_DIR = os.environ.get(
  39. "PROPOSA_STEP4_CHAPTERS_DIR",
  40. os.path.join(OUTPUT_DIR, "step4_chapters"),
  41. )
  42. STEP5_OUTLINE_FILE = os.environ.get(
  43. "PROPOSA_STEP5_REVIEWED_OUTLINE_FILE",
  44. os.path.join(OUTPUT_DIR, "step5_outline.pkl"),
  45. )
  46. STEP5_CHAPTERS_DIR = os.environ.get(
  47. "PROPOSA_STEP5_CHAPTERS_DIR",
  48. os.path.join(OUTPUT_DIR, "step5_chapters"),
  49. )
  50. logging.basicConfig(
  51. level=logging.INFO,
  52. format="%(asctime)s [%(levelname)s] %(message)s",
  53. datefmt="%H:%M:%S",
  54. stream=sys.stderr,
  55. )
  56. def _load_step2_info(path):
  57. if not os.path.isfile(path):
  58. raise FileNotFoundError(
  59. f"Step2 分析结果不存在: {path}\n"
  60. "请先按顺序运行 uv run python scripts/test_step1.py 和 "
  61. "uv run python scripts/test_step2.py。"
  62. )
  63. with open(path, "rb") as file:
  64. payload = pickle.load(file)
  65. analysis = payload.get("analysis")
  66. project_data = payload.get("project_data")
  67. if analysis is None:
  68. raise RuntimeError(f"Step2 文件缺少 analysis: {path}")
  69. return analysis, project_data
  70. def _load_step3_report(path):
  71. if not os.path.isfile(path):
  72. raise FileNotFoundError(
  73. f"Step3 大纲/映射不存在: {path}\n"
  74. "请先按顺序运行 uv run python scripts/test_step1.py 至 "
  75. "uv run python scripts/test_step3.py。"
  76. )
  77. with open(path, "r", encoding="utf-8") as file:
  78. return json.load(file)
  79. def _find_manifest(chapters_dir):
  80. for candidate in glob.glob(
  81. os.path.join(chapters_dir, "chapters", "*", "manifest.json")
  82. ):
  83. return candidate
  84. return ""
  85. def _load_chapter_texts(chapters_dir):
  86. """读取 Step4 实际落盘的每个章节 DOCX 文本。"""
  87. from doc_reader import read_file
  88. manifest_path = _find_manifest(chapters_dir)
  89. records = []
  90. if manifest_path:
  91. with open(manifest_path, "r", encoding="utf-8") as file:
  92. manifest = json.load(file)
  93. records = manifest.get("chapters", [])
  94. else:
  95. for path in glob.glob(
  96. os.path.join(chapters_dir, "chapters", "*", "*.docx")
  97. ):
  98. base = os.path.basename(path)
  99. chapter_id = base.split("_", 1)[0]
  100. records.append({"id": chapter_id, "artifact_path": path})
  101. texts = {}
  102. for record in records:
  103. chapter_id = str(record.get("id", ""))
  104. path = record.get("artifact_path", "")
  105. if not chapter_id or not path or not os.path.isfile(path):
  106. continue
  107. content = read_file(path)
  108. if content:
  109. texts[chapter_id] = content
  110. return texts
  111. def _load_step4_records(chapters_dir):
  112. """读取 Step4 manifest 中的章节文件记录(id/标题/源路径/状态)。"""
  113. manifest_path = _find_manifest(chapters_dir)
  114. if not manifest_path:
  115. records = []
  116. for path in glob.glob(
  117. os.path.join(chapters_dir, "chapters", "*", "*.docx")
  118. ):
  119. base = os.path.basename(path)
  120. records.append({
  121. "id": base.split("_", 1)[0],
  122. "title": base.rsplit("_", 1)[0],
  123. "artifact_path": path,
  124. "status": "complete",
  125. })
  126. return records
  127. with open(manifest_path, "r", encoding="utf-8") as file:
  128. manifest = json.load(file)
  129. return [
  130. {
  131. "id": str(record.get("id", "")),
  132. "title": str(record.get("title", "")),
  133. "artifact_path": str(record.get("artifact_path", "")),
  134. "status": str(record.get("status", "complete")),
  135. }
  136. for record in manifest.get("chapters", [])
  137. if record.get("artifact_path")
  138. ]
  139. def _chapter_type_for(final_id: str) -> ChapterType:
  140. return (
  141. ChapterType.BUSINESS
  142. if final_id in {"index", "1", "2"}
  143. else ChapterType.TECHNICAL
  144. )
  145. def _build_outline_from_report(report, chapter_texts):
  146. mappings = list(report.get("heading_mappings", []) or [])
  147. entries = list(report.get("evaluation_index_entries", []) or [])
  148. nodes = {}
  149. for mapping in mappings:
  150. final_id = str(mapping.get("final_id", ""))
  151. if not final_id:
  152. continue
  153. level = 1 if final_id == "index" else 1 + final_id.count(".")
  154. nodes[final_id] = Chapter(
  155. id=final_id,
  156. title=str(mapping.get("final_title", "") or ""),
  157. chapter_type=_chapter_type_for(final_id),
  158. level=level,
  159. template_original_title=str(mapping.get("template_title", "") or ""),
  160. template_original_id=str(mapping.get("template_id", "") or ""),
  161. template_chapter_id=(
  162. str(mapping.get("template_id", "") or "")
  163. if level == 1 else ""
  164. ),
  165. content_generation_mode=str(
  166. mapping.get("content_generation_mode", "") or ""
  167. ),
  168. direct_scoring_bindings=list(mapping.get("scoring_bindings", []) or []),
  169. direct_rejection_bindings=list(mapping.get("rejection_bindings", []) or []),
  170. structure_locked=bool(
  171. mapping.get("scoring_bindings") or mapping.get("rejection_bindings")
  172. ),
  173. )
  174. for entry in entries:
  175. heading_id = str(entry.get("final_heading_id", "") or "")
  176. node = nodes.get(heading_id)
  177. if node is None:
  178. continue
  179. if entry.get("entry_type") == "scoring":
  180. criterion_id = str(entry.get("criterion_id", "") or "")
  181. if criterion_id and criterion_id not in node.related_criteria:
  182. node.related_criteria.append(criterion_id)
  183. if criterion_id and criterion_id not in node.direct_scoring_criteria:
  184. node.direct_scoring_criteria.append(criterion_id)
  185. elif entry.get("entry_type") == "rejection":
  186. source_id = str(entry.get("source_id", "") or "")
  187. if source_id and source_id not in node.related_rejections:
  188. node.related_rejections.append(source_id)
  189. roots = []
  190. for mapping in mappings:
  191. final_id = str(mapping.get("final_id", ""))
  192. parent_id = str(mapping.get("parent_final_id", "") or "")
  193. node = nodes.get(final_id)
  194. if node is None:
  195. continue
  196. parent = nodes.get(parent_id) if parent_id else None
  197. if parent is not None:
  198. node.chapter_type = parent.chapter_type
  199. if node not in parent.children:
  200. parent.children.append(node)
  201. else:
  202. roots.append(node)
  203. for chapter in roots:
  204. chapter.generated_content = chapter_texts.get(chapter.id, "")
  205. outline = BidOutline(
  206. project_name=str(report.get("project", "") or "投标项目"),
  207. chapters=roots,
  208. )
  209. outline.heading_mappings = list(mappings)
  210. outline.evaluation_index_entries = list(entries)
  211. return outline
  212. def _safe_project_name(name):
  213. return (
  214. re.sub(r'[<>:"/\\|?*]+', "_", name or "投标项目").strip(" ._")
  215. or "投标项目"
  216. )
  217. def _normalize_docx_text(text):
  218. return re.sub(r"[\s\u3000]+", "", text or "")
  219. _EMPTY_LABEL_RE = re.compile(
  220. r"^(项目名称|项目编号|招标编号|招标项目编号|包号|包件号|包件名称|包名|"
  221. r"服务内容|服务要求|服务期限)[::]\s*$"
  222. )
  223. _PROJECT_LABEL_RE = re.compile(
  224. r"^(项目名称|项目编号|招标编号|招标项目编号|包号|包件号|包件名称|包名|"
  225. r"服务内容|服务要求|服务期限)[::]\s*(.*)$"
  226. )
  227. _TITLE_PREFIX_RE = re.compile(
  228. r"^(第[一二三四五六七八九十百\d]+章|"
  229. r"[一二三四五六七八九十百]+、|"
  230. r"[((][一二三四五六七八九十百\d]+[))]|"
  231. r"\d+[.、.]|"
  232. r"[((]\d+[))]|"
  233. r"\d+\)|[a-zA-Z][.、.)])"
  234. )
  235. _PROTECTED_SIGNATURE_RE = re.compile(
  236. r"^\s*(投标人授权代表签字|投标人(公章)|投标人\(公章\)|日期|法定代表人|"
  237. r"授权代表|签署人)[::((]"
  238. )
  239. _PACKAGE_LABELS = {"包号", "包件", "包件号", "包件名称", "包名"}
  240. _PROJECT_LABEL_ALIASES = {
  241. "招标编号": "项目编号",
  242. "招标项目编号": "项目编号",
  243. "包件号": "包号",
  244. "包件名称": "包号",
  245. "包名": "包号",
  246. }
  247. def _heading_level(paragraph):
  248. style = getattr(getattr(paragraph, "style", None), "name", "") or ""
  249. match = re.match(r"(?:Heading|标题)\s*([1-4])", style, re.I)
  250. return int(match.group(1)) if match else 0
  251. def _clear_paragraph_text(paragraph):
  252. for run in list(paragraph.runs):
  253. run.text = ""
  254. def _ensure_body_indent(paragraph):
  255. """给正文段落写入首行缩进 2 字符(Word firstLineChars=200)。"""
  256. W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  257. p_pr = paragraph.find(f"{{{W_NS}}}pPr")
  258. if p_pr is None:
  259. p_pr = paragraph.makeelement(f"{{{W_NS}}}pPr", {})
  260. paragraph.insert(0, p_pr)
  261. ind = p_pr.find(f"{{{W_NS}}}ind")
  262. if ind is None:
  263. ind = p_pr.makeelement(f"{{{W_NS}}}ind", {})
  264. p_pr.append(ind)
  265. ind.set(f"{{{W_NS}}}firstLineChars", "200")
  266. ind.set(f"{{{W_NS}}}firstLine", "480")
  267. def _apply_body_format(paragraph, W_NS):
  268. """给正文段落应用宋体小四、黑色、1.5 倍行距与首行缩进。"""
  269. p_pr = paragraph.find(f"{{{W_NS}}}pPr")
  270. if p_pr is None:
  271. p_pr = ET.SubElement(paragraph, f"{{{W_NS}}}pPr")
  272. spacing = p_pr.find(f"{{{W_NS}}}spacing")
  273. if spacing is None:
  274. spacing = ET.SubElement(p_pr, f"{{{W_NS}}}spacing")
  275. spacing.set(f"{{{W_NS}}}line", "360")
  276. spacing.set(f"{{{W_NS}}}lineRule", "auto")
  277. spacing.set(f"{{{W_NS}}}before", "0")
  278. spacing.set(f"{{{W_NS}}}after", "0")
  279. ind = p_pr.find(f"{{{W_NS}}}ind")
  280. if ind is None:
  281. ind = ET.SubElement(p_pr, f"{{{W_NS}}}ind")
  282. ind.set(f"{{{W_NS}}}firstLineChars", "200")
  283. ind.set(f"{{{W_NS}}}firstLine", "480")
  284. for run in paragraph.findall(f"{{{W_NS}}}r"):
  285. r_pr = run.find(f"{{{W_NS}}}rPr")
  286. if r_pr is None:
  287. r_pr = ET.SubElement(run, f"{{{W_NS}}}rPr")
  288. r_fonts = r_pr.find(f"{{{W_NS}}}rFonts")
  289. if r_fonts is None:
  290. r_fonts = ET.SubElement(r_pr, f"{{{W_NS}}}rFonts")
  291. r_fonts.set(f"{{{W_NS}}}ascii", "Times New Roman")
  292. r_fonts.set(f"{{{W_NS}}}hAnsi", "Times New Roman")
  293. r_fonts.set(f"{{{W_NS}}}eastAsia", "宋体")
  294. for tag, value in (("sz", "24"), ("szCs", "24")):
  295. node = r_pr.find(f"{{{W_NS}}}{tag}")
  296. if node is None:
  297. node = ET.SubElement(r_pr, f"{{{W_NS}}}{tag}")
  298. node.set(f"{{{W_NS}}}val", value)
  299. color = r_pr.find(f"{{{W_NS}}}color")
  300. if color is None:
  301. color = ET.SubElement(r_pr, f"{{{W_NS}}}color")
  302. color.set(f"{{{W_NS}}}val", "000000")
  303. def _clean_chapter_docx(doc, has_packages):
  304. """直接在 step4 章节 DOCX 上删除包号行和重复正文段落。
  305. has_packages 由 Step2 的 LLM 提取结果决定;只有确认项目无实际包号信息时,
  306. 才删除章节中的包号/包件文字。
  307. """
  308. _PACKAGE_LABELS = {"包号", "包件", "包件号", "包件名称", "包名"}
  309. seen = set()
  310. for paragraph in doc.paragraphs:
  311. text = (paragraph.text or "").strip()
  312. if not text:
  313. continue
  314. level = _heading_level(paragraph)
  315. if level == 0:
  316. if not has_packages and package_re.search(text):
  317. _clear_paragraph_text(paragraph)
  318. continue
  319. key = _normalize_docx_text(text)
  320. if len(key) >= 12 and key in seen:
  321. _clear_paragraph_text(paragraph)
  322. continue
  323. if len(key) >= 12:
  324. seen.add(key)
  325. if has_packages:
  326. return
  327. for table in doc.tables:
  328. for row in table.rows:
  329. for cell in row.cells:
  330. for paragraph in cell.paragraphs:
  331. if package_re.search(paragraph.text or ""):
  332. _clear_paragraph_text(paragraph)
  333. def _find_heading_paragraph(doc, node):
  334. candidates = [
  335. _normalize_docx_text(value)
  336. for value in (node.title, getattr(node, "template_original_title", ""))
  337. if value
  338. ]
  339. for paragraph in doc.paragraphs:
  340. if _heading_level(paragraph) and _normalize_docx_text(paragraph.text) in candidates:
  341. return paragraph
  342. return None
  343. def _insert_node_supplement(doc, node):
  344. """把 Step5 生成的补充正文插入到对应绑定标题之后,不改动原生内容块。"""
  345. content = (getattr(node, "supplement_content", "") or "").strip()
  346. if not content:
  347. content = (getattr(node, "generated_content", "") or "").strip()
  348. if not content:
  349. return 0
  350. anchor = _find_heading_paragraph(doc, node)
  351. if anchor is None:
  352. return 0
  353. existing = {
  354. _normalize_docx_text(paragraph.text)
  355. for paragraph in doc.paragraphs
  356. }
  357. lines = []
  358. for raw in content.splitlines():
  359. line = raw.strip()
  360. key = _normalize_docx_text(line)
  361. if key and key not in existing:
  362. lines.append(line)
  363. existing.add(key)
  364. if not lines:
  365. return 0
  366. node_level = int(getattr(node, "level", 2) or 2)
  367. boundary = None
  368. anchor_index = None
  369. for index, paragraph in enumerate(doc.paragraphs):
  370. if paragraph is anchor:
  371. anchor_index = index
  372. continue
  373. if anchor_index is not None:
  374. level = _heading_level(paragraph)
  375. if level and level <= node_level:
  376. boundary = paragraph
  377. break
  378. if boundary is not None:
  379. for line in reversed(lines):
  380. boundary.insert_paragraph_before(line)
  381. else:
  382. for line in lines:
  383. doc.add_paragraph(line)
  384. return len(lines)
  385. def _has_package_info(analysis):
  386. """依据 Step2 LLM 提取结果判断项目是否真的存在包号/包件信息。"""
  387. from step5_reviewing.policies import analysis_has_package_info
  388. return analysis_has_package_info(analysis)
  389. def _safe_copy_and_clean_chapter(source, target, has_packages, remove_keys=None, fills=None):
  390. """用标准库重写 document.xml,避免 python-docx/lxml 在大型章节上 0xC0000005。"""
  391. import xml.etree.ElementTree as ET
  392. import zipfile
  393. W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  394. package_re = re.compile(r"(包号|包件)")
  395. document_name = "word/document.xml"
  396. remove_keys = set(remove_keys or [])
  397. fills = list(fills or [])
  398. with zipfile.ZipFile(source, "r") as source_zip:
  399. names = set(source_zip.namelist())
  400. document_out = None
  401. if document_name in names:
  402. root = ET.fromstring(source_zip.read(document_name))
  403. body = None
  404. for element in root.iter():
  405. if element.tag == f"{{{W_NS}}}body":
  406. body = element
  407. break
  408. if body is not None:
  409. seen = set()
  410. seen_filled_labels = set()
  411. for paragraph in list(body):
  412. if paragraph.tag != f"{{{W_NS}}}p":
  413. continue
  414. text_nodes = [
  415. node for node in paragraph.iter()
  416. if node.tag == f"{{{W_NS}}}t"
  417. ]
  418. text = "".join(node.text or "" for node in text_nodes)
  419. if not text.strip():
  420. continue
  421. if _PROTECTED_SIGNATURE_RE.match(text.strip()):
  422. continue
  423. key = _normalize_docx_text(text)
  424. p_pr = paragraph.find(f"{{{W_NS}}}pPr")
  425. style_val = ""
  426. if p_pr is not None:
  427. p_style = p_pr.find(f"{{{W_NS}}}pStyle")
  428. if p_style is not None:
  429. style_val = p_style.get(f"{{{W_NS}}}val", "")
  430. is_title = bool(
  431. _TITLE_PREFIX_RE.match(text.strip())
  432. or re.match(r"(?:Heading|标题)\s*[1-4]", style_val, re.I)
  433. )
  434. if is_title:
  435. seen = set()
  436. seen_filled_labels = set()
  437. continue
  438. if key in remove_keys:
  439. for node in text_nodes:
  440. node.text = ""
  441. continue
  442. label_match = _PROJECT_LABEL_RE.match(text.strip())
  443. if label_match:
  444. raw_label = label_match.group(1).strip()
  445. label_value = label_match.group(2).strip()
  446. label = _PROJECT_LABEL_ALIASES.get(raw_label, raw_label)
  447. if label_value:
  448. seen_filled_labels.add(label)
  449. continue
  450. if label in seen_filled_labels:
  451. for node in text_nodes:
  452. node.text = ""
  453. continue
  454. if raw_label in _PACKAGE_LABELS and not has_packages:
  455. for node in text_nodes:
  456. node.text = ""
  457. continue
  458. continue
  459. if len(key) >= 12 and key in seen:
  460. for node in text_nodes:
  461. node.text = ""
  462. continue
  463. if len(key) >= 12:
  464. seen.add(key)
  465. for fill in fills:
  466. label = str(fill.get("label", "")).strip()
  467. value = str(fill.get("value", "")).strip()
  468. if not label or not value:
  469. continue
  470. head = re.split(r"[::]", text.strip(), maxsplit=1)[0].strip()
  471. if _normalize_docx_text(head) == _normalize_docx_text(label):
  472. for node in text_nodes:
  473. node.text = ""
  474. if text_nodes:
  475. text_nodes[0].text = f"{label}:{value}"
  476. break
  477. _apply_body_format(paragraph, W_NS)
  478. document_out = ET.tostring(
  479. root, encoding="utf-8", xml_declaration=True
  480. )
  481. with zipfile.ZipFile(source, "r") as source_zip:
  482. with zipfile.ZipFile(
  483. target, "w", compression=zipfile.ZIP_STORED
  484. ) as target_zip:
  485. for info in source_zip.infolist():
  486. data = (
  487. document_out
  488. if info.filename == document_name and document_out is not None
  489. else source_zip.read(info.filename)
  490. )
  491. target_zip.writestr(info, data)
  492. def _collect_redundant_actions(report, chapter_id):
  493. remove_keys = set()
  494. fills = []
  495. if report is None:
  496. return remove_keys, fills
  497. for issue in getattr(report, "issues", []) or []:
  498. if issue.issue_type != "redundant_info" or issue.chapter_id != chapter_id:
  499. continue
  500. try:
  501. payload = json.loads(issue.suggestion or "{}")
  502. except Exception:
  503. continue
  504. for item in payload.get("removals", []) or []:
  505. if isinstance(item, dict) and item.get("text"):
  506. raw = str(item["text"]).strip()
  507. if _EMPTY_LABEL_RE.match(raw):
  508. remove_keys.add(_normalize_docx_text(raw))
  509. for item in payload.get("fills", []) or []:
  510. if isinstance(item, dict):
  511. fills.append(item)
  512. return remove_keys, fills
  513. def _write_step5_chapters_from_step4(records, outline, analysis, base_dir, report=None):
  514. """以 step4 章节 DOCX 为底稿,在其上应用 Step5 修复并写出 step5_chapters。"""
  515. chapter_dir = os.path.join(
  516. base_dir, "chapters", _safe_project_name(outline.project_name)
  517. )
  518. os.makedirs(chapter_dir, exist_ok=True)
  519. has_packages = _has_package_info(analysis)
  520. manifest = []
  521. for record in records:
  522. source = record.get("artifact_path", "")
  523. chapter_id = str(record.get("id", ""))
  524. if not source or not os.path.isfile(source):
  525. continue
  526. target = os.path.join(chapter_dir, os.path.basename(source))
  527. remove_keys, fills = _collect_redundant_actions(report, chapter_id)
  528. _safe_copy_and_clean_chapter(
  529. source, target, has_packages, remove_keys, fills
  530. )
  531. manifest.append({
  532. "id": chapter_id,
  533. "title": record.get("title", ""),
  534. "artifact_path": os.path.abspath(target),
  535. "status": record.get("status", "complete"),
  536. })
  537. manifest_path = os.path.join(chapter_dir, "manifest.json")
  538. with open(manifest_path, "w", encoding="utf-8") as file:
  539. json.dump(
  540. {
  541. "project_name": outline.project_name,
  542. "expected_count": len(records),
  543. "completed_count": len(manifest),
  544. "chapters": manifest,
  545. },
  546. file,
  547. ensure_ascii=False,
  548. indent=2,
  549. )
  550. return [record["artifact_path"] for record in manifest]
  551. def main():
  552. analysis, project_data = _load_step2_info(STEP2_INFO_FILE)
  553. report = _load_step3_report(STEP3_REPORT_FILE)
  554. step4_records = _load_step4_records(STEP4_CHAPTERS_DIR)
  555. chapter_texts = _load_chapter_texts(STEP4_CHAPTERS_DIR)
  556. outline = _build_outline_from_report(report, chapter_texts)
  557. print("=" * 60)
  558. print("Step 5a: 加载实际章节内容、Step3 大纲与评分/废标映射")
  559. print("=" * 60)
  560. print(f"章节数: {len(outline.chapters)}")
  561. print(f"评分项: {len(analysis.scoring_criteria)};废标项: {len(analysis.rejection_items)}")
  562. print(f"Step3 评分/废标索引映射: {len(outline.evaluation_index_entries)} 条")
  563. print(f"Step3 标题关系映射: {len(outline.heading_mappings)} 条")
  564. print(f"已读取实际章节文件: {len(chapter_texts)} 个")
  565. for chapter in outline.chapters:
  566. print(
  567. f" {chapter.id} {chapter.title}: 实际正文 "
  568. f"{len(chapter.generated_content or '')} 字符"
  569. )
  570. print()
  571. print("=" * 60)
  572. print("Step 5b: 内容审核")
  573. print("=" * 60)
  574. from step5_reviewing import review_content, auto_fix_issues
  575. review_report, outline = review_content(outline, analysis)
  576. print()
  577. print("=== 审核报告 ===")
  578. print(f"审核结果: {'[PASS] 通过' if review_report.passed else '[FAIL] 未通过'}")
  579. print(f"总字数: {review_report.total_word_count:,}")
  580. print(f"问题总数: {len(review_report.issues)}")
  581. print(
  582. "问题类型分布:",
  583. dict(Counter(i.issue_type for i in review_report.issues)),
  584. )
  585. errors = [i for i in review_report.issues if i.severity == "error"]
  586. warnings = [i for i in review_report.issues if i.severity == "warning"]
  587. print(f" 错误: {len(errors)}")
  588. for issue in errors:
  589. print(f" [ERR] [{issue.chapter_id}] {issue.description[:120]}")
  590. if issue.suggestion:
  591. print(f" 建议: {issue.suggestion[:120]}")
  592. print(f" 警告: {len(warnings)}")
  593. for issue in warnings[:8]:
  594. print(f" [WARN] [{issue.chapter_id}] {issue.description[:120]}")
  595. if not review_report.passed:
  596. print()
  597. print("=" * 60)
  598. print("Step 5c: 自动修复")
  599. print("=" * 60)
  600. outline = auto_fix_issues(
  601. outline,
  602. analysis,
  603. review_report,
  604. project_data=project_data,
  605. )
  606. report2, outline = review_content(outline, analysis)
  607. print(f"\n修复后审核: {'[PASS] 通过' if report2.passed else '[FAIL] 仍有问题'}")
  608. print(f"修复后字数: {report2.total_word_count:,}")
  609. print(f"剩余问题: {len(report2.issues)}")
  610. print()
  611. print("=" * 60)
  612. print("Step 5d: 在 step4 各章 DOCX 基础上写出 step5_chapters")
  613. print("=" * 60)
  614. artifact_paths = _write_step5_chapters_from_step4(
  615. step4_records,
  616. outline,
  617. analysis,
  618. STEP5_CHAPTERS_DIR,
  619. report=review_report,
  620. )
  621. print(f"已写出 {len(artifact_paths)} 个章节 DOCX 到: {STEP5_CHAPTERS_DIR}")
  622. os.makedirs(os.path.dirname(STEP5_OUTLINE_FILE), exist_ok=True)
  623. with open(STEP5_OUTLINE_FILE, "wb") as file:
  624. pickle.dump(outline, file)
  625. print(f"\nStep5 修复后大纲已保存: {STEP5_OUTLINE_FILE}")
  626. print("Step 5 测试完成 [OK]")
  627. if __name__ == "__main__":
  628. main()