| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- """Step 4 人工验证:按 Step3 最终目录逐章生成并输出块式写作报告。
- 本脚本会调用当前配置的 LLM。运行:uv run python scripts/test_step4.py
- """
- import json
- import logging
- import os
- import pickle
- import re
- import sys
- import zipfile
- import xml.etree.ElementTree as ET
- from collections import Counter
- from _bootstrap import PROJECT_ROOT
- REFERENCE_BID = os.environ.get(
- "PROPOSA_REFERENCE_BID",
- "test_data/171-上海群众艺术馆/参考投标文件/物业管理费项目投标文件.docx",
- )
- TEMPLATE_PATH = os.environ.get(
- "PROPOSA_TEMPLATE_PATH", "src/templates/申勤投标模板.docx"
- )
- OUTPUT_DIR = os.environ.get(
- "PROPOSA_STEP4_OUTPUT_DIR",
- os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
- )
- STEP2_INFO_FILE = os.environ.get(
- "PROPOSA_STEP2_INFO_FILE",
- os.path.join(OUTPUT_DIR, "step2_info.pkl"),
- )
- STEP3_OUTLINE_FILE = os.environ.get(
- "PROPOSA_STEP3_OUTLINE_FILE",
- os.path.join(OUTPUT_DIR, "step3_outline.pkl"),
- )
- CHAPTER_OUTPUT_DIR = os.path.join(OUTPUT_DIR, "step4_chapters")
- os.makedirs(OUTPUT_DIR, exist_ok=True)
- os.environ["BID_OUTLINE_CACHE_DIR"] = os.path.join(OUTPUT_DIR, ".outline_cache")
- os.environ["BID_LLM_CACHE_DIR"] = os.path.join(OUTPUT_DIR, ".llm_cache")
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(levelname)s] %(message)s",
- datefmt="%H:%M:%S",
- stream=sys.stderr,
- )
- def _load_reference_bid(path, project_data, output_dir):
- """参考投书载入入口,保留给既有回归测试兼容。
- 当前手工流程已在 test_step2.py 完成参考投书正文与表格载入并写入
- step2_info.pkl;test_step4.py 主流程不再调用本函数。
- """
- if not path or not os.path.isfile(path):
- return 0
- from doc_reader.reader import read_docx_paragraph_texts_et
- from models import ParsedDocument
- from step1_parsing.table_extractor import extract_reference_tables_with_llm
- reference_content = "\n".join(read_docx_paragraph_texts_et(path))
- if reference_content:
- project_data.reference_bids.append(ParsedDocument(
- file_path=path,
- file_name=os.path.basename(path),
- content=reference_content,
- doc_type="reference_bid",
- ))
- reference_table_dir = os.path.join(output_dir, "内容提取_参考投书")
- project_data.reference_tables = extract_reference_tables_with_llm(
- path, output_dir=reference_table_dir
- )
- return len(project_data.reference_tables)
- def _walk(chapters):
- for chapter in chapters:
- yield chapter
- yield from _walk(chapter.children)
- def _inspect_chapter_artifact(path):
- result = {
- "native_table_count": 0,
- "native_image_count": 0,
- "unresolved_placeholders": [],
- }
- if not path or not os.path.isfile(path):
- return result
- word_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
- with zipfile.ZipFile(path, "r") as package:
- root = ET.fromstring(package.read("word/document.xml"))
- text = "".join(
- node.text or "" for node in root.iter(f"{{{word_ns}}}t")
- )
- result["native_table_count"] = sum(
- 1 for _ in root.iter(f"{{{word_ns}}}tbl")
- )
- result["native_image_count"] = sum(
- 1 for _ in root.iter(f"{{{word_ns}}}drawing")
- )
- result["unresolved_placeholders"] = sorted(set(
- re.findall(r"%%[^%\n]{1,80}%%", text)
- ))
- return result
- def _chapter_record(chapter, entry_counts):
- blocks = list(chapter.content_blocks or [])
- supplements = [
- node for node in _walk([chapter]) if node.supplement_content.strip()
- ]
- record = {
- "id": chapter.id,
- "title": chapter.title,
- "chapter_type": chapter.chapter_type.value,
- "template_original_id": chapter.template_original_id,
- "template_original_title": chapter.template_original_title,
- "preserve_template_layout": bool(chapter.preserve_template_layout),
- "template_fill_completed": chapter.template_fill_completed,
- "generated_chars": len(chapter.generated_content or ""),
- "scoring_binding_count": sum(
- len(node.direct_scoring_bindings or node.direct_scoring_criteria)
- for node in _walk([chapter])
- ),
- "rejection_binding_count": sum(
- len(node.direct_rejection_bindings) for node in _walk([chapter])
- ),
- "index_entry_count": entry_counts.get(chapter.id, 0),
- "supplement_node_count": len(supplements),
- "supplement_chars": sum(len(node.supplement_content) for node in supplements),
- "content_blocks": blocks,
- "artifact_path": chapter.artifact_path,
- "index_deferred_until_pagination": any(
- block.get("block_type") == "deferred_evaluation_index"
- for block in blocks
- ),
- "nodes": [
- {
- "id": node.id,
- "title": node.title,
- "template_original_id": node.template_original_id,
- "content_generation_mode": node.content_generation_mode,
- "scoring_bindings": list(
- node.direct_scoring_bindings or node.direct_scoring_criteria
- ),
- "rejection_bindings": list(node.direct_rejection_bindings),
- "supplement_chars": len(node.supplement_content or ""),
- "supplement_preview": (node.supplement_content or "")[:240],
- }
- for node in _walk([chapter])
- if (
- node.direct_scoring_bindings
- or node.direct_scoring_criteria
- or node.direct_rejection_bindings
- )
- ],
- }
- record.update(_inspect_chapter_artifact(chapter.artifact_path))
- return record
- def _build_report(outline, analysis=None):
- nodes = {node.id: node for node in _walk(outline.chapters)}
- errors = []
- entry_counts = Counter()
- for entry in outline.evaluation_index_entries:
- heading_id = str(entry.get("final_heading_id", ""))
- entry_counts[heading_id.split(".", 1)[0]] += 1
- node = nodes.get(heading_id)
- if node is None:
- errors.append(f"索引落点不存在: {entry.get('entry_id')} -> {heading_id}")
- elif not node.supplement_content.strip():
- errors.append(
- f"直接落点缺少补充正文: {entry.get('entry_id')} -> "
- f"{heading_id} {node.title}"
- )
- heading_pattern = re.compile(
- r"(?m)^\s*(?:#{1,6}\s+|[一二三四五六七八九十]+、|"
- r"([一二三四五六七八九十]+)|\d+[.、])"
- )
- for node in nodes.values():
- supplement = node.supplement_content or ""
- if supplement and heading_pattern.search(supplement):
- errors.append(f"补充块含疑似新标题: {node.id} {node.title}")
- if supplement and re.search(r"(?m)^\s*\|.*\|\s*$", supplement):
- errors.append(f"补充块含 Markdown 表格: {node.id} {node.title}")
- for chapter in outline.chapters:
- if not chapter.preserve_template_layout:
- continue
- kinds = [str(block.get("block_type", "")) for block in chapter.content_blocks]
- if not chapter.template_fill_completed:
- errors.append(f"模板章未完成项目信息填充: {chapter.id} {chapter.title}")
- allowed_prefixes = (
- ["template_base", "native_table_plan"],
- ["template_base", "deferred_evaluation_index"],
- )
- if kinds[:2] not in allowed_prefixes:
- errors.append(f"模板章块顺序错误: {chapter.id} {chapter.title}: {kinds}")
- records = [_chapter_record(chapter, entry_counts) for chapter in outline.chapters]
- for record in records:
- if record["unresolved_placeholders"]:
- errors.append(
- f"章节产物仍有未解析占位符: {record['id']} {record['title']}: "
- + ", ".join(record["unresolved_placeholders"][:10])
- )
- project_fields = dict(
- getattr(analysis, "project_fields", {}) or {}
- ) if analysis is not None else {}
- if analysis is not None and getattr(analysis, "agency_name", ""):
- project_fields["采购机构名称"] = analysis.agency_name
- return {
- "passed": not errors,
- "errors": errors,
- "summary": {
- "chapter_count": len(outline.chapters),
- "heading_count": len(nodes),
- "evaluation_index_entry_count": len(outline.evaluation_index_entries),
- "template_preserved_chapter_count": sum(
- 1 for chapter in outline.chapters if chapter.preserve_template_layout
- ),
- "supplement_node_count": sum(
- 1 for node in nodes.values() if node.supplement_content.strip()
- ),
- },
- "project_fields": project_fields,
- "chapters": records,
- }
- def _write_report(report):
- json_path = os.path.join(OUTPUT_DIR, "step4_generation_report.json")
- md_path = os.path.join(OUTPUT_DIR, "step4_generation_report.md")
- with open(json_path, "w", encoding="utf-8") as file:
- json.dump(report, file, ensure_ascii=False, indent=2)
- summary = report["summary"]
- lines = [
- "# Step4 逐章生成检查报告", "",
- f"- 门禁结果:{'通过' if report['passed'] else '失败'}",
- f"- 一级章:{summary['chapter_count']};全部标题:{summary['heading_count']}",
- f"- 评分/废标索引条目:{summary['evaluation_index_entry_count']}",
- f"- 模板保留章:{summary['template_preserved_chapter_count']};"
- f"实际补充节点:{summary['supplement_node_count']}", "",
- ]
- if report["errors"]:
- lines.extend(["## 门禁错误", ""])
- lines.extend(f"- {error}" for error in report["errors"])
- lines.append("")
- if report.get("project_fields"):
- lines.extend(["## Step2 项目填表字段", ""])
- lines.extend(
- f"- {key}:{value or '未提取'}"
- for key, value in report["project_fields"].items()
- )
- lines.append("")
- lines.extend(["## 各章生成情况", ""])
- for chapter in report["chapters"]:
- block_order = ", ".join(
- str(block.get("block_type", "")) for block in chapter["content_blocks"]
- ) or "无"
- lines.extend([
- f"### {chapter['id']} {chapter['title']}", "",
- f"- 类型:{chapter['chapter_type']};模板保留:{chapter['preserve_template_layout']};"
- f"项目填充完成:{chapter['template_fill_completed']}",
- f"- 模板原节点:{chapter['template_original_id']} {chapter['template_original_title']}",
- f"- 基础正文:{chapter['generated_chars']} 字;补充节点:"
- f"{chapter['supplement_node_count']};补充正文:{chapter['supplement_chars']} 字",
- f"- 评分绑定:{chapter['scoring_binding_count']};废标绑定:"
- f"{chapter['rejection_binding_count']};索引条目:{chapter['index_entry_count']}",
- f"- 内容块顺序:{block_order}",
- f"- 原生表格:{chapter['native_table_count']};原生图片:"
- f"{chapter['native_image_count']};未解析占位符:"
- f"{', '.join(chapter['unresolved_placeholders']) or '无'}",
- f"- 最终分页后构建索引:{chapter['index_deferred_until_pagination']}",
- f"- 章节产物:{chapter['artifact_path'] or '无'}", "",
- ])
- for node in chapter["nodes"]:
- preview = node["supplement_preview"].replace("\n", " ") or "无"
- lines.extend([
- f"- `{node['id']}` {node['title']}(模板原节点 "
- f"`{node['template_original_id'] or '-'}`;模式 "
- f"`{node['content_generation_mode'] or '-'}`;评分 "
- f"{', '.join(node['scoring_bindings']) or '-'};废标 "
- f"{', '.join(node['rejection_bindings']) or '-'};补充 "
- f"{node['supplement_chars']} 字)",
- f" - 预览:{preview}",
- ])
- if chapter["nodes"]:
- lines.append("")
- with open(md_path, "w", encoding="utf-8") as file:
- file.write("\n".join(lines).rstrip() + "\n")
- return md_path, json_path
- def main():
- from step4_writing import write_content
- print("=" * 60)
- print("Step 2: 复用已落盘的分析与项目资料(Step 4 的前置)")
- print("=" * 60)
- if not os.path.isfile(STEP2_INFO_FILE):
- raise FileNotFoundError(
- f"Step2 分析结果不存在: {STEP2_INFO_FILE}\n"
- "请先运行 uv run python scripts/test_step2.py。"
- )
- with open(STEP2_INFO_FILE, "rb") as file:
- step2_payload = pickle.load(file)
- analysis = step2_payload["analysis"]
- project_data = step2_payload["project_data"]
- print(f"通用资料: {len(project_data.general_materials)} 个文件")
- print(f"参考投标书: {len(project_data.reference_bids)} 篇")
- print(f"参考投书表格: {len(project_data.reference_tables)} 张")
- print(
- f"复用 Step2 分析: {len(analysis.scoring_criteria)} 评分项, "
- f"{len(analysis.rejection_items)} 废标项"
- )
- print("\n" + "=" * 60)
- print("Step 3: 复用已落盘的最终目录(Step 4 的唯一结构来源)")
- print("=" * 60)
- if not os.path.isfile(STEP3_OUTLINE_FILE):
- raise FileNotFoundError(
- f"Step3 大纲结果不存在: {STEP3_OUTLINE_FILE}\n"
- "请先运行 uv run python scripts/test_step3.py。"
- )
- with open(STEP3_OUTLINE_FILE, "rb") as file:
- outline = pickle.load(file)
- print(
- f"最终目录: {len(outline.chapters)} 章, "
- f"{len(outline.evaluation_index_entries)} 条索引映射"
- )
- print("\n" + "=" * 60)
- print("Step 4: 按模板基块 → 项目/表格填充 → 评分/废标补充块生成")
- print("=" * 60)
- outline = write_content(
- outline, analysis, project_data,
- template_path=TEMPLATE_PATH,
- reference_bid_path=REFERENCE_BID,
- chapter_output_dir=CHAPTER_OUTPUT_DIR,
- )
- report = _build_report(outline, analysis)
- md_path, json_path = _write_report(report)
- print(f"逐章可读报告: {md_path}")
- print(f"机器可读报告: {json_path}")
- for chapter in report["chapters"]:
- print(
- f" {chapter['id']} {chapter['title']}: 基础 {chapter['generated_chars']:,} 字, "
- f"补充 {chapter['supplement_chars']:,} 字/{chapter['supplement_node_count']} 节点, "
- f"评分 {chapter['scoring_binding_count']}, 废标 {chapter['rejection_binding_count']}"
- )
- if report["errors"]:
- raise AssertionError(
- "Step4 块式生成门禁失败;详见报告:\n- "
- + "\n- ".join(report["errors"][:30])
- )
- print("\nStep 4 测试完成 [OK]")
- if __name__ == "__main__":
- main()
|