"""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", ]