""" 内容审核器 多维度检查投书质量: 1. 评分项覆盖 2. 废标项规避 3. 字数达标 4. 章节完整性 5. 响应清单完整性 """ from __future__ import annotations import json import logging import re from concurrent.futures import ThreadPoolExecutor, as_completed from typing import List from llm_client import LLMClient from models import ( BidOutline, Chapter, ChapterType, ReviewReport, ReviewIssue, TenderAnalysis, ScoringCriterion, RejectionItem, ResponseRequirement, ) from config import get_config from chapter_policy import ChapterEditMode, get_chapter_edit_mode from step5_reviewing.policies import ( analysis_has_package_info, find_body_indent_issues, find_duplicate_info_issues, find_heading_score_issues, find_package_info_issues, find_rejection_filling_issues, find_sibling_numbering_issues, find_template_integrity_issues, ) logger = logging.getLogger(__name__) def _heading_match_token(text: str) -> str: """归一化标题用于在父章实际文本中判断小节是否存在。""" value = re.sub(r"^第\s*[一二三四五六七八九十百\d]+\s*章\s*[::]?\s*", "", (text or "")) return re.sub(r"[\s\u3000]+", "", value).rstrip("::。;;") def _chapter_effective_content(chapter: Chapter) -> str: """返回章节唯一正文;父章已有汇总正文时不再重复统计子章。""" if (chapter.generated_content or "").strip(): return chapter.generated_content return "\n".join( _chapter_effective_content(child) for child in (chapter.children or []) if _chapter_effective_content(child).strip() ) def _canonical_outline_content(outline: BidOutline) -> str: """构建不重复父子内容的全文语料。""" return "\n".join( f"【{ch.id} {ch.title}】\n{_chapter_effective_content(ch)}" for ch in outline.chapters if _chapter_effective_content(ch).strip() ) def _balanced_outline_excerpt( outline: BidOutline, max_chars: int = 12000, ) -> str: """从每个顶层章节均匀取样,避免审核只看到文档开头。""" populated = [ (ch, _chapter_effective_content(ch)) for ch in outline.chapters if _chapter_effective_content(ch).strip() ] if not populated: return "" quota = max(500, max_chars // len(populated)) blocks = [] for ch, content in populated: available = max(quota - len(ch.id) - len(ch.title) - 8, 100) if len(content) > available: head = available * 2 // 3 tail = available - head excerpt = content[:head] + "\n……\n" + content[-tail:] else: excerpt = content blocks.append(f"【{ch.id} {ch.title}】\n{excerpt}") return "\n".join(blocks)[:max_chars] class _Reviewer: """内容审核器""" def __init__(self): self.llm = LLMClient() self.cfg = get_config() def review( self, outline: BidOutline, analysis: TenderAnalysis, ) -> ReviewReport: """执行全面审核""" issues: List[ReviewIssue] = [] total_words = 0 # 1. 字数统计(仅用于报告,不再做字数达标判定) for ch in outline.chapters: total_words += len(_chapter_effective_content(ch)) total_words += self._count_template_words(outline) # 2. 标题格式/序号检查(确定性) issues.extend(self._check_heading_format(outline)) # 3. 冗余信息删除(包号/重复/表前表后信息,包号与重复判断使用 LLM 结果) issues.extend(self._check_redundant_info(outline, analysis)) report = ReviewReport( passed=len([i for i in issues if i.severity == "error"]) == 0, issues=issues, total_word_count=total_words, ) return report # ============================================================ # 评分项覆盖检查 # ============================================================ def _check_criteria_coverage( self, outline: BidOutline, criteria: List[ScoringCriterion], ) -> List[ReviewIssue]: """检查每个评分项是否按 Step3 大纲填充到其绑定节点。 优先读取 Step3 持久化的评分绑定节点完整正文做 LLM 语义判断, 避免再使用全书 12,000 字均衡抽样而遗漏章中部材料。LLM 失败或大纲 缺少绑定时使用确定性关键词/空正文保底。 """ issues: List[ReviewIssue] = [] all_content = _canonical_outline_content(outline) entries = list(getattr(outline, "evaluation_index_entries", []) or []) entries_by_criterion: dict[str, List[dict]] = {} for entry in entries: if entry.get("entry_type") != "scoring": continue cid = str(entry.get("criterion_id", "") or "") entries_by_criterion.setdefault(cid, []).append(entry) top_criteria = sorted(criteria, key=lambda c: c.max_score, reverse=True) llm_failed = False # 旧大纲缺少 evaluation_index_entries 时保留原有全书均衡抽样路径。 if not entries_by_criterion: content_excerpt = _balanced_outline_excerpt(outline) try: for batch_start in range(0, len(top_criteria), 15): batch = top_criteria[batch_start:batch_start + 15] criteria_text = "\n".join( f"[{c.id}] {c.category} | {c.name} ({c.max_score}分): " f"{c.description[:200]}" for c in batch ) result = self.llm.extract_json( system_prompt=( "你是投标文件质量审核专家。严格检查投标书内容是否充分覆盖了" "每条评分要求。只报告确实未覆盖或覆盖不足的项。" "你必须输出一个 JSON 对象,包含 uncovered 数组和 summary 字符串。" ), user_prompt=( f"## 本批评分标准({len(batch)}项)\n{criteria_text}\n\n" f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n" "请逐一检查本批评分项是否被充分覆盖。\n" '输出 JSON:{"uncovered": [{"criteria_id": "ID", ' '"reason": "说明"}], "summary": "总结"}' ), max_tokens=16384, ) if not isinstance(result, dict): llm_failed = True continue uncovered = result.get("uncovered", []) if isinstance(uncovered, list): for item in uncovered: if not isinstance(item, dict): continue cid = item.get("criteria_id", "") reason = item.get("reason", "") matched = [c for c in criteria if c.id == cid] if matched: c = matched[0] issues.append(ReviewIssue( chapter_id="*", severity="error", issue_type="missing_criterion", description=( f"[LLM深度检查] 评分项 [{c.id}] " f"{c.name} 未充分覆盖" ), suggestion=reason or f"需要补充{c.name}相关内容", )) except Exception as e: logger.warning(f"LLM 覆盖检查失败: {e},降级使用关键词检查") llm_failed = True else: scoring_entries = [ entry for entry in entries if entry.get("entry_type") == "scoring" ] try: for batch_start in range(0, len(scoring_entries), 15): batch = scoring_entries[batch_start:batch_start + 15] blocks = [] for entry in batch: evidence = self._bound_entry_evidence(outline, entry) evidence = ( evidence[:2500] if evidence else "(未找到该评分项绑定正文)" ) blocks.append( f"[{entry.get('source_id')}] " f"{entry.get('criterion_id')} | " f"{entry.get('display_name')}\n" f"评分要求:{entry.get('requirement', '')[:300]}\n" f"绑定节点:{entry.get('final_heading_id')} " f"{entry.get('final_heading_title')}\n" f"绑定节点正文:\n{evidence}" ) payload = "\n\n".join(blocks) result = self.llm.extract_json( system_prompt=( "你是投标文件质量审核专家。系统已按 Step3 大纲把每个评分小项" "绑定到唯一标题节点,并给出该节点正文。请只检查这些评分小项" "是否在各自绑定节点中被充分覆盖。只报告确实未覆盖或覆盖不足的项。" "输出 JSON:{\"uncovered\": [{\"source_id\": \"ID\", " "\"reason\": \"说明\"}], \"summary\": \"总结\"}" ), user_prompt=( f"## 本批评分小项与绑定正文({len(batch)}项)\n{payload}\n\n" '请逐一检查并输出 JSON。' ), max_tokens=16384, ) if not isinstance(result, dict): llm_failed = True logger.warning("LLM 覆盖检查返回非预期格式") continue uncovered = result.get("uncovered", []) if isinstance(uncovered, list): for item in uncovered: if not isinstance(item, dict): continue source_id = str( item.get("source_id") or item.get("criteria_id") or "" ) reason = item.get("reason", "") entry = next( ( e for e in scoring_entries if str(e.get("source_id", "")) == source_id or str(e.get("criterion_id", "")) == source_id ), None, ) cid = ( str(entry.get("criterion_id", "")) if entry else source_id.split("#", 1)[0] ) matched = [c for c in criteria if c.id == cid] if matched: c = matched[0] issues.append(ReviewIssue( chapter_id="*", severity="error", issue_type="missing_criterion", description=( f"[LLM深度检查] 评分项 [{c.id}] " f"{c.name} 未按 Step3 大纲充分覆盖" ), suggestion=reason or f"在绑定节点补充{c.name}相关内容", )) if result.get("summary"): logger.info(f"LLM 覆盖检查: {result['summary']}") except Exception as e: logger.warning(f"LLM 覆盖检查失败: {e},降级使用关键词检查") llm_failed = True # ---- 确定性保底:只报告绑定节点空正文或未映射,不做模糊关键词误报 ---- if llm_failed: scoring_entries = [ entry for entry in entries if entry.get("entry_type") == "scoring" ] mapped_ids = { str(entry.get("criterion_id", "")) for entry in scoring_entries } for entry in scoring_entries: criterion_id = str(entry.get("criterion_id", "")) evidence = self._bound_entry_evidence(outline, entry) if not evidence.strip(): issues.append(ReviewIssue( chapter_id="*", severity="error", issue_type="missing_criterion", description=( f"评分项 [{criterion_id}] " f"{entry.get('display_name', '')} 绑定节点内容为空" ), suggestion="在 Step3 绑定的最深层标题节点补充响应正文", )) for criterion in criteria: if criterion.id in mapped_ids or not criterion.name: continue if criterion.name[:4] in all_content: continue issues.append(ReviewIssue( chapter_id="*", severity="warning", issue_type="missing_criterion", description=( f"评分项 [{criterion.id}] {criterion.name} 未映射到任何章节" ), suggestion="需要将此项加入相关章节", )) return issues def _bound_criterion_evidence( self, outline: BidOutline, entries: List[dict], ) -> str: """收集某评分项绑定节点的完整正文(供旧调用方/单评分项证据使用)。""" nodes = self._bound_nodes(outline, entries) if not nodes: return "" parts = [] for node in nodes: evidence = self._node_section_text(outline, node) if evidence: parts.append(f"【{node.id} {node.title}】\n{evidence}") return "\n\n".join(parts) def _bound_entry_evidence(self, outline: BidOutline, entry: dict) -> str: """返回单个评分小项绑定节点的正文,供 LLM 逐小项审核。""" nodes = self._bound_nodes(outline, [entry]) if not nodes: return "" return self._node_section_text(outline, nodes[0]) def _node_section_text(self, outline: BidOutline, node: Chapter) -> str: """返回绑定节点自身的正文;自身为空时按 Step3 标题关系切出对应小节。""" specific = "\n".join( part for part in ( node.supplement_content or "", node.generated_content or "", ) if part.strip() ).strip() if specific: return specific from step4_writing import _split_artifact_content_by_outline root_id = str(node.id).split(".", 1)[0] root = next( (chapter for chapter in outline.chapters if str(chapter.id) == root_id), None, ) if root is None: return "" buckets = self._chapter_section_buckets(root) return "\n".join(buckets.get(str(node.id), []) or []).strip() def _chapter_section_buckets(self, root: Chapter) -> dict: """按章节缓存 Step3 标题切分结果,避免重复扫描大段正文。""" from step4_writing import _split_artifact_content_by_outline cache = getattr(self, "_section_bucket_cache", None) if cache is None: cache = {} self._section_bucket_cache = cache key = str(root.id) if key not in cache: cache[key] = _split_artifact_content_by_outline(root) return cache[key] def _bound_nodes( self, outline: BidOutline, entries: List[dict], ) -> List[Chapter]: """返回评分项在 Step3 大纲中绑定的最终标题节点。""" heading_ids = { str(entry.get("final_heading_id", "") or "") for entry in entries if entry.get("final_heading_id") } return [ node for node in outline.flatten() if str(node.id) in heading_ids ] def _check_heading_format(self, outline: BidOutline) -> List[ReviewIssue]: """标题分值、同级序号与正文首行缩进的确定性检查。""" issues: List[ReviewIssue] = [] issues.extend(find_heading_score_issues(outline)) issues.extend(find_sibling_numbering_issues(outline)) issues.extend(find_body_indent_issues(outline)) return issues def _check_rejection_filling(self, outline: BidOutline) -> List[ReviewIssue]: """废标项是否按 Step3 大纲填充到第一章要求承诺函。""" entries = [ entry for entry in getattr(outline, "evaluation_index_entries", []) or [] if entry.get("entry_type") == "rejection" ] return find_rejection_filling_issues(outline, entries) def _check_cleanup_policies( self, outline: BidOutline, analysis: TenderAnalysis, ) -> List[ReviewIssue]: """无分包包号清理、重复正文与模板补充块一致性检查。""" issues: List[ReviewIssue] = [] issues.extend(find_package_info_issues( outline, analysis_has_package_info(analysis) )) issues.extend(find_duplicate_info_issues(outline)) issues.extend(find_template_integrity_issues(outline)) return issues def _check_redundant_info( self, outline: BidOutline, analysis: TenderAnalysis, ) -> List[ReviewIssue]: """冗余信息删除:包号、重复正文,以及表前表后重复项目信息。""" issues: List[ReviewIssue] = [] issues.extend(find_package_info_issues( outline, analysis_has_package_info(analysis) )) issues.extend(find_duplicate_info_issues(outline)) issues.extend(self._check_redundant_info_with_llm(outline, analysis)) return issues def _check_redundant_info_with_llm( self, outline: BidOutline, analysis: TenderAnalysis, ) -> List[ReviewIssue]: """用 LLM 判断表前表后重复信息及需要回填的 Step2 项目字段。""" fields = getattr(analysis, "project_fields", {}) or {} field_text = "\n".join( f"{key}:{value}" for key, value in fields.items() ) or "无" chapters = [ chapter for chapter in outline.chapters if len((chapter.generated_content or "").strip()) >= 50 ] if not chapters: return [] max_workers = min( int(getattr(self.cfg, "max_concurrent_writers", 5) or 5), len(chapters), ) logger.info( f"Step5 冗余信息 LLM 并发审核 {len(chapters)} 章 " f"(max_workers={max_workers})" ) def _review_one(chapter): from llm_client import LLMClient return self._review_chapter_redundant( chapter, field_text, LLMClient() ) issues: List[ReviewIssue] = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(_review_one, chapter): chapter for chapter in chapters } for future in as_completed(futures): issue = future.result() if issue is not None: issues.append(issue) return issues def _review_chapter_redundant( self, chapter: Chapter, field_text: str, llm, ): """单个章节的冗余信息 LLM 判断,返回 ReviewIssue 或 None。""" content = (chapter.generated_content or "").strip() try: result = llm.extract_json( system_prompt=( "你是投标文件冗余信息审核专家。请识别章节中表前/表后重复出现的" "项目信息、重复段落,以及无实际包号时应删除的包号行;并结合给定" "Step2 项目字段指出需要回填/补全的字段。只输出 JSON,不要改写正文。" ), user_prompt=( f"## Step2 项目字段\n{field_text}\n\n" f"## 章节正文(前 8000 字)\n{content[:8000]}\n\n" '输出 JSON:{"removals": [{"text": "需删除原文", "reason": "原因"}], ' '"fills": [{"label": "服务内容", "value": "..."}]}' ), max_tokens=16384, ) removals = result.get("removals", []) if isinstance(result, dict) else [] fills = result.get("fills", []) if isinstance(result, dict) else [] if removals or fills: return ReviewIssue( chapter_id=chapter.id, severity="error", issue_type="redundant_info", description=( f"第{chapter.id}章存在需清理或回填的表前表后冗余信息" ), suggestion=json.dumps( {"removals": removals, "fills": fills}, ensure_ascii=False, ), ) except Exception as exc: logger.warning(f"第{chapter.id}章冗余信息 LLM 判断失败,使用确定性结果: {exc}") return None def _check_response_requirements_coverage( self, outline: BidOutline, requirements: List[ResponseRequirement], ) -> List[ReviewIssue]: """检查必须提交的商务/技术响应清单是否被覆盖。 优先使用 LLM 语义判断,避免关键词误报;LLM 失败时降级关键词兜底。 """ issues: List[ReviewIssue] = [] required = [r for r in requirements if getattr(r, "required", True)] if not required: return issues all_content = _canonical_outline_content(outline) content_excerpt = _balanced_outline_excerpt(outline) llm_failed = False try: req_text = "\n".join( f"[{r.id}] {r.category or '响应要求'} | {r.name}:{r.description}" for r in required ) result = self.llm.extract_json( system_prompt=( "你是投标文件审核专家。请判断投标书正文是否逐项覆盖了" "商务/技术响应文件清单中的必交项。只报告确实未覆盖或覆盖不足的项。" ), user_prompt=( f"## 必交响应清单(共{len(required)}项)\n{req_text}\n\n" f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n" '输出严格 JSON:{"missing": [{"id": "RR-01", "reason": "说明"}]}' ), max_tokens=16384, ) if isinstance(result, dict): missing = result.get("missing", []) if isinstance(missing, list): for item in missing: if not isinstance(item, dict): continue rid = item.get("id", "") reason = item.get("reason", "") matched = [r for r in required if r.id == rid] if matched: r = matched[0] issues.append(ReviewIssue( chapter_id="*", severity="warning", issue_type="response_incomplete", description=f"[LLM检查] 响应清单项 [{r.id}] {r.name} 未充分覆盖", suggestion=reason or f"补充“{r.name}”的说明或材料引用", )) else: llm_failed = True except Exception as e: logger.warning(f"响应清单 LLM 覆盖检查失败: {e},降级关键词检查") llm_failed = True if llm_failed: for req in required: name = (req.name or "").strip() desc = (req.description or "").strip() keyword = name[:8] or desc[:8] if not keyword: continue if keyword in all_content or name in all_content: continue issues.append(ReviewIssue( chapter_id="*", severity="warning", issue_type="response_incomplete", description=f"响应清单项 [{req.id}] {name} 未在正文中明确出现", suggestion=f"建议补充“{name}”的说明或证明材料引用", )) return issues def _check_procurement_requirements_coverage( self, outline: BidOutline, procurement_requirements: str, ) -> List[ReviewIssue]: """对采购需求关键要求做覆盖检查。 优先使用 LLM 判断;LLM 失败时降级为关键词兜底。 """ issues: List[ReviewIssue] = [] if not procurement_requirements: return issues all_content = _canonical_outline_content(outline) content_excerpt = _balanced_outline_excerpt(outline) llm_failed = False try: result = self.llm.extract_json( system_prompt=( "你是投标文件审核专家。请判断投标书正文是否充分覆盖采购需求中的关键要求。" "只报告确实未覆盖或覆盖不足的要点。" ), user_prompt=( f"## 采购需求关键要求\n{procurement_requirements[:6000]}\n\n" f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n" '输出严格 JSON:{"missing": ["要点1", "要点2"], "summary": "一句话总结"}' ), max_tokens=16384, ) if isinstance(result, dict): missing = result.get("missing", []) if isinstance(missing, list) and missing: issues.append(ReviewIssue( chapter_id="*", severity="warning", issue_type="procurement_incomplete", description="[LLM检查] 采购需求关键要求覆盖不足: " + ", ".join( str(x)[:40] for x in missing[:5] ), suggestion="检查正文是否逐项响应采购需求中的关键要求", )) else: llm_failed = True except Exception as e: logger.warning(f"采购需求 LLM 覆盖检查失败: {e},降级关键词检查") llm_failed = True if llm_failed: key_lines = [ line.strip() for line in procurement_requirements.splitlines() if line.strip() and (":" in line or ":" in line) ] missing = [] for line in key_lines[:8]: head = line.split(":")[0].split(":")[0].strip() if head and head not in all_content and line[:20] not in all_content: missing.append(head[:30]) if missing: issues.append(ReviewIssue( chapter_id="*", severity="warning", issue_type="procurement_incomplete", description=f"采购需求关键要求可能未覆盖: {', '.join(missing[:5])}", suggestion="检查正文是否逐项响应采购需求中的关键要求", )) return issues # ============================================================ # 废标项规避检查 # ============================================================ def _check_rejection_avoidance( self, outline: BidOutline, rejection_items: List[RejectionItem], ) -> List[ReviewIssue]: """检查是否触及废标条件(LLM 为主,正则保底)""" issues: List[ReviewIssue] = [] all_content = _canonical_outline_content(outline) content_excerpt = _balanced_outline_excerpt(outline, max_chars=10000) # ---- 策略 A: LLM 废标风险分析(优先) ---- if rejection_items: try: rejection_text = "\n".join( f"[{r.id}] {r.description[:200]}" for r in rejection_items[:10] ) result = self.llm.extract_json( system_prompt=( "你是投标文件合规审核专家。检查投标书内容是否可能触发废标条件。" "只报告有实际风险的问题,不要报告已经正确规避的情况。" "你必须输出一个 JSON 对象,包含 risks 数组和 summary 字符串。" ), user_prompt=( f"## 废标条件\n{rejection_text}\n\n" f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n" f"请检查是否可能触发废标。\n" f'输出 JSON 对象格式:{{"risks": [{{"item_id": "RI-01", "description": "风险描述"}}], "summary": "总结"}}\n' f'如果没有风险,输出 {{"risks": [], "summary": "所有废标条件均已正确规避"}}' ), max_tokens=8192, ) if isinstance(result, dict): risks = result.get("risks", []) if isinstance(risks, list): for risk in risks: if isinstance(risk, dict): issues.append(ReviewIssue( chapter_id="*", severity="error", issue_type="rejection_involved", description=f"[LLM检查] 废标风险: {risk.get('description', '')}", suggestion="请人工检查并修正相关内容", )) if result.get("summary"): logger.info(f"LLM 废标检查: {result['summary']}") return issues # LLM 成功,直接返回 else: logger.warning("LLM 废标检查返回非预期格式,降级使用正则检查") except Exception as e: logger.warning(f"LLM 废标检查失败: {e},降级使用正则检查") # ---- 策略 B: 正则保底(仅 LLM 失败时使用) ---- danger_patterns = [ (r"(?i)(投标无效|废标|否决投标)", "内容中包含废标相关术语"), (r"(?i)(无.*资质|不具备.*条件|不符合.*要求)", "内容中可能存在否定性资格描述"), (r"(?i)(无法满足|不能满足|不响应)", "内容中可能存在对要求的否定响应"), ] for pattern, desc in danger_patterns: matches = re.findall(pattern, all_content) if matches and len(matches) > 2: issues.append(ReviewIssue( chapter_id="*", severity="warning", issue_type="rejection_involved", description=f"检测到可能的废标风险: {desc} (出现 {len(matches)} 次)", suggestion="请人工检查这些上下文是否构成废标风险", )) for item in rejection_items[:5]: keywords = item.description[:10] if keywords and keywords in all_content: issues.append(ReviewIssue( chapter_id="*", severity="warning", issue_type="rejection_involved", description=f"废标项关键词出现在正文中: {keywords}...", suggestion="请确认该处描述不会触发废标条件", )) return issues # ============================================================ # 空章节检查 # ============================================================ def _check_empty_chapters(self, outline: BidOutline) -> List[ReviewIssue]: """检查是否有空章节 商务标子章节(如 1.1 资格条件响应表、2.1 投标函)是目录骨架节点: 其实际内容由父章的模板文本承载,表格/函件在 Step 6 从 Step 1 提取的招标表格替换并填充。因此只要父章有内容,这些子章节不视为 空章节,避免误报并触发 LLM 对表格做无意义补写。 """ issues = [] parent_content = {} parent_modes = {} for ch in outline.chapters: if ch.id.isdigit(): parent_content[ch.id] = ch.generated_content parent_modes[ch.id] = get_chapter_edit_mode(ch.title) for ch in outline.flatten(): if not ch.generated_content.strip(): parent_id = ch.id.split(".")[0] parent_text = parent_content.get(parent_id, "").strip() if "." in ch.id and parent_text: if ( ch.chapter_type == ChapterType.BUSINESS or parent_modes.get(parent_id) == ChapterEditMode.RESTRUCTURE ): # 商务骨架由父章模板承载;重构章的旧模板 # 子节也不再是必须单独生成的内容单元。 continue # 模板容器标题自身可能没有独立正文,内容在父章实际文本中; # 只要父章文本出现该子标题,就视为该小节非空,避免误报。 heading_present = any( _heading_match_token(text) in _heading_match_token(parent_text) for text in (ch.title, ch.template_original_title) if text and _heading_match_token(text) ) if heading_present: continue issues.append(ReviewIssue( chapter_id=ch.id, severity="error", issue_type="style", description=f"章节 {ch.id} {ch.title} 内容为空", suggestion=f"需要撰写该章节内容(目标 {ch.word_count_target:,} 字)", )) return issues # ============================================================ # 章节编号检查 # ============================================================ def _check_chapter_numbering(self, outline: BidOutline) -> List[ReviewIssue]: """检查章节编号是否符合规范""" issues = [] tech_chapters = [c for c in outline.chapters if c.chapter_type.value == "technical"] for ch in tech_chapters: try: ch_num = int(ch.id) if ch_num < 3: issues.append(ReviewIssue( chapter_id=ch.id, severity="error", issue_type="style", description=f"技术标章节 {ch.id} 编号应 >= 3(技术标从第三章开始)", suggestion=f"将技术标章节编号调整为 3 起", )) except ValueError: pass return issues def _check_demand_understanding_scoring_coverage( self, outline: BidOutline, criteria: List[ScoringCriterion], ) -> List[ReviewIssue]: """确保“需求理解”章覆盖名称相同评分项的具体评分维度。 评分大类可能是“技术方案”,其小评分标题按结构规则放在第四章; 但模板另有“需求理解”一级章时,该章正文仍必须覆盖服务定位、预期 目标、重点难点及应对/改进措施,不能只依赖第四章的全局命中。 """ demand_chapter = next( (chapter for chapter in outline.chapters if "需求理解" in chapter.title), None, ) if demand_chapter is None: return [] content = _chapter_effective_content(demand_chapter) issues = [] for criterion in criteria: criterion_text = " ".join(( criterion.name or "", criterion.description or "" )) if not any(key in criterion_text for key in ("需求理解", "重点难点")): continue groups = [] for token in ("服务定位", "预期目标", "重点", "难点"): if token in criterion.description: groups.append((token,)) if "应对" in criterion.description or "改进措施" in criterion.description: groups.append(("应对", "改进措施")) if not groups: groups.append((criterion.name,)) missing = [ "/".join(group) for group in groups if not any(token and token in content for token in group) ] if missing: issues.append(ReviewIssue( chapter_id=demand_chapter.id, severity="error", issue_type="demand_scoring_coverage", description=( f"需求理解章未完整覆盖评分项 [{criterion.id}] " f"{criterion.name}:缺少 {', '.join(missing)}" ), suggestion=( "在需求理解章既有结构内补充服务定位、预期目标、" "重点难点分析及应对或改进措施,不新增评分标题。" ), )) return issues # ============================================================ # 辅助 # ============================================================ def _count_template_words(self, outline: BidOutline) -> int: """估算模板内容字数。 历史实现硬编码返回 5000,但: 1. 商务章等模板内容已在 Step 4 载入 generated_content,flatten 统计已包含它们,再凭空加 5000 属于双重计数; 2. 未被章节载入的模板表格/函件字数无法从 outline 得知,凭空估算 可能掩盖真实字数缺口,导致"缺字仍达标"。 因此不虚增;若将来需要计入模板表格字数,应显式传入模板统计。 """ return 0