| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923 |
- """
- 内容审核器
- 多维度检查投书质量:
- 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
|