| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- """Step3 模板目录与评分/废标增强目录的可读报告及结构门禁。"""
- from __future__ import annotations
- import json
- from pathlib import Path
- from typing import Iterable
- from models import BidOutline, Chapter, ChapterType, TenderAnalysis
- from step3_outlining.outline_generator import _OutlineGenerator
- from step3_outlining.scoring_structure import (
- get_outline_scoring_dimensions,
- normalize_heading_text,
- validate_template_chapter_alignment,
- )
- from step3_outlining.template_parser import (
- get_template_chapter_titles,
- parse_template,
- )
- def build_template_outline(
- template_path: str,
- reference_bid_path: str = "",
- ) -> BidOutline:
- """只读取正式模板 Heading 1-4,构建未加入评分/废标项的原始目录。"""
- titles = get_template_chapter_titles(template_path, reference_bid_path)
- structure = parse_template(
- template_path=template_path,
- reference_bid_path=reference_bid_path,
- )
- chapters = [
- Chapter(
- id=str(chapter_id),
- title=title,
- chapter_type=(
- ChapterType.BUSINESS
- if str(chapter_id).isdigit() and int(chapter_id) <= 2
- else ChapterType.TECHNICAL
- ),
- level=1,
- template_chapter_id=str(chapter_id),
- template_section=next(
- (
- section.name
- for section in structure.sections
- if section.name == f"第{chapter_id}章:{title}"
- ),
- "",
- ),
- )
- for chapter_id, title in titles
- ]
- chapters = _OutlineGenerator()._extract_sub_sections(chapters, structure)
- return BidOutline(project_name="模板原始目录", chapters=chapters)
- def _walk_with_paths(chapters: Iterable[Chapter]):
- def visit(nodes, parents):
- for node in nodes:
- path = parents + [node]
- yield node, path
- yield from visit(node.children or [], path)
- yield from visit(chapters, [])
- def _path_text(path: list[Chapter]) -> str:
- return " → ".join(node.title for node in path)
- def _origin_label(node: Chapter) -> str:
- if node.level == 1 and getattr(node, "is_scoring_inserted", False):
- return "新增一级评分章"
- if getattr(node, "from_template", False) or getattr(
- node, "template_chapter_id", ""
- ):
- return "复用模板标题"
- if node.level > 1 and getattr(node, "structure_locked", False):
- return "既有父章节下新增标题"
- return "大纲生成标题"
- def _structural_action(node: Chapter | None, path: list[Chapter]) -> tuple[str, str]:
- """用户可读地说明评分维度是复用标题还是新建标题。"""
- if node is None:
- return "未映射", "尚未找到合法落点,Step3 门禁应阻止进入 Step4。"
- origin = _origin_label(node)
- if origin == "复用模板标题":
- original = getattr(node, "template_original_title", "") or node.title
- return (
- "复用已有标题,改为评分小类名并增加响应内容",
- f"复用模板 H{node.level}「{original}」,最终标题名改为"
- f"「{node.title}」;保留该模板区域内容,并补充评分要求、"
- "针对性响应和项目信息。",
- )
- if origin == "新增一级评分章":
- return (
- "新增一级评分章",
- "评分大类无法匹配任一模板一级章;"
- "按评分表顺序在项目经理章之前新增。",
- )
- parent = path[-2].title if len(path) >= 2 else "已匹配章节"
- return (
- f"在已有标题下新增 H{node.level}",
- f"模板中未复用到能识别该具体评分维度的标题;"
- f"因此在「{parent}」下新增「{node.title}」,"
- "评分要求和针对性响应写入新标题正文。",
- )
- def validate_outline_gate(
- template_outline: BidOutline,
- enhanced_outline: BidOutline,
- analysis: TenderAnalysis,
- ) -> list[str]:
- """执行进入 Step4 前的硬门禁;返回全部错误,调用方决定是否终止。"""
- template_titles = [
- (chapter.id, chapter.title)
- for chapter in template_outline.chapters
- if chapter.level == 1 and chapter.id.isdigit()
- ]
- errors = (
- validate_template_chapter_alignment(
- enhanced_outline.chapters, template_titles
- )
- if template_titles
- else []
- )
- errors.extend(
- f"标题层级与父子树不一致: {node.id} H{node.level},应为 H{len(path)}"
- for node, path in _walk_with_paths(enhanced_outline.chapters)
- if node.level != len(path)
- )
- top_titles: dict[str, list[str]] = {}
- for chapter in enhanced_outline.chapters:
- if chapter.level != 1 or not chapter.id.isdigit():
- continue
- key = normalize_heading_text(chapter.title)
- top_titles.setdefault(key, []).append(chapter.id)
- if not getattr(chapter, "template_chapter_id", "") and not getattr(
- chapter, "is_scoring_inserted", False
- ):
- errors.append(
- f"一级章 {chapter.id}:{chapter.title} 既非模板章,也非授权评分新增章"
- )
- if set(chapter.related_criteria or []) != set(
- chapter.direct_scoring_criteria or []
- ):
- errors.append(
- f"一级章 {chapter.id}:{chapter.title} 禁止保存子树评分汇总"
- )
- if chapter.related_rejections and not getattr(
- chapter, "direct_rejection_bindings", []
- ):
- errors.append(
- f"一级章 {chapter.id}:{chapter.title} 禁止保存废标项汇总"
- )
- for key, ids in top_titles.items():
- if key and len(ids) > 1:
- errors.append(f"一级章标题重复: {key}(章号 {','.join(ids)})")
- nodes_with_paths = list(_walk_with_paths(enhanced_outline.chapters))
- for criterion in analysis.scoring_criteria:
- scoped_chapter = next(
- (
- chapter for chapter in enhanced_outline.chapters
- if chapter.level == 1
- and normalize_heading_text(
- getattr(chapter, "template_original_title", "") or chapter.title
- ) == normalize_heading_text(criterion.name)
- ),
- None,
- )
- if scoped_chapter is None and "项目经理" in (criterion.name or ""):
- scoped_chapter = next(
- (
- chapter for chapter in enhanced_outline.chapters
- if chapter.level == 1 and "项目经理" in (chapter.title or "")
- ),
- None,
- )
- dimensions = get_outline_scoring_dimensions(enhanced_outline, criterion)
- for dimension in dimensions:
- direct = [
- (node, path)
- for node, path in nodes_with_paths
- if dimension.binding_id
- in (getattr(node, "direct_scoring_bindings", []) or [])
- ]
- # 兼容旧的单维度大纲/测试对象;复合项必须有维度绑定。
- if not direct and len(dimensions) == 1:
- direct = [
- (node, path)
- for node, path in nodes_with_paths
- if criterion.id in (node.direct_scoring_criteria or [])
- ]
- if len(direct) != 1:
- errors.append(
- f"评分维度 {dimension.binding_id} {dimension.title} "
- f"必须有且只有一个直接落点,实际 {len(direct)} 个"
- )
- continue
- node, path = direct[0]
- if scoped_chapter is not None and (not path or path[0] is not scoped_chapter):
- errors.append(
- f"评分维度 {dimension.binding_id} 必须位于"
- f"「{scoped_chapter.title}」章,实际: {_path_text(path)}"
- )
- if node.level > 4:
- errors.append(
- f"评分维度 {dimension.binding_id} 标题超过四级: "
- f"{_path_text(path)}"
- )
- # 新增评分小项不得与模板其他标题同名。
- if not getattr(node, "from_template", False) and node.level > 1:
- normalized = normalize_heading_text(node.title)
- duplicates = [
- template_node
- for template_node, _ in _walk_with_paths(template_outline.chapters)
- if normalize_heading_text(template_node.title) == normalized
- ]
- if normalized and duplicates:
- errors.append(
- f"评分维度 {dimension.binding_id} 新增标题与模板已有标题同名: "
- f"{_path_text(path)}"
- )
- chapter_one = next(
- (
- chapter
- for chapter in enhanced_outline.chapters
- if chapter.level == 1
- and str(getattr(chapter, "template_chapter_id", "") or chapter.id) == "1"
- ),
- None,
- )
- for item in analysis.rejection_items:
- targets = [
- (node, path)
- for node, path in nodes_with_paths
- if item.id in (node.related_rejections or [])
- and "要求承诺函" in (node.title or "")
- ]
- if len(targets) != 1:
- errors.append(
- f"废标项 {item.id} 必须唯一落到第一章要求承诺函,实际 {len(targets)} 个"
- )
- continue
- _, path = targets[0]
- if not chapter_one or path[0] is not chapter_one:
- errors.append(
- f"废标项 {item.id} 落到第一章之外: {_path_text(path)}"
- )
- persisted_mappings = list(
- getattr(enhanced_outline, "heading_mappings", []) or []
- )
- if persisted_mappings:
- for criterion in analysis.scoring_criteria:
- for dimension in get_outline_scoring_dimensions(
- enhanced_outline, criterion
- ):
- mapped = [
- row for row in persisted_mappings
- if dimension.binding_id
- in (row.get("scoring_bindings", []) or [])
- ]
- if len(mapped) != 1:
- errors.append(
- f"评分维度 {dimension.binding_id} 的新旧标题关系必须唯一保存,"
- f"实际 {len(mapped)} 个"
- )
- for item in analysis.rejection_items:
- mapped = [
- row for row in persisted_mappings
- if item.id in (row.get("rejection_bindings", []) or [])
- ]
- if len(mapped) != 1:
- errors.append(
- f"废标项 {item.id} 的新旧标题关系必须唯一保存,实际 {len(mapped)} 个"
- )
- return errors
- def _render_tree(outline: BidOutline, *, annotated: bool) -> list[str]:
- lines = []
- for node, _path in _walk_with_paths(outline.chapters):
- if node.id == "index":
- continue
- indent = " " * max(node.level - 1, 0)
- annotations = []
- if annotated:
- annotations.append(_origin_label(node))
- if node.direct_scoring_criteria:
- annotations.append(
- "评分直接落点=" + ",".join(node.direct_scoring_criteria)
- )
- if getattr(node, "direct_scoring_bindings", None):
- annotations.append(
- "评分维度=" + ",".join(node.direct_scoring_bindings)
- )
- if getattr(node, "direct_rejection_bindings", None):
- annotations.append(
- "废标直接落点=" + ",".join(node.direct_rejection_bindings)
- )
- suffix = f" `{';'.join(annotations)}`" if annotations else ""
- lines.append(
- f"{indent}- H{node.level} `{node.id}` {node.title}{suffix}"
- )
- return lines
- def build_outline_report(
- template_outline: BidOutline,
- enhanced_outline: BidOutline,
- analysis: TenderAnalysis,
- ) -> tuple[str, dict]:
- errors = validate_outline_gate(template_outline, enhanced_outline, analysis)
- nodes_with_paths = list(_walk_with_paths(enhanced_outline.chapters))
- scoring_rows = []
- for criterion in analysis.scoring_criteria:
- dimensions = get_outline_scoring_dimensions(enhanced_outline, criterion)
- for dimension in dimensions:
- target = next(
- (
- (node, path)
- for node, path in nodes_with_paths
- if dimension.binding_id
- in (getattr(node, "direct_scoring_bindings", []) or [])
- ),
- (None, []),
- )
- if target[0] is None and len(dimensions) == 1:
- target = next(
- (
- (node, path)
- for node, path in nodes_with_paths
- if criterion.id in (node.direct_scoring_criteria or [])
- ),
- (None, []),
- )
- node, path = target
- action, explanation = _structural_action(node, path)
- scoring_rows.append(
- {
- "id": criterion.id,
- "binding_id": dimension.binding_id,
- "category": criterion.category,
- "name": criterion.name,
- "dimension": dimension.title,
- "score": criterion.max_score,
- "requirement": dimension.requirement,
- "full_requirement": criterion.description,
- "source": criterion.section_ref or "未提取到原文位置",
- "target": _path_text(path) if path else "未映射",
- "origin": _origin_label(node) if node else "未映射",
- "action": action,
- "explanation": explanation,
- "template_original_title": (
- getattr(node, "template_original_title", "") if node else ""
- ),
- "final_title": node.title if node else "",
- "content_generation_mode": (
- getattr(node, "content_generation_mode", "") if node else ""
- ),
- }
- )
- rejection_rows = []
- for item in analysis.rejection_items:
- target = next(
- (
- (node, path)
- for node, path in nodes_with_paths
- if item.id in (node.related_rejections or [])
- and "要求承诺函" in (node.title or "")
- ),
- (None, []),
- )
- node, path = target
- rejection_rows.append(
- {
- "id": item.id,
- "category": item.category,
- "requirement": item.description,
- "source": "废标项原文位置尚未由 Step2 保存",
- "target": _path_text(path) if path else "未映射",
- "origin": _origin_label(node) if node else "未映射",
- }
- )
- new_nodes = [
- {
- "id": node.id,
- "title": node.title,
- "level": node.level,
- "origin": _origin_label(node),
- "path": _path_text(path),
- }
- for node, path in nodes_with_paths
- if _origin_label(node) in {
- "新增一级评分章",
- "既有父章节下新增标题",
- }
- ]
- lines = [
- "# Step 3 大纲对照与映射报告",
- "",
- f"- 项目:{enhanced_outline.project_name}",
- f"- 门禁结果:{'通过' if not errors else '失败'}",
- f"- 评分项:{len(analysis.scoring_criteria)} 项,具体评分维度 {len(scoring_rows)} 个",
- f"- 废标项:{len(rejection_rows)} 项",
- f"- 新增一级评分章:{sum(1 for n in new_nodes if n['level'] == 1)} 个",
- f"- 新增章内标题:{sum(1 for n in new_nodes if n['level'] > 1)} 个",
- "",
- "## ID 查询说明",
- "",
- "- `SC-xx` 是 Step2 生成的评分项内部 ID;在第 3 节查名称、维度、原文要求、来源和落点。",
- "- `RI-xx` 是 Step2 生成的废标项内部 ID;在第 4 节查完整废标条件和落点。",
- "- `SC-xx#n` 是 Step3 拆出的具体计分维度,用于防止一个总名称假装覆盖多条要求。",
- "",
- "## 1. 模板原始 Heading 目录",
- "",
- *_render_tree(template_outline, annotated=False),
- "",
- "## 2. 加入评分项和废标项后的最终目录",
- "",
- *_render_tree(enhanced_outline, annotated=True),
- "",
- "## 3. 评分项具体落点",
- "",
- "| ID/维度 | 评分大类 | 评分小类 | 分值 | 最终章节路径 | 结构动作 |",
- "| --- | --- | --- | ---: | --- | --- |",
- ]
- for row in scoring_rows:
- lines.append(
- f"| {row['binding_id']} | {row['category']} | "
- f"{row['dimension']} | {row['score']} | {row['target']} | {row['action']} |"
- )
- lines.extend(["", "### 评分要求明细", ""])
- for row in scoring_rows:
- lines.extend(
- [
- f"#### {row['binding_id']} {row['dimension']}",
- "",
- f"- 落点:{row['target']}",
- f"- 结构动作:{row['action']}",
- f"- 解释:{row['explanation']}",
- f"- 模板原标题:{row['template_original_title'] or '无(新增标题)'}",
- f"- 最终标题:{row['final_title']}",
- f"- Step4 写作模式:{row['content_generation_mode']}",
- f"- 来源:{row['source']}",
- f"- 评分要求:{row['requirement']}",
- "",
- ]
- )
- lines.extend(
- [
- "## 4. 废标项具体落点",
- "",
- "| ID | 类别 | 最终章节路径 | 处理方式 |",
- "| --- | --- | --- | --- |",
- ]
- )
- for row in rejection_rows:
- lines.append(
- f"| {row['id']} | {row['category']} | {row['target']} | {row['origin']} |"
- )
- lines.extend(["", "### 废标要求明细", ""])
- for row in rejection_rows:
- lines.extend(
- [
- f"#### {row['id']} {row['category']}",
- "",
- f"- 落点:{row['target']}",
- f"- 来源:{row['source']}",
- f"- 要求:{row['requirement']}",
- "",
- ]
- )
- lines.extend(["## 5. 新增标题汇总", ""])
- if new_nodes:
- for node in new_nodes:
- lines.append(
- f"- {node['origin']}:H{node['level']} {node['path']}"
- )
- else:
- lines.append("- 无新增标题。")
- lines.extend(["", "## 6. Step 3 门禁", ""])
- if errors:
- lines.extend(f"- ❌ {error}" for error in errors)
- else:
- lines.extend(
- [
- "- ✅ 模板一级章完整且唯一。",
- "- ✅ 每个具体评分维度有且只有一个直接落点。",
- "- ✅ 只有无法匹配模板的评分大类可新增一级章。",
- "- ✅ 每条废标项唯一落到第一章要求承诺函。",
- ]
- )
- payload = {
- "project": enhanced_outline.project_name,
- "passed": not errors,
- "errors": errors,
- "scoring": scoring_rows,
- "rejections": rejection_rows,
- "new_headings": new_nodes,
- "heading_mappings": list(
- getattr(enhanced_outline, "heading_mappings", []) or []
- ),
- "evaluation_index_entries": list(
- getattr(enhanced_outline, "evaluation_index_entries", []) or []
- ),
- }
- return "\n".join(lines).rstrip() + "\n", payload
- def write_outline_report(
- markdown_path: str | Path,
- template_outline: BidOutline,
- enhanced_outline: BidOutline,
- analysis: TenderAnalysis,
- ) -> tuple[Path, Path, list[str]]:
- markdown_path = Path(markdown_path)
- json_path = markdown_path.with_suffix(".json")
- markdown, payload = build_outline_report(
- template_outline, enhanced_outline, analysis
- )
- markdown_path.parent.mkdir(parents=True, exist_ok=True)
- markdown_path.write_text(markdown, encoding="utf-8")
- json_path.write_text(
- json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
- )
- return markdown_path, json_path, list(payload["errors"])
- __all__ = [
- "build_outline_report",
- "build_template_outline",
- "validate_outline_gate",
- "write_outline_report",
- ]
|