outline_report.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. """Step3 模板目录与评分/废标增强目录的可读报告及结构门禁。"""
  2. from __future__ import annotations
  3. import json
  4. from pathlib import Path
  5. from typing import Iterable
  6. from models import BidOutline, Chapter, ChapterType, TenderAnalysis
  7. from step3_outlining.outline_generator import _OutlineGenerator
  8. from step3_outlining.scoring_structure import (
  9. get_outline_scoring_dimensions,
  10. normalize_heading_text,
  11. validate_template_chapter_alignment,
  12. )
  13. from step3_outlining.template_parser import (
  14. get_template_chapter_titles,
  15. parse_template,
  16. )
  17. def build_template_outline(
  18. template_path: str,
  19. reference_bid_path: str = "",
  20. ) -> BidOutline:
  21. """只读取正式模板 Heading 1-4,构建未加入评分/废标项的原始目录。"""
  22. titles = get_template_chapter_titles(template_path, reference_bid_path)
  23. structure = parse_template(
  24. template_path=template_path,
  25. reference_bid_path=reference_bid_path,
  26. )
  27. chapters = [
  28. Chapter(
  29. id=str(chapter_id),
  30. title=title,
  31. chapter_type=(
  32. ChapterType.BUSINESS
  33. if str(chapter_id).isdigit() and int(chapter_id) <= 2
  34. else ChapterType.TECHNICAL
  35. ),
  36. level=1,
  37. template_chapter_id=str(chapter_id),
  38. template_section=next(
  39. (
  40. section.name
  41. for section in structure.sections
  42. if section.name == f"第{chapter_id}章:{title}"
  43. ),
  44. "",
  45. ),
  46. )
  47. for chapter_id, title in titles
  48. ]
  49. chapters = _OutlineGenerator()._extract_sub_sections(chapters, structure)
  50. return BidOutline(project_name="模板原始目录", chapters=chapters)
  51. def _walk_with_paths(chapters: Iterable[Chapter]):
  52. def visit(nodes, parents):
  53. for node in nodes:
  54. path = parents + [node]
  55. yield node, path
  56. yield from visit(node.children or [], path)
  57. yield from visit(chapters, [])
  58. def _path_text(path: list[Chapter]) -> str:
  59. return " → ".join(node.title for node in path)
  60. def _origin_label(node: Chapter) -> str:
  61. if node.level == 1 and getattr(node, "is_scoring_inserted", False):
  62. return "新增一级评分章"
  63. if getattr(node, "from_template", False) or getattr(
  64. node, "template_chapter_id", ""
  65. ):
  66. return "复用模板标题"
  67. if node.level > 1 and getattr(node, "structure_locked", False):
  68. return "既有父章节下新增标题"
  69. return "大纲生成标题"
  70. def _structural_action(node: Chapter | None, path: list[Chapter]) -> tuple[str, str]:
  71. """用户可读地说明评分维度是复用标题还是新建标题。"""
  72. if node is None:
  73. return "未映射", "尚未找到合法落点,Step3 门禁应阻止进入 Step4。"
  74. origin = _origin_label(node)
  75. if origin == "复用模板标题":
  76. original = getattr(node, "template_original_title", "") or node.title
  77. return (
  78. "复用已有标题,改为评分小类名并增加响应内容",
  79. f"复用模板 H{node.level}「{original}」,最终标题名改为"
  80. f"「{node.title}」;保留该模板区域内容,并补充评分要求、"
  81. "针对性响应和项目信息。",
  82. )
  83. if origin == "新增一级评分章":
  84. return (
  85. "新增一级评分章",
  86. "评分大类无法匹配任一模板一级章;"
  87. "按评分表顺序在项目经理章之前新增。",
  88. )
  89. parent = path[-2].title if len(path) >= 2 else "已匹配章节"
  90. return (
  91. f"在已有标题下新增 H{node.level}",
  92. f"模板中未复用到能识别该具体评分维度的标题;"
  93. f"因此在「{parent}」下新增「{node.title}」,"
  94. "评分要求和针对性响应写入新标题正文。",
  95. )
  96. def validate_outline_gate(
  97. template_outline: BidOutline,
  98. enhanced_outline: BidOutline,
  99. analysis: TenderAnalysis,
  100. ) -> list[str]:
  101. """执行进入 Step4 前的硬门禁;返回全部错误,调用方决定是否终止。"""
  102. template_titles = [
  103. (chapter.id, chapter.title)
  104. for chapter in template_outline.chapters
  105. if chapter.level == 1 and chapter.id.isdigit()
  106. ]
  107. errors = (
  108. validate_template_chapter_alignment(
  109. enhanced_outline.chapters, template_titles
  110. )
  111. if template_titles
  112. else []
  113. )
  114. errors.extend(
  115. f"标题层级与父子树不一致: {node.id} H{node.level},应为 H{len(path)}"
  116. for node, path in _walk_with_paths(enhanced_outline.chapters)
  117. if node.level != len(path)
  118. )
  119. top_titles: dict[str, list[str]] = {}
  120. for chapter in enhanced_outline.chapters:
  121. if chapter.level != 1 or not chapter.id.isdigit():
  122. continue
  123. key = normalize_heading_text(chapter.title)
  124. top_titles.setdefault(key, []).append(chapter.id)
  125. if not getattr(chapter, "template_chapter_id", "") and not getattr(
  126. chapter, "is_scoring_inserted", False
  127. ):
  128. errors.append(
  129. f"一级章 {chapter.id}:{chapter.title} 既非模板章,也非授权评分新增章"
  130. )
  131. if set(chapter.related_criteria or []) != set(
  132. chapter.direct_scoring_criteria or []
  133. ):
  134. errors.append(
  135. f"一级章 {chapter.id}:{chapter.title} 禁止保存子树评分汇总"
  136. )
  137. if chapter.related_rejections and not getattr(
  138. chapter, "direct_rejection_bindings", []
  139. ):
  140. errors.append(
  141. f"一级章 {chapter.id}:{chapter.title} 禁止保存废标项汇总"
  142. )
  143. for key, ids in top_titles.items():
  144. if key and len(ids) > 1:
  145. errors.append(f"一级章标题重复: {key}(章号 {','.join(ids)})")
  146. nodes_with_paths = list(_walk_with_paths(enhanced_outline.chapters))
  147. for criterion in analysis.scoring_criteria:
  148. scoped_chapter = next(
  149. (
  150. chapter for chapter in enhanced_outline.chapters
  151. if chapter.level == 1
  152. and normalize_heading_text(
  153. getattr(chapter, "template_original_title", "") or chapter.title
  154. ) == normalize_heading_text(criterion.name)
  155. ),
  156. None,
  157. )
  158. if scoped_chapter is None and "项目经理" in (criterion.name or ""):
  159. scoped_chapter = next(
  160. (
  161. chapter for chapter in enhanced_outline.chapters
  162. if chapter.level == 1 and "项目经理" in (chapter.title or "")
  163. ),
  164. None,
  165. )
  166. dimensions = get_outline_scoring_dimensions(enhanced_outline, criterion)
  167. for dimension in dimensions:
  168. direct = [
  169. (node, path)
  170. for node, path in nodes_with_paths
  171. if dimension.binding_id
  172. in (getattr(node, "direct_scoring_bindings", []) or [])
  173. ]
  174. # 兼容旧的单维度大纲/测试对象;复合项必须有维度绑定。
  175. if not direct and len(dimensions) == 1:
  176. direct = [
  177. (node, path)
  178. for node, path in nodes_with_paths
  179. if criterion.id in (node.direct_scoring_criteria or [])
  180. ]
  181. if len(direct) != 1:
  182. errors.append(
  183. f"评分维度 {dimension.binding_id} {dimension.title} "
  184. f"必须有且只有一个直接落点,实际 {len(direct)} 个"
  185. )
  186. continue
  187. node, path = direct[0]
  188. if scoped_chapter is not None and (not path or path[0] is not scoped_chapter):
  189. errors.append(
  190. f"评分维度 {dimension.binding_id} 必须位于"
  191. f"「{scoped_chapter.title}」章,实际: {_path_text(path)}"
  192. )
  193. if node.level > 4:
  194. errors.append(
  195. f"评分维度 {dimension.binding_id} 标题超过四级: "
  196. f"{_path_text(path)}"
  197. )
  198. # 新增评分小项不得与模板其他标题同名。
  199. if not getattr(node, "from_template", False) and node.level > 1:
  200. normalized = normalize_heading_text(node.title)
  201. duplicates = [
  202. template_node
  203. for template_node, _ in _walk_with_paths(template_outline.chapters)
  204. if normalize_heading_text(template_node.title) == normalized
  205. ]
  206. if normalized and duplicates:
  207. errors.append(
  208. f"评分维度 {dimension.binding_id} 新增标题与模板已有标题同名: "
  209. f"{_path_text(path)}"
  210. )
  211. chapter_one = next(
  212. (
  213. chapter
  214. for chapter in enhanced_outline.chapters
  215. if chapter.level == 1
  216. and str(getattr(chapter, "template_chapter_id", "") or chapter.id) == "1"
  217. ),
  218. None,
  219. )
  220. for item in analysis.rejection_items:
  221. targets = [
  222. (node, path)
  223. for node, path in nodes_with_paths
  224. if item.id in (node.related_rejections or [])
  225. and "要求承诺函" in (node.title or "")
  226. ]
  227. if len(targets) != 1:
  228. errors.append(
  229. f"废标项 {item.id} 必须唯一落到第一章要求承诺函,实际 {len(targets)} 个"
  230. )
  231. continue
  232. _, path = targets[0]
  233. if not chapter_one or path[0] is not chapter_one:
  234. errors.append(
  235. f"废标项 {item.id} 落到第一章之外: {_path_text(path)}"
  236. )
  237. persisted_mappings = list(
  238. getattr(enhanced_outline, "heading_mappings", []) or []
  239. )
  240. if persisted_mappings:
  241. for criterion in analysis.scoring_criteria:
  242. for dimension in get_outline_scoring_dimensions(
  243. enhanced_outline, criterion
  244. ):
  245. mapped = [
  246. row for row in persisted_mappings
  247. if dimension.binding_id
  248. in (row.get("scoring_bindings", []) or [])
  249. ]
  250. if len(mapped) != 1:
  251. errors.append(
  252. f"评分维度 {dimension.binding_id} 的新旧标题关系必须唯一保存,"
  253. f"实际 {len(mapped)} 个"
  254. )
  255. for item in analysis.rejection_items:
  256. mapped = [
  257. row for row in persisted_mappings
  258. if item.id in (row.get("rejection_bindings", []) or [])
  259. ]
  260. if len(mapped) != 1:
  261. errors.append(
  262. f"废标项 {item.id} 的新旧标题关系必须唯一保存,实际 {len(mapped)} 个"
  263. )
  264. return errors
  265. def _render_tree(outline: BidOutline, *, annotated: bool) -> list[str]:
  266. lines = []
  267. for node, _path in _walk_with_paths(outline.chapters):
  268. if node.id == "index":
  269. continue
  270. indent = " " * max(node.level - 1, 0)
  271. annotations = []
  272. if annotated:
  273. annotations.append(_origin_label(node))
  274. if node.direct_scoring_criteria:
  275. annotations.append(
  276. "评分直接落点=" + ",".join(node.direct_scoring_criteria)
  277. )
  278. if getattr(node, "direct_scoring_bindings", None):
  279. annotations.append(
  280. "评分维度=" + ",".join(node.direct_scoring_bindings)
  281. )
  282. if getattr(node, "direct_rejection_bindings", None):
  283. annotations.append(
  284. "废标直接落点=" + ",".join(node.direct_rejection_bindings)
  285. )
  286. suffix = f" `{';'.join(annotations)}`" if annotations else ""
  287. lines.append(
  288. f"{indent}- H{node.level} `{node.id}` {node.title}{suffix}"
  289. )
  290. return lines
  291. def build_outline_report(
  292. template_outline: BidOutline,
  293. enhanced_outline: BidOutline,
  294. analysis: TenderAnalysis,
  295. ) -> tuple[str, dict]:
  296. errors = validate_outline_gate(template_outline, enhanced_outline, analysis)
  297. nodes_with_paths = list(_walk_with_paths(enhanced_outline.chapters))
  298. scoring_rows = []
  299. for criterion in analysis.scoring_criteria:
  300. dimensions = get_outline_scoring_dimensions(enhanced_outline, criterion)
  301. for dimension in dimensions:
  302. target = next(
  303. (
  304. (node, path)
  305. for node, path in nodes_with_paths
  306. if dimension.binding_id
  307. in (getattr(node, "direct_scoring_bindings", []) or [])
  308. ),
  309. (None, []),
  310. )
  311. if target[0] is None and len(dimensions) == 1:
  312. target = next(
  313. (
  314. (node, path)
  315. for node, path in nodes_with_paths
  316. if criterion.id in (node.direct_scoring_criteria or [])
  317. ),
  318. (None, []),
  319. )
  320. node, path = target
  321. action, explanation = _structural_action(node, path)
  322. scoring_rows.append(
  323. {
  324. "id": criterion.id,
  325. "binding_id": dimension.binding_id,
  326. "category": criterion.category,
  327. "name": criterion.name,
  328. "dimension": dimension.title,
  329. "score": criterion.max_score,
  330. "requirement": dimension.requirement,
  331. "full_requirement": criterion.description,
  332. "source": criterion.section_ref or "未提取到原文位置",
  333. "target": _path_text(path) if path else "未映射",
  334. "origin": _origin_label(node) if node else "未映射",
  335. "action": action,
  336. "explanation": explanation,
  337. "template_original_title": (
  338. getattr(node, "template_original_title", "") if node else ""
  339. ),
  340. "final_title": node.title if node else "",
  341. "content_generation_mode": (
  342. getattr(node, "content_generation_mode", "") if node else ""
  343. ),
  344. }
  345. )
  346. rejection_rows = []
  347. for item in analysis.rejection_items:
  348. target = next(
  349. (
  350. (node, path)
  351. for node, path in nodes_with_paths
  352. if item.id in (node.related_rejections or [])
  353. and "要求承诺函" in (node.title or "")
  354. ),
  355. (None, []),
  356. )
  357. node, path = target
  358. rejection_rows.append(
  359. {
  360. "id": item.id,
  361. "category": item.category,
  362. "requirement": item.description,
  363. "source": "废标项原文位置尚未由 Step2 保存",
  364. "target": _path_text(path) if path else "未映射",
  365. "origin": _origin_label(node) if node else "未映射",
  366. }
  367. )
  368. new_nodes = [
  369. {
  370. "id": node.id,
  371. "title": node.title,
  372. "level": node.level,
  373. "origin": _origin_label(node),
  374. "path": _path_text(path),
  375. }
  376. for node, path in nodes_with_paths
  377. if _origin_label(node) in {
  378. "新增一级评分章",
  379. "既有父章节下新增标题",
  380. }
  381. ]
  382. lines = [
  383. "# Step 3 大纲对照与映射报告",
  384. "",
  385. f"- 项目:{enhanced_outline.project_name}",
  386. f"- 门禁结果:{'通过' if not errors else '失败'}",
  387. f"- 评分项:{len(analysis.scoring_criteria)} 项,具体评分维度 {len(scoring_rows)} 个",
  388. f"- 废标项:{len(rejection_rows)} 项",
  389. f"- 新增一级评分章:{sum(1 for n in new_nodes if n['level'] == 1)} 个",
  390. f"- 新增章内标题:{sum(1 for n in new_nodes if n['level'] > 1)} 个",
  391. "",
  392. "## ID 查询说明",
  393. "",
  394. "- `SC-xx` 是 Step2 生成的评分项内部 ID;在第 3 节查名称、维度、原文要求、来源和落点。",
  395. "- `RI-xx` 是 Step2 生成的废标项内部 ID;在第 4 节查完整废标条件和落点。",
  396. "- `SC-xx#n` 是 Step3 拆出的具体计分维度,用于防止一个总名称假装覆盖多条要求。",
  397. "",
  398. "## 1. 模板原始 Heading 目录",
  399. "",
  400. *_render_tree(template_outline, annotated=False),
  401. "",
  402. "## 2. 加入评分项和废标项后的最终目录",
  403. "",
  404. *_render_tree(enhanced_outline, annotated=True),
  405. "",
  406. "## 3. 评分项具体落点",
  407. "",
  408. "| ID/维度 | 评分大类 | 评分小类 | 分值 | 最终章节路径 | 结构动作 |",
  409. "| --- | --- | --- | ---: | --- | --- |",
  410. ]
  411. for row in scoring_rows:
  412. lines.append(
  413. f"| {row['binding_id']} | {row['category']} | "
  414. f"{row['dimension']} | {row['score']} | {row['target']} | {row['action']} |"
  415. )
  416. lines.extend(["", "### 评分要求明细", ""])
  417. for row in scoring_rows:
  418. lines.extend(
  419. [
  420. f"#### {row['binding_id']} {row['dimension']}",
  421. "",
  422. f"- 落点:{row['target']}",
  423. f"- 结构动作:{row['action']}",
  424. f"- 解释:{row['explanation']}",
  425. f"- 模板原标题:{row['template_original_title'] or '无(新增标题)'}",
  426. f"- 最终标题:{row['final_title']}",
  427. f"- Step4 写作模式:{row['content_generation_mode']}",
  428. f"- 来源:{row['source']}",
  429. f"- 评分要求:{row['requirement']}",
  430. "",
  431. ]
  432. )
  433. lines.extend(
  434. [
  435. "## 4. 废标项具体落点",
  436. "",
  437. "| ID | 类别 | 最终章节路径 | 处理方式 |",
  438. "| --- | --- | --- | --- |",
  439. ]
  440. )
  441. for row in rejection_rows:
  442. lines.append(
  443. f"| {row['id']} | {row['category']} | {row['target']} | {row['origin']} |"
  444. )
  445. lines.extend(["", "### 废标要求明细", ""])
  446. for row in rejection_rows:
  447. lines.extend(
  448. [
  449. f"#### {row['id']} {row['category']}",
  450. "",
  451. f"- 落点:{row['target']}",
  452. f"- 来源:{row['source']}",
  453. f"- 要求:{row['requirement']}",
  454. "",
  455. ]
  456. )
  457. lines.extend(["## 5. 新增标题汇总", ""])
  458. if new_nodes:
  459. for node in new_nodes:
  460. lines.append(
  461. f"- {node['origin']}:H{node['level']} {node['path']}"
  462. )
  463. else:
  464. lines.append("- 无新增标题。")
  465. lines.extend(["", "## 6. Step 3 门禁", ""])
  466. if errors:
  467. lines.extend(f"- ❌ {error}" for error in errors)
  468. else:
  469. lines.extend(
  470. [
  471. "- ✅ 模板一级章完整且唯一。",
  472. "- ✅ 每个具体评分维度有且只有一个直接落点。",
  473. "- ✅ 只有无法匹配模板的评分大类可新增一级章。",
  474. "- ✅ 每条废标项唯一落到第一章要求承诺函。",
  475. ]
  476. )
  477. payload = {
  478. "project": enhanced_outline.project_name,
  479. "passed": not errors,
  480. "errors": errors,
  481. "scoring": scoring_rows,
  482. "rejections": rejection_rows,
  483. "new_headings": new_nodes,
  484. "heading_mappings": list(
  485. getattr(enhanced_outline, "heading_mappings", []) or []
  486. ),
  487. "evaluation_index_entries": list(
  488. getattr(enhanced_outline, "evaluation_index_entries", []) or []
  489. ),
  490. }
  491. return "\n".join(lines).rstrip() + "\n", payload
  492. def write_outline_report(
  493. markdown_path: str | Path,
  494. template_outline: BidOutline,
  495. enhanced_outline: BidOutline,
  496. analysis: TenderAnalysis,
  497. ) -> tuple[Path, Path, list[str]]:
  498. markdown_path = Path(markdown_path)
  499. json_path = markdown_path.with_suffix(".json")
  500. markdown, payload = build_outline_report(
  501. template_outline, enhanced_outline, analysis
  502. )
  503. markdown_path.parent.mkdir(parents=True, exist_ok=True)
  504. markdown_path.write_text(markdown, encoding="utf-8")
  505. json_path.write_text(
  506. json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
  507. )
  508. return markdown_path, json_path, list(payload["errors"])
  509. __all__ = [
  510. "build_outline_report",
  511. "build_template_outline",
  512. "validate_outline_gate",
  513. "write_outline_report",
  514. ]