""" Step 4:投书内容撰写(V2 更新) 三套策略协同: 1. 表/函填充模式(V2 新增):逻辑+LLM 协同填充资格响应表、投标函等 2. 模板改写模式(保留):解析模板 → 逐章基于模板改写 3. 从零生成模式(降级):模板不可用时,LLM 从零撰写 流程: 解析模板 → 格式比对 → 提取公司信息 → 构建占位符映射 → 表/函填充 → 逐章撰写 → 占位符解析 """ from __future__ import annotations import logging import json import os import re import xml.etree.ElementTree as ET from copy import deepcopy from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Dict, List, Optional, Tuple from models import ( BidOutline, Chapter, ChapterType, CompanyInfo, ProjectData, TenderAnalysis, TemplateStructure, ) from config import get_config from step4_writing.chapter_writer import ( _ChapterWriter, clean_ai_commentary, replace_bracket_placeholders, strip_markdown_headings, ) from step4_writing.content_injector import _ContentInjector from step4_writing.company_info_extractor import extract_company_info from step4_writing.placeholder_engine import ( build_placeholder_map, apply_placeholders, fill_company_static_text, resolve_all_placeholders, ) from step4_writing.table_form_filler import fill_all_tables from step3_outlining.template_parser import ( parse_template, get_template_text_for_chapter, get_template_text_for_section, ) logger = logging.getLogger(__name__) _EVALUATION_INDEX_KEYWORDS = ( "索引表", "主要内容索引", "评标有关的投标文件主要内容索引", ) _TEMPLATE_PART_MARKERS = frozenset( {"商务部分", "技术部分", "附件", "附录"} ) _BOUND_BLOCK_MAX_TOKENS_BASE = 65536 # 初始调用后再按 max_tokens 翻倍重试 4 次,仍缺节点才进入要求兜底。 _BOUND_BLOCK_RETRY_ATTEMPTS = 4 _BOUND_BLOCK_PROMPT = """你是投标文件评分响应撰写专家。系统已经完成模板原文保留、 原生表格处理计划和项目信息填充。你只能为给出的 Step3 最终标题节点生成独立正文块。 硬性要求: 1. 每个 node_id 必须恰好返回一个 content;不得新增、改名或重复输出任何标题; 2. 不得输出 Markdown 表格、HTML、图片说明、签名、公章、日期或表/函结构; 3. 模板复用节点只写评分所需的针对性补充,不复述或重构模板原文; 4. 必须分析该节点已有模板正文(template_content);模板已覆盖的内容不得重复承诺, 只补充模板未覆盖的针对性分析、措施或项目信息,且不得与模板原文冲突; 5. 新增节点写完整响应正文,但只能依据提供的招标要求、项目信息和真实材料; 6. 不得编造证书、业绩、人员、数值或承诺;资料不足时明确写“按招标文件要求响应, 证明材料见对应附件/表单”,不得虚构细节; 7. content 使用普通段落,可分段,但不得使用“一、”“(一)”“1.”等标题型编号。 8. 章节交叉引用只能使用 final_chapter_index 中给出的真实章号;不得引用清单外的 “第X章”,无法确定时写“详见本投标文件相应章节及附件”。 输出:{"blocks":[{"node_id":"3.1","content":"正文"}]}""" def _walk_nodes(chapter: Chapter): yield chapter for child in chapter.children or []: yield from _walk_nodes(child) def _is_evaluation_index_chapter(chapter: Chapter) -> bool: return any( keyword in (chapter.title or "") for keyword in _EVALUATION_INDEX_KEYWORDS ) def _is_deferred_evaluation_index(chapter: Chapter) -> bool: return _is_evaluation_index_chapter(chapter) and any( block.get("block_type") == "deferred_evaluation_index" for block in (chapter.content_blocks or []) ) def _defer_evaluation_index_chapter( chapter: Chapter, template_text: str, placeholder_map: Dict[str, str], company_info: CompanyInfo, analysis: TenderAnalysis, ) -> Chapter: """Step4 只保留索引模板;行内容和页码等待最终合并分页后构建。""" filled_template = _resolve_chapter_text_placeholders( template_text or "", placeholder_map ) filled_template = replace_bracket_placeholders( filled_template, company_info=company_info, analysis=analysis ) chapter.generated_content = filled_template chapter.preserve_template_layout = True chapter.template_fill_completed = True chapter.content_blocks = [ { "order": 0, "block_type": "template_base", "template_original_id": chapter.template_original_id, "template_chars": len(template_text or ""), "project_fill_completed": True, }, { "order": 1, "block_type": "deferred_evaluation_index", "action": "build_rows_and_pages_after_final_merge_and_pagination", "entry_count": 0, }, ] return chapter def _sanitize_bound_block(text: str) -> str: """把 LLM 响应收敛为不会被识别为新标题/表格的普通正文块。""" lines = [] for raw in clean_ai_commentary(text or "").splitlines(): line = raw.strip() if not line: if lines and lines[-1] != "": lines.append("") continue if line.startswith("|") and line.endswith("|"): continue line = re.sub(r"^#{1,6}\s*", "", line) heading = re.match( r"^(?:[一二三四五六七八九十百]+[、,]|" r"[((][一二三四五六七八九十百\d]+[))]|" r"\d+[、..])\s*(.+)$", line, ) if heading: line = f"{heading.group(1).strip()}:" lines.append(line) return "\n".join(lines).strip() def _chapter_title_key(text: str) -> str: value = re.sub(r"^第\s*[一二三四五六七八九十百\d]+\s*章\s*", "", text or "") value = re.sub(r"[《》“”「」()()\s]+", "", value) return value.strip() def _sanitize_chapter_references(content: str, outline: BidOutline) -> str: """把 LLM 输出中不在最终大纲里的“第X章”引用改写为正确章号或通用表述。""" if not content: return content from step3_outlining.outline_generator import _chinese_to_int valid_numbers = set() chapter_by_title = {} for chapter in outline.chapters or []: if str(chapter.id).isdigit(): valid_numbers.add(str(int(chapter.id))) title_key = _chapter_title_key(chapter.title) if title_key: chapter_by_title.setdefault(title_key, chapter) def _is_valid(num_text: str) -> bool: if num_text.isdigit(): return str(int(num_text)) in valid_numbers try: return str(_chinese_to_int(num_text)) in valid_numbers except Exception: return False quoted_pattern = re.compile( r"第\s*([一二三四五六七八九十百\d]+)\s*章\s*[《“「]" r"([^》”」]{1,40})[》”」]" ) def _quoted_repl(match: re.Match) -> str: num_text = match.group(1) title_text = match.group(2).strip() if _is_valid(num_text): return match.group(0) target = chapter_by_title.get(_chapter_title_key(title_text)) if target is not None and str(target.id).isdigit(): return f"第{_native_chapter_number(target)}章《{target.title}》" return "本投标文件相应章节及附件" content = quoted_pattern.sub(_quoted_repl, content) plain_pattern = re.compile(r"(?:投标文件)?第\s*([一二三四五六七八九十百\d]+)\s*章") def _plain_repl(match: re.Match) -> str: if _is_valid(match.group(1)): return match.group(0) return "本投标文件相应章节及附件" return plain_pattern.sub(_plain_repl, content) _REJECTION_CONSEQUENCE_SUFFIXES = ( r"(?:,|;|。)?(?:其)?投标(?:将)?(?:被)?(?:认定为|作为|按)?无效(?:投标)?", r"(?:,|;|。)?(?:其)?投标无效", r"(?:,|;|。)?(?:招标人|采购人|评标委员会)?(?:将|可|有权)?(?:予以)?拒绝" r"(?:其)?(?:投标|接收|参加本次采购活动)?", r"(?:,|;|。)?不能通过(?:资格性|符合性)?审查", ) def _compact_rejection_requirement(text: str) -> str: """保留废标条件本体,去掉每条末尾重复出现的处理后果。""" value = re.sub(r"\s+", "", str(text or "")).strip("。;; ") previous = None while value and value != previous: previous = value for suffix in _REJECTION_CONSEQUENCE_SUFFIXES: value = re.sub(f"{suffix}[。;; ]*$", "", value).strip(",。;; ") return value def _compact_rejection_supplement( rejection_entries: List[dict], analysis: TenderAnalysis, ) -> str: """把要求承诺函的废标项按上游类别归并,避免逐项重复套话。""" items_by_id = { str(item.id): item for item in (analysis.rejection_items or []) } grouped: Dict[str, List[str]] = {} seen = set() for entry in rejection_entries: source_id = str(entry.get("source_id", "") or entry.get("entry_id", "")) item = items_by_id.get(source_id) category = str(getattr(item, "category", "") or "其他实质性要求").strip() requirement = _compact_rejection_requirement( getattr(item, "description", "") or entry.get("requirement", "") ) fingerprint = re.sub(r"[\s,。;;::]", "", requirement) if not requirement or fingerprint in seen: continue seen.add(fingerprint) grouped.setdefault(category, []).append(requirement) if not grouped: return "" lines = [ "我方郑重承诺:严格遵守招标文件、采购需求及相关澄清文件的全部实质性要求," "确保投标文件真实、完整、有效,并重点做到如下事项:" ] for category, requirements in grouped.items(): lines.append(f"{category}方面:{';'.join(requirements)}。") return "\n".join(lines) def _generate_direct_content_blocks( chapter: Chapter, outline: BidOutline, analysis: TenderAnalysis, injector: "_ContentInjector", writer: "_ChapterWriter", ) -> int: """只为 Step3 直接绑定节点生成独立补充块,不改模板基块。""" entries = list(getattr(outline, "evaluation_index_entries", []) or []) entries_by_heading: Dict[str, List[dict]] = {} for entry in entries: heading_id = str(entry.get("final_heading_id", "") or "") if heading_id: entries_by_heading.setdefault(heading_id, []).append(entry) nodes = [ node for node in _walk_nodes(chapter) if entries_by_heading.get(str(node.id)) ] if not nodes: return 0 scoring_nodes = [ node for node in nodes if any( entry.get("entry_type") == "scoring" for entry in entries_by_heading[str(node.id)] ) ] generated_by_id: Dict[str, str] = {} if scoring_nodes: template_buckets = _split_artifact_content_by_outline(chapter) def _request_nodes_for(nodes_subset) -> List[dict]: request_nodes = [] for node in nodes_subset: node_entries = [ entry for entry in entries_by_heading[str(node.id)] if entry.get("entry_type") == "scoring" ] request_nodes.append({ "node_id": node.id, "title": node.title, "mode": node.content_generation_mode or "project_scoring", "requirements": [ { "id": entry.get("source_id", ""), "name": entry.get("display_name", ""), "requirement": entry.get("requirement", ""), } for entry in node_entries ], "step3_description": node.description, "template_content": "\n".join( template_buckets.get(str(node.id), []) ).strip()[:4000], }) return request_nodes def _request_payload(nodes_subset) -> Dict[str, object]: return { "project": { "name": analysis.project_name, "buyer": analysis.buyer_name, "project_id": analysis.tender_project_id, }, "final_chapter_index": [ { "id": chapter.id, "title": chapter.title, "display": ( f"第{_native_chapter_number(chapter)}章 {chapter.title}" if str(chapter.id).isdigit() else chapter.title ), } for chapter in outline.chapters or [] ], "nodes": _request_nodes_for(nodes_subset), "tender_context": injector.get_tender_context(chapter)[:10000], "material_context": injector.get_material_context(chapter)[:8000], "reference_context": injector.get_reference_context(chapter)[:5000], } def _consume_blocks(result, target: Dict[str, str]) -> None: raw_blocks = result.get("blocks", []) if isinstance(result, dict) else [] if not isinstance(raw_blocks, list): return allowed = {str(node.id) for node in scoring_nodes} for block in raw_blocks: if not isinstance(block, dict): continue node_id = str(block.get("node_id", "") or "") if node_id in allowed and node_id not in target: content = _sanitize_bound_block(str(block.get("content", ""))) content = _sanitize_chapter_references(content, outline) if content: target[node_id] = content try: max_tokens = _BOUND_BLOCK_MAX_TOKENS_BASE missing = list(scoring_nodes) for attempt in range(_BOUND_BLOCK_RETRY_ATTEMPTS + 1): if not missing: break payload = _request_payload(missing) result = writer.llm.extract_json( system_prompt=_BOUND_BLOCK_PROMPT, user_prompt=json.dumps(payload, ensure_ascii=False), max_tokens=max_tokens, ) _consume_blocks(result, generated_by_id) missing = [ node for node in scoring_nodes if str(node.id) not in generated_by_id ] if not missing: break if attempt >= _BOUND_BLOCK_RETRY_ATTEMPTS: break logger.warning( f" [{chapter.id}] 评分块缺失 {len(missing)} 个节点," f"第 {attempt + 1} 次重试 max_tokens={max_tokens} -> " f"{min(max_tokens * 2, 65536)}: " f"{[str(node.id) for node in missing]}" ) max_tokens = min(max_tokens * 2, 65536) except Exception as exc: logger.warning(f" [{chapter.id}] 评分内容块批量生成失败,使用要求兜底: {exc}") completed = 0 for node in nodes: node_entries = entries_by_heading[str(node.id)] parts = [] scoring_entries = [e for e in node_entries if e.get("entry_type") == "scoring"] rejection_entries = [e for e in node_entries if e.get("entry_type") == "rejection"] if scoring_entries: parts.append( generated_by_id.get(str(node.id)) or "\n".join( ( f"针对{entry.get('display_name', '本评分要求')},我方已结合" f"{(analysis.project_name or '本项目')}的实际情况、服务范围及" f"{(analysis.buyer_name or '采购人')}的管理要求,对相关事项进行" f"分析并制定针对性响应:{entry.get('requirement', '')}。" f"具体措施及证明材料详见本投标文件相关章节及附件。" ) for entry in scoring_entries ) ) if rejection_entries: parts.append(_compact_rejection_supplement(rejection_entries, analysis)) supplement = _sanitize_bound_block("\n\n".join(part for part in parts if part)) if not supplement: continue node.supplement_content = supplement if node is not chapter or not chapter.preserve_template_layout: node.generated_content = supplement supplement_blocks = ([{ "order": 2, "block_type": "scoring_supplement", "source_ids": [e.get("source_id", "") for e in scoring_entries], "target_heading_id": node.id, }] if scoring_entries else []) + ([{ "order": 3, "block_type": "rejection_supplement", "source_ids": [e.get("source_id", "") for e in rejection_entries], "target_heading_id": node.id, }] if rejection_entries else []) node.content_blocks = [ block for block in (node.content_blocks or []) if block.get("block_type") not in { "scoring_supplement", "rejection_supplement" } ] + supplement_blocks if node is not chapter: target_id = str(node.id) chapter.content_blocks = [ block for block in (chapter.content_blocks or []) if not ( block.get("block_type") in { "scoring_supplement", "rejection_supplement" } and str(block.get("target_heading_id", "")) == target_id ) ] + [dict(block) for block in supplement_blocks] completed += 1 return completed def _get_template_text_for_chapter( template_structure, chapter, ) -> str: """获取模板章节原文;新增评分章无模板正文,模板章按来源编号兜底。 商务章(如第二章"投标报价")要求严格按模板格式书写。 若目录标题与模板标题不一致(如目录用默认"商务响应文件"而模板是 "投标报价"),按模板来源编号回退,避免用顺延后的最终编号读取其他章。 """ if getattr(chapter, "is_scoring_inserted", False): return "" # Step 3 已匹配并持久化的模板区域名 → 精确取文本,避免再次标题匹配 section_name = getattr(chapter, "template_section", "") or "" if section_name: text = get_template_text_for_section(template_structure, section_name) if text: return text text = get_template_text_for_chapter(template_structure, chapter.title) if text or not getattr(chapter, "id", "") or not chapter.id.isdigit(): return text try: from step3_outlining.template_parser import get_template_chapter_titles titles = get_template_chapter_titles(template_structure.file_path) source_number = _template_chapter_number(chapter) tmpl_title = dict(titles).get(source_number, "") if tmpl_title: text = get_template_text_for_chapter(template_structure, tmpl_title) if text: logger.info( f" 章节 [{chapter.id}] 标题未直接匹配模板," f"已按模板来源编号 [{source_number}] 回退到模板标题: {tmpl_title}" ) except Exception as e: logger.debug(f"按章节编号取模板文本失败: {e}") return text def _partition_step4_chapters(outline: BidOutline) -> Tuple[List[Chapter], List[Chapter]]: """普通商务/技术章统一并发;依赖全书的索引章保持延迟。""" ordinary = [] deferred = [] for chapter in outline.chapters: if chapter.depends_on_all or _is_evaluation_index_chapter(chapter): deferred.append(chapter) else: ordinary.append(chapter) return ordinary, deferred def write_content( outline: BidOutline, analysis: TenderAnalysis, project_data: ProjectData, template_path: str = "", reference_bid_path: str = "", chapter_output_dir: Optional[str] = None, ) -> BidOutline: """逐章撰写投书内容(模板改写优先) Args: outline: Step 3 输出的章节目录 analysis: Step 2 输出的招标分析 project_data: Step 1 输出的项目数据 template_path: 模板 DOCX 路径(空则使用默认模板) reference_bid_path: 参考投标文件路径(用于 diff 识别模板骨架) Returns: 填充了 generated_content 的 BidOutline """ cfg = get_config() # ---- 0. 尝试加载模板 ---- template_structure: Optional[TemplateStructure] = None if not template_path: template_path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates", "申勤投标模板.docx", ) if os.path.exists(template_path): try: template_structure = parse_template( template_path=template_path, reference_bid_path=reference_bid_path, ) logger.info( f"模板解析完成: {len(template_structure.sections)} 个区域, " f"{template_structure.total_tables} 个表格" ) except Exception as e: logger.warning(f"模板解析失败,将使用从零生成模式: {e}") template_structure = None # ---- 1. 提取公司信息 ---- company_info = extract_company_info(project_data.general_materials) # ---- 2. 构建占位符映射 ---- placeholder_map = build_placeholder_map(analysis, company_info, project_data) # ---- 3. V2: 格式比对 + 表/函填充 ---- format_reconciled = False if template_structure: # 尝试格式比对(如果招标 PDF 可用) tender_pdf = ( project_data.tender_docs[0].file_path if project_data.tender_docs else "" ) if tender_pdf and tender_pdf.lower().endswith(".pdf"): try: from step1_parsing.table_extractor import ( extract_tables_from_template_docx, ) from step2_analysis.format_reconciler import reconcile_formats logger.info("开始格式比对...") template_tables = extract_tables_from_template_docx(template_path) # 招标表格统一使用提示词+代码方式提取结果(Step 1/Step 2 产物), # 不再用纯结构提取重复生成,保证名称/分类与 LLM 识别一致。 tender_tables = list(analysis.tender_tables or []) if not tender_tables and project_data.extracted_items: tender_tables = [ it.source_table for it in project_data.extracted_items if it.source_table is not None ] if not tender_tables and not project_data.extracted_items: from step1_parsing import extract_tender_items_with_tables items, tender_tables = extract_tender_items_with_tables( tender_file=tender_pdf, ) project_data.extracted_items = items analysis.tender_tables = tender_tables reconciliation_report = reconcile_formats( template_tables, tender_tables, use_llm=True, ) logger.info(f"格式比对完成: {reconciliation_report.summary}") # 对模板 DOCX 执行表/函填充 # 注意:此时的 doc 在 Step 6 才加载,这里先记录比对结果 # 将 reconciliation 存入 project_data 的元数据中供 Step 6 使用 project_data.metadata["reconciliation_report"] = reconciliation_report format_reconciled = True except Exception as e: logger.warning(f"格式比对失败,跳过表/函填充: {e}") else: # 非 PDF 招标文件(如 .docx/.md):无法做表格级格式比对。 # 明确告警而不是静默跳过,避免占位符原样残留却无人知晓。 logger.warning( f"招标文件不是 PDF({tender_pdf or '空'})," f"跳过格式比对/表函替换;" f"模板表格仍由 Step 6 逻辑填充,招标表格式将不替换" ) # ---- 4. 加载通用资料和参考 ---- material_map: Dict[str, str] = {} for doc in project_data.general_materials: if doc.content: material_map[doc.file_name] = doc.content reference_texts: List[str] = [] for doc in project_data.reference_bids: if doc.content: reference_texts.append(doc.content) # 招标相关文件全文(招标文件/采购需求/澄清公告),作为章节生成依据 tender_texts: List[str] = [] for doc in ( list(project_data.tender_docs) + list(project_data.procurement_docs) + list(project_data.clarification_docs) ): if doc.content: tender_texts.append(doc.content) # ---- 4.1 项目信息净化(V2.6 新增)---- # 通用资料/参考标书常来自历史项目,可能包含其他项目名称、场所、地址 # (如"嘉定区行政服务中心、档案馆等物业管理服务项目")。若直接注入 # LLM 提示词,生成内容会引用历史项目信息,造成内容污染。此处统一将 # 其他项目标识替换为当前招标项目的名称/采购人/地址。 from step4_writing.project_info_sanitizer import ( discover_foreign_project_names, extract_current_project_address, is_achievement_material, sanitize_project_identity, ) current_project_name = analysis.project_name or outline.project_name or "" current_venue = analysis.buyer_name or current_project_name current_address = extract_current_project_address( [d.content for d in project_data.tender_docs] + [d.content for d in project_data.procurement_docs] ) foreign_names = discover_foreign_project_names( list(material_map.values()) + reference_texts, current_project_name, ) if current_address: analysis.raw_text_snippets["project_address"] = current_address if foreign_names: logger.info( f" 参考资料中发现其他项目标识 {len(foreign_names)} 个: " f"{foreign_names[:5]}" ) for fname, content in list(material_map.items()): preserve_achievements = is_achievement_material(fname) material_map[fname] = sanitize_project_identity( content, foreign_names, current_project_name, current_venue, current_address=current_address, replace_short_venue=not preserve_achievements, replace_address=not preserve_achievements, ) injector = _ContentInjector(material_map, reference_texts, tender_texts) # ---- 5. 章节撰写(V2:先技术后商务 + 并发) ---- # 本轮 Step 4 对每章重新建立内容来源。默认是生成/改写内容;只有明确把 # 模板章节原文直接载入 generated_content 的分支才改为 True。旧 pickle # 没有该字段时由 Step 6 以有界比较兼容,不能再对整章做无界相似度计算。 for chapter in outline.chapters: chapter.preserve_template_layout = False # 普通商务/技术章统一并发;依赖全书的非索引章延迟撰写;评标索引章 # 只保留模板,直到 Step6 合并、检查并稳定分页后再构建行与页码。 ordinary, deferred = _partition_step4_chapters(outline) index_chapters = [ch for ch in deferred if _is_evaluation_index_chapter(ch)] deferred = [ch for ch in deferred if not _is_evaluation_index_chapter(ch)] technical = [ chapter for chapter in ordinary if chapter.chapter_type == ChapterType.TECHNICAL ] business = [ chapter for chapter in ordinary if chapter.chapter_type == ChapterType.BUSINESS ] ordinary = technical + business logger.info( f"章节分组: 普通章节{len(ordinary)}章统一并发 " f"(技术{len(technical)} + 商务{len(ordinary) - len(technical)}) → " f"延迟依赖章节{len(deferred)}章;" f"评标索引章{len(index_chapters)}章仅保留模板" ) for ch in index_chapters: template_text = ( _get_template_text_for_chapter(template_structure, ch) if template_structure else "" ) _defer_evaluation_index_chapter( ch, template_text, placeholder_map, company_info, analysis ) ch.content_blocks[1]["entry_count"] = len( getattr(outline, "evaluation_index_entries", []) or [] ) logger.info( f" [{ch.id}] {ch.title}: Step4 跳过内容和页码生成," f"保留 {len(outline.evaluation_index_entries)} 条最终索引映射" ) # ---- 阶段 A: 普通技术章和商务章统一并发 ---- failed_chapters: List[str] = [] if ordinary: total_ordinary = len(ordinary) max_workers = min(cfg.max_concurrent_writers, total_ordinary) logger.info( f"阶段 A: 并发处理 {total_ordinary} 个普通章节 " f"(max_workers={max_workers})" ) with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_chapter = {} for ch in ordinary: chapter_writer = _ChapterWriter() future = executor.submit( _write_single_chapter, chapter=ch, writer=chapter_writer, injector=injector, analysis=analysis, company_info=company_info, template_structure=template_structure, cfg=cfg, outline=outline, placeholder_map=placeholder_map, table_fill_planned=format_reconciled, ) future_to_chapter[future] = ch completed = 0 for future in as_completed(future_to_chapter): ch = future_to_chapter[future] completed += 1 try: updated_chapter = future.result() _update_chapter_in_outline(outline, updated_chapter) words = len(updated_chapter.generated_content) logger.info( f" [{ch.id}] {ch.title[:30]}: {words:,} 字 " f"({completed}/{total_ordinary})" ) except Exception as e: logger.exception(f" [{ch.id}] 撰写失败: {e}") failed_chapters.append(ch.id) ch.generated_content = f"【撰写失败: {e}】" # ---- 阶段 B: 延迟章节(完整上下文) ---- if deferred: logger.info(f"阶段 B: 撰写 {len(deferred)} 个延迟章节(完整上下文)") deferred_writer = _ChapterWriter() for ch in deferred: template_text = "" if template_structure: template_text = _get_template_text_for_chapter( template_structure, ch ) if template_text and ch.chapter_type == ChapterType.BUSINESS: ch.generated_content = template_text ch.preserve_template_layout = True logger.info( f" [{ch.id}] 延迟商务章," f"已载入模板章节内容 {len(template_text):,} 字" ) continue try: ch = deferred_writer.write_deferred_chapter( chapter=ch, analysis=analysis, outline=outline, template_text=template_text, company_info=company_info, tender_context=injector.get_tender_context(ch), reference_context=injector.get_reference_context(ch), ) words = len(ch.generated_content) logger.info(f" [{ch.id}] {ch.title[:30]}: {words:,} 字") except Exception as e: logger.exception(f" [{ch.id}] 延迟章节撰写失败: {e}") ch.generated_content = f"【撰写失败: {e}】" if failed_chapters: raise RuntimeError( f" {len(failed_chapters)} 个章节撰写失败: {failed_chapters}" ) # ---- 阶段 D: 统一解析章节文本中的已知占位符 ---- chapter_placeholder_map = build_placeholder_map( analysis, company_info, project_data ) for ch in outline.flatten(): if _is_evaluation_index_chapter(ch): # 索引模板之外的任何内容均不得在 Step4 生成或改写。 continue ch.generated_content = _resolve_chapter_text_placeholders( ch.generated_content, chapter_placeholder_map, ) ch.generated_content = replace_bracket_placeholders( ch.generated_content, company_info=company_info, analysis=analysis, ) # 不再用“正文前 12000 字”的 LLM 返回结果覆盖整章;该旧路径会截断 # 长章节并覆盖前序章节策略。这里只做可重复执行的确定性事实修正。 ch.generated_content = _override_rejection_conflicts( ch.generated_content, analysis, tender_texts ) # 确保废标项内容至少在本章正文中出现一次,而不是只出现在最终清单 ch.generated_content = _append_missing_rejection_responses( ch, analysis ) # 去除章节内重复出现的超长段落(如同一段"物联网+设施设备监测子系统" # 文字被 LLM 在不同小节重复输出),并做最终字数保险裁剪 ch.generated_content = _dedupe_repeated_blocks(ch.generated_content) is_template_derived = getattr(ch, "from_template", False) or any( getattr(c, "from_template", False) for c in (ch.children or []) ) if ch.word_count_target > 0 and not is_template_derived: from step4_writing.chapter_writer import trim_to_word_budget ch.generated_content = trim_to_word_budget( ch.generated_content, ch.word_count_target, tolerance=1.25 ) # ---- 阶段 D0.5: 模板内嵌附件(制度/预案等)用通用材料全文回填 ---- # 模板第四章区域夹着"(五)安全运行管理制度 第一章~第十章"等附件块, # 技术章正文替换时这些旧文本会被删除。此处检测各章模板区域内的内嵌 # 附件 section,用匹配的通用材料(如 管理制度.md)全文替换到章末, # 不做摘要;找不到匹配材料才保留模板原文,避免附件内容消失。 try: _chapter_ctx = {} for _ch in outline.chapters: if _ch.chapter_type == ChapterType.TECHNICAL: _chapter_ctx[_ch.id] = { "tender": injector.get_tender_context(_ch), "reference": injector.get_reference_context(_ch), } _append_template_attachments_with_materials( outline, template_structure, material_map, _chapter_ctx ) except Exception as e: logger.warning(f"模板内嵌附件材料回填失败: {e}") # ---- 阶段 D1: 生成内容项目信息净化(兜底,V2.6)---- # 即使材料已净化,LLM 仍可能从参考标书等处引入历史项目名称/场所, # 此处对最终章节内容做确定性替换(仅替换项目全名与场所全名,不替换 # 场所短名/地址,避免误伤"类似业绩"中的历史客户名单)。 if foreign_names: replaced_total = 0 for ch in outline.flatten(): cleaned = sanitize_project_identity( ch.generated_content, foreign_names, current_project_name, current_venue, replace_short_venue=False, replace_address=False, ) if cleaned != ch.generated_content: replaced_total += 1 ch.generated_content = cleaned if replaced_total: logger.info( f" 生成内容项目信息净化: {replaced_total} 个章节完成替换" ) # 用户授权的唯一正文重写/子标题扩展特例,在通用裁剪之后执行。 from step4_writing.difficulty_rewrite import rewrite_difficulty_sections rewrite_difficulty_sections(outline, analysis, project_data) # ---- 阶段 E: 全部章节统一落盘;完成后才允许返回给审核/导出 ---- if cfg.save_chapter_docs: _persist_all_chapter_docs( outline, chapter_output_dir or cfg.output_dir, template_path=template_path, placeholder_map=chapter_placeholder_map, company_info=company_info, analysis=analysis, project_data=project_data, ) # ---- 字数汇总 ---- total_words = sum(len(c.generated_content) for c in outline.chapters) logger.info(f"撰写完成: 总字数 {total_words:,} / 目标 {cfg.min_word_count:,}") return outline def _write_single_chapter( chapter: Chapter, writer: "_ChapterWriter", injector: "_ContentInjector", analysis: TenderAnalysis, company_info: CompanyInfo, template_structure: Optional[TemplateStructure], cfg, outline: BidOutline, placeholder_map: Dict[str, str], table_fill_planned: bool = False, ) -> Chapter: """在独立线程中撰写单个章节(供 ThreadPoolExecutor 使用) 这是一个纯函数:接收 Chapter 输入,返回填充后的 Chapter。 不访问任何共享可变状态。 Args: chapter: 待撰写的章节 writer: 独立的 _ChapterWriter 实例(每线程一个) injector: _ContentInjector(只读,可共享) analysis: 招标分析结果(只读) company_info: 公司信息(只读) template_structure: 模板结构(只读) cfg: 配置(只读) Returns: 填充了 generated_content 的 Chapter """ ch_id = chapter.id logger.debug(f" [{ch_id}] 线程开始...") # 获取上下文 scoring_context = injector.get_scoring_context(chapter, analysis) # 需求理解章节完整保留模板原文,仅以相关评分项、招标要求和采购需求 # 驱动授权评分小节的增补正文。 if "需求理解" in (chapter.title or ""): understanding = [ c for c in analysis.scoring_criteria if ( ("需求理解" in (c.name or "")) or ("需求理解" in (c.category or "")) or (c.id in (chapter.related_criteria or [])) ) ] if understanding: criteria_lines = "\n".join( f"- 【{c.id}】{c.name}({c.max_score}分):{c.description}" for c in understanding ) unique_req = ( "\n\n【需求理解专项要求】本章必须围绕以下评分项内容," "结合本项目实际背景、服务对象、采购需求与难点," "展开独特、有针对性的需求理解与分析。模板原文不得删除或改写," "只撰写 Step3 已授权的评分小节,且新增内容必须能追溯到评分项、" "与需求理解相关的废标项、招标要求或采购需求," "不得照搬模板通用表述:\n" f"{criteria_lines}" ) scoring_context = (scoring_context or "") + unique_req rejection_context = injector.get_rejection_context(chapter, analysis) response_context = injector.get_response_context(chapter, analysis) material_context = injector.get_material_context(chapter) reference_context = injector.get_reference_context(chapter) tender_context = injector.get_tender_context(chapter) # 模板原文 template_text = "" if template_structure: template_text = _get_template_text_for_chapter( template_structure, chapter ) if template_text: logger.debug(f" [{ch_id}] 找到模板原文 {len(template_text):,} 字符") else: logger.debug(f" [{ch_id}] 模板中未找到对应章节,将从零生成") # 模板章统一采用“模板基块 → 项目/表格填充计划 → 直接绑定补充块”。 # 不把整章或全部模板子节交给 LLM 重写,因此原生表格、图片、签名及 # 相邻说明的 XML 顺序仍由 Step6 模板装配路径保持。 if template_text: filled_template = _resolve_chapter_text_placeholders( template_text, placeholder_map ) filled_template = replace_bracket_placeholders( filled_template, company_info=company_info, analysis=analysis, ) chapter.generated_content = filled_template chapter.preserve_template_layout = True chapter.template_fill_completed = True chapter.content_blocks = [ { "order": 0, "block_type": "template_base", "template_original_id": chapter.template_original_id, "template_chars": len(template_text), "project_fill_completed": True, }, { "order": 1, "block_type": "native_table_plan", "action": ( "apply_reconciliation_then_authorized_fill" if table_fill_planned else "preserve_structure_and_fill_authorized_cells" ), }, ] supplement_count = _generate_direct_content_blocks( chapter, outline, analysis, injector, writer ) logger.info( f" [{ch_id}] 模板基块 {len(filled_template):,} 字," f"直接补充块 {supplement_count} 个" ) return chapter has_direct_entries = any( str(entry.get("final_heading_id", "")).split(".", 1)[0] == str(chapter.id) for entry in list(getattr(outline, "evaluation_index_entries", []) or []) ) if has_direct_entries: supplement_count = _generate_direct_content_blocks( chapter, outline, analysis, injector, writer ) parts = [] for node in _walk_nodes(chapter): if node is chapter: continue if node.supplement_content: parts.append(f"{node.title}\n{node.supplement_content}") if chapter.supplement_content: parts.insert(0, chapter.supplement_content) chapter.generated_content = "\n\n".join(parts).strip() chapter.template_fill_completed = True chapter.content_blocks = [{ "order": 0, "block_type": "generated_scoring_chapter", "supplement_nodes": supplement_count, }] return chapter # 撰写 chapter = writer.write_chapter( chapter=chapter, analysis=analysis, scoring_context=scoring_context, material_context=material_context, reference_context=reference_context, template_text=template_text, company_info=company_info, tender_context=tender_context, rejection_context=rejection_context, response_context=response_context, ) # 字数不足时扩展(最多补到目标字数,不超发); # 字数超出目标 120% 时在 write_chapter 内已裁剪,此处仅做最终保险 words = len(chapter.generated_content) is_template_derived = any( getattr(c, "from_template", False) for c in (chapter.children or []) ) if ( chapter.word_count_target > 0 and not is_template_derived and cfg.enforce_word_limit and words < chapter.word_count_target * 0.9 ): deficit = chapter.word_count_target - words logger.debug(f" [{ch_id}] 字数不足,扩展 {deficit:,} 字...") try: chapter = writer.expand_chapter(chapter, deficit) except Exception as e: logger.warning(f" [{ch_id}] 扩展失败: {e}") words_after = len(chapter.generated_content) if ( chapter.word_count_target > 0 and not is_template_derived and words_after > chapter.word_count_target * 1.2 ): from step4_writing.chapter_writer import trim_to_word_budget chapter.generated_content = trim_to_word_budget( chapter.generated_content, chapter.word_count_target ) return chapter def _update_chapter_in_outline(outline: BidOutline, updated: Chapter) -> None: """按 chapter.id 在 outline 中定位并替换章节(线程安全) 避免依赖列表索引(并发时索引可能错乱)。 若 outline 中存在重复 id(step3 缺陷),只替换与传入对象同 id 的那一个槽位,防止并发完成的多个章节相互覆盖。 """ matched = None for i, ch in enumerate(outline.chapters): if ch.id == updated.id: if ch is updated: matched = i break if matched is None: matched = i if matched is not None: outline.chapters[matched] = updated return # 未找到(不应发生) logger.warning(f"章节 [{updated.id}] 未在 outline 中找到,无法更新") def _resolve_chapter_text_placeholders( content: str, placeholder_map: Dict[str, str], ) -> str: """在章节落盘前替换已知的 %%...%% 项目/公司占位符。""" result = content or "" for key, value in placeholder_map.items(): if not value: continue if key in ("服务内容", "服务要求", "服务期限"): value = re.sub(r"[。;;]+$", "", value.strip()) + "。" pattern = re.compile( rf"%{{1,3}}\s*{re.escape(key)}\s*%{{1,3}}" ) result = pattern.sub(lambda _m: value, result) # 模板常在占位符后自带句号;来源字段又以句号结尾时,直接替换会产生 # “。。。”,这里统一收敛为单句号。 return re.sub(r"。{2,}", "。", result) _NUM_VALUE_PATTERN = ( r"\d+(?:\.\d+)?\s*(?:%|人|名|年|个月|月|天|日|小时|分钟|分|元|万元|台|套|次|个)?" ) _DYNAMIC_METRIC_PATTERN = re.compile( r"([\u4e00-\u9fff]{2,16}(?:率|数量|人数|期限|时间|违约金|罚款|金额|比例|面积|岗位|设备|单价|费用|标准|人次|年限))" r"[^,。;\n]{0,25}?" + _NUM_VALUE_PATTERN ) _FIXED_NUMERIC_RULES = [ ( "流动率", re.compile(r"(?:人员)?流动率[^,。;\n]{0,20}?\d+(?:\.\d+)?%"), re.compile(r"(?:人员)?流动率[^,。;\n]{0,30}?\d+(?:\.\d+)?%"), ), ] def _build_dynamic_numeric_rules(source_texts): """从废标项/采购需求/服务要求中动态提取数值型约束规则。""" rules = [] seen_metrics = set() for text in source_texts: for match in _DYNAMIC_METRIC_PATTERN.finditer(text or ""): metric = match.group(1).strip() canonical = match.group(0).strip() metric = re.sub( r"^(本项目|本项目的|供应商|投标人|我公司)[的]?", "", metric, ) if not metric or metric in seen_metrics: continue if len(metric) < 2: continue # canonical 必须从规范化后的指标名开始,否则替换目标中已有的 # “本项目的/供应商”等前缀会与来源前缀叠加,重复执行后不断增长。 metric_pos = canonical.find(metric) if metric_pos >= 0: canonical = canonical[metric_pos:] seen_metrics.add(metric) conflict_re = re.compile( re.escape(metric) + r"[^,。;\n]{0,30}?" + _NUM_VALUE_PATTERN ) rules.append({ "metric": metric, "canonical": canonical, "conflict_re": conflict_re, }) return rules _REQUIREMENT_ALIGN_PROMPT = """你是投标文件一致性修正专家。下方提供一份章节正文和权威要求来源。 请仅修正正文中与权威要求冲突的事实性内容,例如数值、比例、期限、人数、金额、证书、资质等。 必须保留原文结构、标题、表格框架和其他无关内容。 输出完整修正后的正文,不要输出任何解释、前缀或后缀。""" def _llm_apply_requirement_alignment( content: str, analysis: TenderAnalysis, source_texts: Optional[List[str]] = None, ) -> str: """使用 LLM 修正正文与招标/采购/废标要求冲突的内容。""" if not content or analysis is None: return content sources = [ item.description or "" for item in (analysis.rejection_items or []) ] if analysis.service_requirements: sources.append(analysis.service_requirements) if analysis.qualification_requirements: sources.append(analysis.qualification_requirements) if analysis.procurement_requirements: sources.append(analysis.procurement_requirements) for extra in (source_texts or []): if extra: sources.append(extra) sources = [x for x in sources if x.strip()] if not sources: return content try: from llm_client import LLMClient llm = LLMClient() user_prompt = ( "## 章节正文\n" f"{content[:12000]}\n\n" "## 权威要求来源\n" + "\n\n".join(x[:2500] for x in sources[:8]) ) corrected = llm.generate( system_prompt=_REQUIREMENT_ALIGN_PROMPT, user_prompt=user_prompt, max_tokens=32768, temperature=0.2, ) cleaned = clean_ai_commentary(corrected.strip()) if cleaned and len(cleaned) >= len(content) * 0.4: return cleaned except Exception as e: logger.warning(f"LLM 要求一致性修正失败: {e}") return content def _override_rejection_conflicts( content: str, analysis: TenderAnalysis, source_texts: Optional[List[str]] = None, ) -> str: """用评分/废标/招标/采购需求中的数值约束覆盖正文或模板冲突。""" if not content or analysis is None: return content result = content sources = [ item.description or "" for item in (analysis.rejection_items or []) ] sources.extend( item.description or "" for item in (analysis.scoring_criteria or []) ) if analysis.service_requirements: sources.append(analysis.service_requirements) if analysis.qualification_requirements: sources.append(analysis.qualification_requirements) if analysis.procurement_requirements: sources.append(analysis.procurement_requirements) for extra in (source_texts or []): if extra: sources.append(extra) dynamic_rules = _build_dynamic_numeric_rules(sources) for rule in dynamic_rules: result = rule["conflict_re"].sub(rule["canonical"], result) for metric, canonical_re, conflict_re in _FIXED_NUMERIC_RULES: for desc in sources: if metric not in desc: continue canonical_match = canonical_re.search(desc) if not canonical_match: continue canonical = canonical_match.group(0).strip() result = conflict_re.sub(canonical, result) break return result def _append_missing_rejection_responses( chapter: Chapter, analysis: TenderAnalysis, ) -> str: """兼容入口:废标要求已在写作提示词中正向转化,不再机械追加原文。 旧实现会把“投标无效/废标”等负面条款逐字堆到章节末尾,既可能触发 审核误报,也无法保证落到正确的模板小节。缺失覆盖由 Step5 按既有 小节定向补充。 """ return chapter.generated_content or "" def _dedupe_repeated_blocks(content: str) -> str: """去除章节正文中重复出现的长文本块(LLM 常把同一材料写入多个小节)。 规则: - 单行正文(>=40 字)重复出现时,保留第一次,删除后续重复行; - 连续两行组合重复出现时同样去重; - 小节标题行(一、/(一)/1. 等)不去重(不同小节可能重名但结构不同)。 """ if not content: return content def norm(s: str) -> str: return re.sub(r"\s+", "", s) lines = content.split("\n") seen_single = set() seen_pair = set() out = [] removed = 0 for i, line in enumerate(lines): stripped = line.strip() key = norm(stripped) is_heading = bool( re.match(r"^[一二三四五六七八九十百]+、", key) or re.match(r"^([一二三四五六七八九十百]+)", key) or re.match(r"^[((]\d+[))]", key) or re.match(r"^\d+[\.、))]", key) ) if ( not is_heading and len(key) >= 40 and key in seen_single ): removed += 1 continue if len(key) >= 40: seen_single.add(key) # 连续两行组合去重(针对被换行拆开的重复段落) if i >= 1 and not is_heading: prev_key = norm(lines[i - 1].strip()) if prev_key: pair = prev_key + "||" + key if pair in seen_pair and len(prev_key) + len(key) >= 60: removed += 1 continue if prev_key: seen_pair.add(pair) out.append(line) if removed: logger.info(f" 章节正文重复块去重: 删除 {removed} 行") return "\n".join(out) def _clean_chapter_title(name: str) -> str: """去掉"第X章"前缀,返回干净的章节标题""" return re.sub( r"^第\s*[一二三四五六七八九十百\d]+\s*章[\s::、]*", "", name or "" ).strip() _ATTACHMENT_MATERIAL_MATCH_PROMPT = """你是投标文件材料匹配专家。模板正文中夹有一段附件内容 (通常是"管理制度/预案/方案"等),需要从通用材料清单中选出与它内容最匹配的一份, 用其全文替换附件区。 输出严格 JSON:{"material": "材料文件名或null", "label": "附件标题", "reason": "一句话理由"} 规则: 1. 依据附件块章节标题与每份材料的内容开头判断,选择章节结构最吻合、覆盖最全的材料; 2. material 必须是清单中存在的文件名;若清单中没有匹配项,输出 null; 3. label 给出附件标题(简短,如"安全运行管理制度"/"职业健康管理制度"), 从附件块章节标题归纳,不含"附:"前缀; 4. 只输出 JSON。""" def _llm_pick_attachment_material( embedded_names: list, material_map: Dict[str, str], ) -> Tuple[str, str]: """用 LLM 选出与附件块最匹配的材料与附件标题,返回 (材料文件名, 附件标题)""" try: from llm_client import LLMClient lines = ["## 附件块章节标题"] for name in embedded_names: lines.append(f"- {name}") lines.append("\n## 通用材料清单") for fname, content in sorted(material_map.items()): preview = " ".join((content or "").split())[:150] lines.append( f"- {fname}(约 {len(content or ''):,} 字)开头:{preview}" ) llm = LLMClient() result = llm.extract_json( system_prompt=_ATTACHMENT_MATERIAL_MATCH_PROMPT, user_prompt="\n".join(lines), max_tokens=8192, ) if isinstance(result, dict): name = str(result.get("material") or "").strip() if name not in material_map: name = "" label = str(result.get("label") or "").strip() return name, label except Exception as e: logger.warning(f"附件材料 LLM 匹配失败(回退模板原文): {e}") return "", "" _ATTACHMENT_GENERATE_PROMPT = """你是投标文件附件撰写专家。请根据以下材料撰写一份完整的 附件正文(如管理制度/预案/方案等),用于投标文件附录。 要求: 1. 以"模板附件原文"为结构基准(章节顺序、编号体系),完整覆盖其中所有章节; 2. 招标文件相关要求必须体现;参考投书内容仅作风格与写法参考; 3. 篇幅控制在模板篇幅的 1~1.3 倍以内,按模板章节与条目完整覆盖; 在模板内容基础上仅做必要润色与本项目适配,不新增条目、不展开解释性 扩写;超过模板篇幅 1.3 倍视为不合格;也不要摘要化(须给出完整正文); 4. 直接输出附件正文,无前缀无后缀。""" def _llm_generate_attachment( chapter: Chapter, template_attachment_text: str, tender_context: str, reference_context: str, ) -> str: """基于模板附件原文 + 招标要求 + 参考投书片段(三源)生成附件正文。""" try: from llm_client import LLMClient target = len(template_attachment_text or "") segments = [ f"附件所属章节:第{chapter.id}章 {chapter.title}", ] if template_attachment_text: segments.append( f"## 模板附件原文(结构基准,约 {target:,} 字)\n" f"模板篇幅约 {target:,} 字,附件正文请控制在 " f"{int(target * 1.0):,}~{int(target * 1.3):,} 字以内。\n" f"{template_attachment_text}" ) if tender_context: segments.append( f"## 招标文件相关要求(必须体现)\n{tender_context[:5000]}" ) if reference_context: segments.append( f"## 参考投书相关片段(风格参考)\n{reference_context[:3000]}" ) llm = LLMClient() content = llm.generate( system_prompt=_ATTACHMENT_GENERATE_PROMPT, user_prompt="\n\n".join(segments), max_tokens=32768, temperature=0.3, ) cleaned = clean_ai_commentary(content.strip()) return cleaned except Exception as e: logger.warning(f"附件正文 LLM 生成失败: {e}") return "" def _append_template_attachments_with_materials( outline: "BidOutline", template_structure: Optional[TemplateStructure], material_map: Dict[str, str], chapter_contexts: Optional[Dict[str, Dict[str, str]]] = None, ) -> int: """把模板章节区域内夹着的内嵌附件(制度等)用通用材料全文回填到章末。 附件区检测为纯结构判断:当前真实章到下一真实章之间、且不属于大纲的 "第X章" section;附件正文用 LLM 基于"模板附件原文 + 招标要求 + 参考投书片段" 生成(通用材料仅作补充参考),篇幅与模板附件原文相当,不全文照搬。 返回回填的章节数。 """ if not template_structure or not outline: return 0 sections = list(template_structure.sections) if not sections: return 0 # 大纲真实章标题(去"第X章"前缀) real_titles = set() for ch in outline.chapters: t = _clean_chapter_title(ch.title) if t: real_titles.add(t) if not real_titles: return 0 chapter_re = re.compile(r"^第\s*[一二三四五六七八九十百\d]+\s*章") filled = 0 for ch in outline.chapters: if ch.chapter_type != ChapterType.TECHNICAL: continue if ch.preserve_template_layout: # 模板原生附件、图片和签署上下文由 Step6 原样保留;Step4 # 不再把附件文本重复追加到模板基块末尾。 continue own_name = getattr(ch, "template_section", "") or "" own = next( (s for s in sections if s.name.strip() == own_name.strip()), None, ) if own is None: continue # 下一真实章边界:own 之后第一个"第X章"且标题属于大纲真实章的 section later = [ s for s in sections if chapter_re.match(s.name) and s.start_para > own.start_para and _clean_chapter_title(s.name) in real_titles ] nxt = min(later, key=lambda s: s.start_para) if later else None # 内嵌附件区:own 到下一真实章之间、标题不属于大纲真实章的"第X章" section embedded = [] for s in sections: if s.start_para <= own.start_para: continue if nxt is not None and s.end_para >= nxt.start_para: continue if ( chapter_re.match(s.name) and _clean_chapter_title(s.name) not in real_titles ): embedded.append(s) if not embedded: continue # 模板附件原文(结构基准) template_attachment_text = "\n\n".join( t for s in embedded for t in [ get_template_text_for_section(template_structure, s.name) or "" ] if t ) ctx = (chapter_contexts or {}).get(ch.id, {}) tender_ctx = ctx.get("tender", "") ref_ctx = ctx.get("reference", "") # LLM 选出最匹配的通用材料,仅用于附件命名(内容生成只用三源) best_name, attach_label = _llm_pick_attachment_material( [s.name for s in embedded], material_map ) generated = _llm_generate_attachment( ch, template_attachment_text, tender_ctx, ref_ctx, ) if generated: # 篇幅保险:LLM 扩写超过模板 1.4 倍时,直接采用模板附件原文 # (模板本身即完整依据,避免附件无限膨胀) if ( template_attachment_text and len(generated) > len(template_attachment_text) * 1.4 ): logger.warning( f" 附件生成 [{ch.id}] 篇幅 {len(generated):,} 字超过模板基准 " f"{len(template_attachment_text):,} 字的 1.4 倍,改用模板原文" ) generated = template_attachment_text label = ( attach_label or ( best_name.replace(".md", "").replace(".txt", "") if best_name else "" ) or "附件" ) ch.generated_content += f"\n\n附:{label}\n{generated}" logger.info( f" 附件区回填 [{ch.id}] {ch.title}: LLM 基于模板/招标/参考生成 " f"{label} ({len(generated):,} 字,模板基准 {len(template_attachment_text):,} 字)" ) filled += 1 continue # 兜底:LLM 无匹配或失败时保留模板内嵌附件原文 parts = [] for s in embedded: txt = get_template_text_for_section(template_structure, s.name) if txt: parts.append(txt) if parts: ch.generated_content += "\n\n" + "\n\n".join(parts) logger.info( f" 附件区保留模板原文 [{ch.id}] {ch.title}: " f"{sum(len(p) for p in parts):,} 字(附件生成失败,保留模板原文)" ) filled += 1 return filled def _persist_all_chapter_docs( outline: BidOutline, output_dir: str, template_path: str = "", placeholder_map: Optional[Dict[str, str]] = None, company_info: Optional[CompanyInfo] = None, analysis: Optional[TenderAnalysis] = None, project_data: Optional[ProjectData] = None, ) -> List[str]: """统一保存所有顶层章节,并写 manifest 作为拼接前完整性屏障。 章节目录按项目隔离,避免上一次未完成运行留下的局部文件与本次产物 混在同一个目录中。 """ safe_project = re.sub( r'[<>:"/\\|?*]+', "_", outline.project_name or "投标项目", ).strip(" ._") or "投标项目" chapter_dir = os.path.abspath( os.path.join(output_dir, "chapters", safe_project) ) os.makedirs(chapter_dir, exist_ok=True) incomplete = [] for ch in outline.chapters: content = ch.generated_content.strip() if _is_deferred_evaluation_index(ch): logger.info( f" [{ch.id}] {ch.title}: 索引产物允许无正文," "等待最终合并、检查和稳定分页后构建" ) continue # 兜底:父章节内容为空但子章节有内容时,用子章节内容拼接 if not content and ch.children: joined = "\n".join( f"{c.title}\n{c.generated_content}" for c in ch.children if c.generated_content.strip() ).strip() if joined: logger.warning( f" [{ch.id}] {ch.title}: 父章节内容为空,已用子章节内容兜底" ) ch.generated_content = joined content = joined if ( not content or "【撰写失败" in content or "【索引表生成失败" in content ): incomplete.append(f"{ch.id} {ch.title}") logger.warning( f" 章节不完整 [{ch.id}] {ch.title}: 长度={len(content)}, " f"空={not content}, 含撰写失败={'【撰写失败' in content}, " f"开头={content[:80]!r}" ) if incomplete: raise RuntimeError( "章节产物不完整,停止审核与拼接: " + ", ".join(incomplete) ) artifact_paths: List[str] = [] manifest_chapters = [] for order, chapter in enumerate(outline.chapters, start=1): path = _save_chapter_docx( chapter, chapter_dir, template_path=template_path, placeholder_map=placeholder_map, company_info=company_info, analysis=analysis, project_data=project_data, ) chapter.artifact_path = path artifact_paths.append(path) manifest_chapters.append({ "order": order, "id": chapter.id, "title": chapter.title, "chapter_type": chapter.chapter_type.value, "content_chars": len(chapter.generated_content), "artifact_path": path, "status": ( "deferred_until_final_pagination" if _is_deferred_evaluation_index(chapter) else "complete" ), }) missing = [path for path in artifact_paths if not os.path.isfile(path)] if missing: raise RuntimeError( "章节 DOCX 保存后校验失败: " + ", ".join(missing) ) manifest_path = os.path.join(chapter_dir, "manifest.json") with open(manifest_path, "w", encoding="utf-8") as f: json.dump( { "project_name": outline.project_name, "expected_count": len(outline.chapters), "completed_count": len(artifact_paths), "ready_for_assembly": True, "final_outline": { "project": outline.project_name, "heading_mappings": outline.heading_mappings, "evaluation_index_entries": outline.evaluation_index_entries, } if outline.heading_mappings else None, "chapters": manifest_chapters, }, f, ensure_ascii=False, indent=2, ) logger.info( f"章节产物完整性校验通过: {len(artifact_paths)}/" f"{len(outline.chapters)},目录: {chapter_dir}" ) return artifact_paths def _artifact_heading_key(text: str) -> str: """用于把模板旧标题正文归入 Step3 最终节点,不用于重新判断层级。""" value = strip_markdown_headings(text or "").strip() value = re.sub(r"^第\s*[一二三四五六七八九十百\d]+\s*章\s*[::]?\s*", "", value) value = re.sub(r"\s+", "", value) return value.rstrip("::。;;") def _split_artifact_content_by_outline(chapter: Chapter) -> Dict[str, List[str]]: """按模板原标题定位正文,但只由最终大纲决定产物中的标题和顺序。""" from step4_writing.difficulty_rewrite import MODE, split_sections if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)): return split_sections(chapter) nodes = list(_walk_nodes(chapter)) buckets: Dict[str, List[str]] = {str(node.id): [] for node in nodes} node_by_id = {str(node.id): node for node in nodes} parent_by_id = {str(chapter.id): None} for node in nodes: for child in node.children or []: parent_by_id.setdefault(str(child.id), str(node.id)) def _node_key(node) -> str: for candidate in (node.template_original_title, node.title): key = _artifact_heading_key(candidate) if key: return key return "" def _node_path(node) -> Tuple[str, ...]: path = [] current = node while current is not None: key = _node_key(current) if key: path.append(key) parent_id = parent_by_id.get(str(current.id)) current = node_by_id.get(parent_id) if parent_id else None return tuple(reversed(path)) path_nodes: Dict[Tuple[str, ...], List[Chapter]] = {} for node in nodes: path_nodes.setdefault(_node_path(node), []).append(node) current = chapter heading_stack: List[Tuple[int, str, Chapter]] = [ (int(chapter.level or 1), _node_key(chapter), chapter) ] content = clean_ai_commentary( strip_markdown_headings(chapter.generated_content or "") ) for raw in content.splitlines(): line = raw.strip() if not line: if buckets[str(current.id)] and buckets[str(current.id)][-1] != "": buckets[str(current.id)].append("") continue line_key = _artifact_heading_key(line) prefix = [item_key for _level, item_key, _node in heading_stack] matched = next( ( candidate for candidate in path_nodes.get(tuple(prefix + [line_key]), []) if candidate is not current ), None, ) if matched is not None: current = matched level = int(matched.level or 2) while heading_stack and heading_stack[-1][0] >= level: heading_stack.pop() heading_stack.append((level, line_key, matched)) continue buckets[str(current.id)].append(line) return buckets def _render_artifact_lines(doc, lines: List[str]) -> None: """把某个最终标题节点的正文写入检查 DOCX,不从正文猜测新标题。""" from docx.shared import Pt i = 0 while i < len(lines): line = lines[i].strip() if not line: i += 1 continue if _is_markdown_table_row(line) and _has_separator_row(lines, i + 1): table_lines, table_end = _extract_table_block(lines, i) _render_table_block(doc, table_lines) i = table_end continue para = doc.add_paragraph() para.paragraph_format.line_spacing = 1.5 para.add_run(line).font.size = Pt(12) i += 1 def _render_artifact_node(doc, node: Chapter, buckets: Dict[str, List[str]]) -> None: """按 Step3 最终树递归写出 H2-H4、模板正文和直接补充块。""" from docx.shared import Pt level = max(2, min(int(node.level or 2), 4)) heading = doc.add_paragraph(node.title, style=f"Heading {level}") for run in heading.runs: run.font.size = Pt(16 if level == 2 else 14) body_lines = list(buckets.get(str(node.id), []) or []) if not body_lines and node.generated_content and not node.supplement_content: body_lines = clean_ai_commentary( strip_markdown_headings(node.generated_content) ).splitlines() _render_artifact_lines(doc, body_lines) if node.supplement_content.strip(): _render_artifact_lines(doc, node.supplement_content.splitlines()) for child in node.children or []: _render_artifact_node(doc, child, buckets) _WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" _XML_NS = "http://www.w3.org/XML/1998/namespace" def _w_tag(local: str) -> str: return f"{{{_WORD_NS}}}{local}" def _docx_xml_text(element) -> str: return "".join( node.text or "" for node in element.iter(_w_tag("t")) ).strip() def _docx_style_maps(styles_root) -> Tuple[Dict[str, int], Dict[int, str]]: by_id: Dict[str, int] = {} by_level: Dict[int, str] = {} for style in styles_root.iter(_w_tag("style")): style_id = style.get(_w_tag("styleId"), "") name_node = style.find(_w_tag("name")) name = name_node.get(_w_tag("val"), "") if name_node is not None else "" level = 0 for value in (style_id, name): match = re.match(r"^(?:heading|标题)\s*([1-4])$", value or "", re.I) if match: level = int(match.group(1)) break if level: by_id[style_id] = level by_level.setdefault(level, style_id) return by_id, by_level def _docx_paragraph_level(paragraph, style_levels: Dict[str, int]) -> int: p_pr = paragraph.find(_w_tag("pPr")) if p_pr is None: return 0 p_style = p_pr.find(_w_tag("pStyle")) if p_style is None: return 0 style_id = p_style.get(_w_tag("val"), "") if style_id in style_levels: return style_levels[style_id] match = re.match(r"^(?:heading|标题)\s*([1-4])$", style_id or "", re.I) return int(match.group(1)) if match else 0 def _replace_docx_paragraph_text(paragraph, text: str) -> None: text_nodes = list(paragraph.iter(_w_tag("t"))) if not text_nodes: run = ET.SubElement(paragraph, _w_tag("r")) text_node = ET.SubElement(run, _w_tag("t")) text_nodes = [text_node] text_nodes[0].text = text text_nodes[0].set(f"{{{_XML_NS}}}space", "preserve") for node in text_nodes[1:]: node.text = "" def _fill_docx_paragraph_text(paragraph, text: str) -> None: """只更新变化的文本范围,保留模板各 run 的格式和非文本节点。""" from difflib import SequenceMatcher nodes = list(paragraph.iter(_w_tag("t"))) if not nodes: _replace_docx_paragraph_text(paragraph, text) return original = "".join(node.text or "" for node in nodes) # 占位符作为整体匹配,避免值中的相同字符被匹配到占位符内部。 token_pattern = r"%%[^%\r\n]+%%|%[((][^%\r\n]+[))]%|[\s\S]" before = re.findall(token_pattern, original) after = re.findall(token_pattern, text) offsets = [0] for token in before: offsets.append(offsets[-1] + len(token)) owners = [index for index, node in enumerate(nodes) for _ in (node.text or "")] values = ["" for _ in nodes] for kind, i, j, a, b in SequenceMatcher(None, before, after, autojunk=False).get_opcodes(): start, end = offsets[i], offsets[j] if kind == "equal": for pos in range(start, end): values[owners[pos]] += original[pos] elif kind in {"replace", "insert"}: # 新值继承被替换范围起点的格式;末尾追加继承最后一个字符。 owner = owners[min(start, len(owners) - 1)] if owners else 0 values[owner] += "".join(after[a:b]) for node, value in zip(nodes, values): node.text = value node.set(f"{{{_XML_NS}}}space", "preserve") def _new_docx_paragraph(text: str, style_id: str = ""): paragraph = ET.Element(_w_tag("p")) if style_id: p_pr = ET.SubElement(paragraph, _w_tag("pPr")) p_style = ET.SubElement(p_pr, _w_tag("pStyle")) p_style.set(_w_tag("val"), style_id) run = ET.SubElement(paragraph, _w_tag("r")) text_node = ET.SubElement(run, _w_tag("t")) text_node.set(f"{{{_XML_NS}}}space", "preserve") text_node.text = text return paragraph def _native_chapter_number(chapter: Chapter) -> str: if not chapter.id.isdigit(): return chapter.id from step3_outlining.scoring_structure import _int_to_chinese return _int_to_chinese(int(chapter.id)) def _template_chapter_number(chapter: Chapter) -> str: value = ( chapter.template_chapter_id or chapter.template_original_id or chapter.id ) return str(value).split(".", 1)[0] _TABLE_PLACEHOLDER_TYPES = { "资格条件响应表": "qualification_response", "实质性要求响应表": "substantive_response", "报价汇总表": "bid_summary_table", "报价明细表": "bid_detail_table", } def _table_placeholder_key(text: str) -> str: match = re.fullmatch(r"\s*%%\s*([^%\n]+?)\s*%%\s*", text or "") return match.group(1).strip() if match else "" def _normalized_table_name(value: str) -> str: return re.sub(r"[\s\d.、::()()《》]", "", value or "").replace("格式", "") def _table_type_for_placeholder(key: str) -> str: normalized = _normalized_table_name(key) for name, table_type in _TABLE_PLACEHOLDER_TYPES.items(): known = _normalized_table_name(name) if known in normalized or normalized in known: return table_type return "" def _looks_like_table_placeholder(key: str) -> bool: return bool( _table_type_for_placeholder(key) or re.search(r"表|清单|一览|汇总|明细|台账|台帐", key or "") ) def _table_identity_matches(key: str, table: Any) -> bool: """按表名身份匹配任意表占位符,标准类型只作为辅助证据。""" normalized_key = _normalized_table_name(key) if not normalized_key: return False for value in ( getattr(table, "title_hint", ""), getattr(table, "caption", ""), ): normalized_value = _normalized_table_name(value) if not normalized_value: continue if normalized_key == normalized_value: return True if min(len(normalized_key), len(normalized_value)) >= 4 and ( normalized_key in normalized_value or normalized_value in normalized_key ): return True expected_type = _table_type_for_placeholder(key) return bool( expected_type and getattr(table, "table_type", "") == expected_type ) def _select_placeholder_table( key: str, analysis: Optional[TenderAnalysis], project_data: Optional[ProjectData], ) -> Any: """按招标文件 > 采购需求 > 模板 > 参考投书选择获授权表格。 格式协调结果只能在来源表类型仍与占位符精确一致时作为候选,禁止 仅因列数/表头相似就把评分表等其他表格替换到资格响应表位置。 """ expected_type = _table_type_for_placeholder(key) normalized_key = _normalized_table_name(key) tender_paths = { os.path.abspath(doc.file_path) for doc in list(getattr(project_data, "tender_docs", []) or []) if getattr(doc, "file_path", "") } procurement_paths = { os.path.abspath(doc.file_path) for doc in list(getattr(project_data, "procurement_docs", []) or []) if getattr(doc, "file_path", "") } reference_tables = list(getattr(project_data, "reference_tables", []) or []) def source_rank(table: Any) -> int: source_file = str(getattr(table, "source_file", "") or "") absolute_source = os.path.abspath(source_file) if source_file else "" source_type = str(getattr(table, "source_type", "") or "").lower() if any(table is reference for reference in reference_tables): return 3 if absolute_source in tender_paths or "tender" in source_type: return 0 if absolute_source in procurement_paths or "procurement" in source_type: return 1 if "template" in source_type: return 2 if "reference" in source_type: return 3 # Step1 旧缓存的招标表可能缺少来源标记;它们仍属于 analysis.tender_tables, # 排在模板/参考投书之前,但不会越过路径明确的招标文件。 return 1 def name_rank(table: Any) -> int: return 0 if _table_identity_matches(key, table) else 1 candidates = [ table for table in list(getattr(analysis, "tender_tables", []) or []) if _table_identity_matches(key, table) ] report = ( (project_data.metadata or {}).get("reconciliation_report") if project_data is not None else None ) for rec in list(getattr(report, "reconciliations", []) or []): section_name = _normalized_table_name(getattr(rec, "section_name", "")) template_table = getattr(rec, "template_table", None) section_type = getattr(template_table, "table_type", "") if not ( (normalized_key and (normalized_key in section_name or section_name in normalized_key)) or (expected_type and section_type == expected_type) ): continue decision = getattr(getattr(rec, "decision", None), "value", "") if decision != "replace_with_tender": continue source = ( getattr(rec, "replace_with_tender_table", None) or getattr(rec, "best_match", None) ) if source is None: continue if not _table_identity_matches(key, source): logger.warning( f" %%{key}%% 忽略格式协调误配: " f"{getattr(source, 'title_hint', '') or getattr(source, 'table_type', '') or '未分类'}" ) continue if all(source is not table for table in candidates): candidates.append(source) if candidates: candidates.sort(key=lambda table: (source_rank(table), name_rank(table))) return candidates[0] # 招标文件没有指定格式时,才允许使用参考投书的同类型表格结构。 # 具体项目身份和历史报价在构建 XML 前另行净化。 reference = [ table for table in list(getattr(project_data, "reference_tables", []) or []) if _table_identity_matches(key, table) ] if reference: reference.sort(key=name_rank) return reference[0] return None def _source_item_for_table(project_data: Optional[ProjectData], source_table: Any): for item in list(getattr(project_data, "extracted_items", []) or []): if getattr(item, "source_table", None) is source_table: return item candidate = getattr(item, "source_table", None) if candidate is not None and ( getattr(candidate, "table_id", "") and getattr(candidate, "table_id", "") == getattr(source_table, "table_id", "") ): return item return None def _response_table_columns(source_table: Any) -> Tuple[int, int]: cells = list(getattr(source_table, "cells", []) or []) if not cells: return -1, -1 header = [str(getattr(cell, "text", "") or "") for cell in cells[0]] response_col = next(( index for index, text_value in enumerate(header) if "是/否" in text_value.replace(" ", "") or "投标检查项" in text_value or "响应内容说明" in text_value or "是否响应" in text_value ), -1) evidence_col = next(( index for index, text_value in enumerate(header) if "电子投标文件" in text_value or "证明材料" in text_value or ("对应" in text_value and "文件" in text_value) ), -1) return response_col, evidence_col def _fill_current_response_table(source_table: Any, key: str) -> Any: """只用当前上游表的要求填写响应列,不带入参考投书历史正文。""" table = deepcopy(source_table) cells = list(getattr(table, "cells", []) or []) if len(cells) < 2: return table response_col, evidence_col = _response_table_columns(table) if response_col < 0: return table for row in cells[1:]: if ( response_col < len(row) and not getattr(row[response_col], "is_merged_continuation", False) and not str(row[response_col].text or "").strip() ): row[response_col].text = "是" if evidence_col < 0 or evidence_col >= len(row): continue if getattr(row[evidence_col], "is_merged_continuation", False): continue if str(row[evidence_col].text or "").strip(): continue item_name = str(getattr(row[0], "text", "") or "") if row else "" if "授权" in item_name: evidence = "第一章 法定代表人授权书" elif "中小企业" in item_name: evidence = "第一章 中小企业声明函" elif "联合" in item_name: evidence = "第一章 要求承诺函" else: evidence = "投标文件对应章节" row[evidence_col].text = evidence return table def _overlay_table_xml_text(table_xml, source_table: Any): """按Word网格只填空白响应/证明格,其他原生XML保持不变。 横向合并省略物理tc,纵向合并续格却仍有tc;不能过滤元数据续格后zip。 Step1原文不应再用扁平元数据覆盖,否则会压平段落、run并丢失格式。 """ rows = list(getattr(source_table, "cells", []) or []) fill_columns = {col for col in _response_table_columns(source_table) if col >= 0} xml_rows = table_xml.findall(_w_tag("tr")) for xml_row, row in zip(xml_rows[1:], rows[1:]): before = xml_row.find(_w_tag("trPr") + "/" + _w_tag("gridBefore")) col = int(before.get(_w_tag("val"), "0")) if before is not None else 0 for xml_cell in xml_row.findall(_w_tag("tc")): span = xml_cell.find(_w_tag("tcPr") + "/" + _w_tag("gridSpan")) width = max(1, int(span.get(_w_tag("val"), "1"))) if span is not None else 1 cell_col = col col += width merge = xml_cell.find(_w_tag("tcPr") + "/" + _w_tag("vMerge")) if merge is not None and merge.get(_w_tag("val")) != "restart": continue if cell_col not in fill_columns or cell_col >= len(row): continue cell = row[cell_col] if getattr(cell, "is_merged_continuation", False): continue value = str(getattr(cell, "text", "") or "") if not value.strip() or _docx_xml_text(xml_cell).strip(): continue paragraph = xml_cell.find(_w_tag("p")) if paragraph is None: paragraph = ET.SubElement(xml_cell, _w_tag("p")) _fill_docx_paragraph_text(paragraph, value) return table_xml def _table_artifact_xml(source_table: Any): """优先读取 Step1 单表产物中的原生 w:tbl;不加载 python-docx。""" import zipfile artifact_path = getattr(source_table, "artifact_path", "") if not artifact_path or not os.path.isfile(artifact_path): return None try: with zipfile.ZipFile(artifact_path, "r") as package: artifact_root = ET.fromstring(package.read("word/document.xml")) return next(artifact_root.iter(_w_tag("tbl")), None) except Exception as exc: logger.warning(f"Step1 表格产物读取失败,将按元数据重建: {exc}") return None def _table_origin_covering(source_table: Any, row: int, col: int): cells = list(getattr(source_table, "cells", []) or []) for origin_row in range(row - 1, -1, -1): if origin_row >= len(cells): continue for origin_col, cell in enumerate(cells[origin_row]): if not getattr(cell, "is_merged_origin", False): continue rowspan = max(1, int(getattr(cell, "rowspan", 1) or 1)) colspan = max(1, int(getattr(cell, "colspan", 1) or 1)) if ( origin_row + rowspan > row and origin_col <= col < origin_col + colspan ): return origin_col, cell return None def _metadata_table_xml(source_table: Any, placeholder_map: Dict[str, str]): """按 ExtractedTable 的行列、合并和单元格样式元数据构造 w:tbl。""" cells = list(getattr(source_table, "cells", []) or []) rows = max(int(getattr(source_table, "rows", 0) or 0), len(cells)) cols = int(getattr(source_table, "cols", 0) or 0) if not cols: cols = max((len(row) for row in cells), default=0) if not rows or not cols: return None table = ET.Element(_w_tag("tbl")) table_pr = ET.SubElement(table, _w_tag("tblPr")) width = ET.SubElement(table_pr, _w_tag("tblW")) width.set(_w_tag("w"), "0") width.set(_w_tag("type"), "auto") borders = ET.SubElement(table_pr, _w_tag("tblBorders")) for side in ("top", "left", "bottom", "right", "insideH", "insideV"): border = ET.SubElement(borders, _w_tag(side)) border.set(_w_tag("val"), "single") border.set(_w_tag("sz"), "4") border.set(_w_tag("space"), "0") border.set(_w_tag("color"), "000000") raw_widths = list(getattr(source_table, "col_widths", []) or []) if len(raw_widths) != cols or sum(raw_widths) <= 0: raw_widths = [1.0 / cols] * cols total_width = sum(raw_widths) twips = [max(240, round(9000 * value / total_width)) for value in raw_widths] grid = ET.SubElement(table, _w_tag("tblGrid")) for value in twips: col = ET.SubElement(grid, _w_tag("gridCol")) col.set(_w_tag("w"), str(value)) for row_index in range(rows): row_element = ET.SubElement(table, _w_tag("tr")) row_cells = cells[row_index] if row_index < len(cells) else [] col_index = 0 while col_index < cols: cell = row_cells[col_index] if col_index < len(row_cells) else None covering = _table_origin_covering(source_table, row_index, col_index) if covering is not None: origin_col, origin = covering if col_index != origin_col: col_index += 1 continue colspan = max(1, int(getattr(origin, "colspan", 1) or 1)) text_value = "" vertical_continue = True style_cell = origin elif cell is not None and getattr(cell, "is_merged_continuation", False): col_index += 1 continue else: colspan = max(1, int(getattr(cell, "colspan", 1) or 1)) if cell else 1 text_value = str(getattr(cell, "text", "") or "") if cell else "" vertical_continue = False style_cell = cell tc = ET.SubElement(row_element, _w_tag("tc")) tc_pr = ET.SubElement(tc, _w_tag("tcPr")) tc_width = ET.SubElement(tc_pr, _w_tag("tcW")) tc_width.set(_w_tag("w"), str(sum(twips[col_index:col_index + colspan]))) tc_width.set(_w_tag("type"), "dxa") if colspan > 1: span = ET.SubElement(tc_pr, _w_tag("gridSpan")) span.set(_w_tag("val"), str(colspan)) rowspan = max(1, int(getattr(style_cell, "rowspan", 1) or 1)) if style_cell else 1 if vertical_continue or rowspan > 1: merge = ET.SubElement(tc_pr, _w_tag("vMerge")) if not vertical_continue: merge.set(_w_tag("val"), "restart") paragraph = ET.SubElement(tc, _w_tag("p")) alignment = getattr(style_cell, "alignment", "") if style_cell else "" if alignment: p_pr = ET.SubElement(paragraph, _w_tag("pPr")) jc = ET.SubElement(p_pr, _w_tag("jc")) jc.set(_w_tag("val"), alignment) run = ET.SubElement(paragraph, _w_tag("r")) if style_cell and (getattr(style_cell, "font_size", None) or getattr(style_cell, "bold", False)): r_pr = ET.SubElement(run, _w_tag("rPr")) if getattr(style_cell, "bold", False): ET.SubElement(r_pr, _w_tag("b")) if getattr(style_cell, "font_size", None): size = ET.SubElement(r_pr, _w_tag("sz")) size.set(_w_tag("val"), str(round(float(style_cell.font_size) * 2))) text_node = ET.SubElement(run, _w_tag("t")) text_node.set(f"{{{_XML_NS}}}space", "preserve") text_node.text = _resolve_chapter_text_placeholders( text_value, placeholder_map ) col_index += colspan return table def _sanitized_reference_table( source_table: Any, analysis: Optional[TenderAnalysis], placeholder_map: Dict[str, str], ): """复用参考表结构时移除历史项目身份和历史报价数值。""" import copy table = copy.deepcopy(source_table) header_fields = list(getattr(table, "header_fields", []) or []) reference_project = "" reference_id = "" for line in header_fields: match = re.match(r"\s*项目名称\s*[::]\s*(.+)", str(line or "")) if match: reference_project = match.group(1).strip() match = re.match( r"\s*(?:项目编号|招标编号|采购编号)\s*[::]\s*(.+)", str(line or ""), ) if match: reference_id = match.group(1).strip() current_project = str(getattr(analysis, "project_name", "") or "") current_id = str(getattr(analysis, "tender_project_id", "") or "") monetary_columns = set() cells = list(getattr(table, "cells", []) or []) for row in cells[:5]: for col, cell in enumerate(row): text_value = str(getattr(cell, "text", "") or "") if any(token in text_value for token in ( "金额", "单价", "总价", "月工资", "年费用", "小计", "合计", )): monetary_columns.add(col) for row_index, row in enumerate(cells): for col, cell in enumerate(row): value = str(getattr(cell, "text", "") or "") if reference_project and current_project: value = value.replace(reference_project, current_project) if reference_id and current_id: value = value.replace(reference_id, current_id) if ( row_index > 0 and col in monetary_columns and re.search(r"\d", value) ): value = "待投标报价确认" cell.text = _resolve_chapter_text_placeholders(value, placeholder_map) table.header_fields = [ _resolve_chapter_text_placeholders( str(line or "") .replace(reference_project, current_project) if reference_project else str(line or ""), placeholder_map, ) for line in header_fields ] return table def _replace_native_table_placeholders( body, analysis: Optional[TenderAnalysis], project_data: Optional[ProjectData], placeholder_map: Dict[str, str], ) -> int: """把独立段落中的 %%表名%% 替换为获授权的招标或参考表格块。""" replaced = 0 for paragraph in list(body): if paragraph.tag != _w_tag("p"): continue key = _table_placeholder_key(_docx_xml_text(paragraph)) if not key or not _looks_like_table_placeholder(key): continue source_table = _select_placeholder_table(key, analysis, project_data) if source_table is None: if _table_type_for_placeholder(key) or re.search( r"表|清单|一览|汇总|明细", key ): logger.warning(f" %%{key}%% 未找到获授权的表格来源") continue reference_tables = list(getattr(project_data, "reference_tables", []) or []) is_reference = any(source_table is table for table in reference_tables) or "reference" in str( getattr(source_table, "source_type", "") or "" ).lower() if is_reference: source_table = _sanitized_reference_table( source_table, analysis, placeholder_map ) is_response_table = _response_table_columns(source_table)[0] >= 0 if is_response_table: source_table = _fill_current_response_table(source_table, key) table = None if is_reference else _table_artifact_xml(source_table) if table is not None and is_response_table: table = _overlay_table_xml_text(table, source_table) if table is None: table = _metadata_table_xml(source_table, placeholder_map) if table is None: logger.warning(f" %%{key}%% 的招标表格元数据为空,保留占位符") continue source_item = _source_item_for_table(project_data, source_table) preamble = str(getattr(source_item, "preamble", "") or "") header_fields = list(getattr(source_table, "header_fields", []) or []) postamble = str( getattr(source_item, "postamble", "") or getattr(source_table, "postamble", "") or "" ) insert_at = list(body).index(paragraph) for line in [*preamble.splitlines(), *header_fields]: filled = _resolve_chapter_text_placeholders(line.strip(), placeholder_map) if filled: body.insert(insert_at, _new_docx_paragraph(filled)) insert_at += 1 body.insert(insert_at, table) insert_at += 1 for line in postamble.splitlines(): filled = _resolve_chapter_text_placeholders(line.strip(), placeholder_map) if filled: body.insert(insert_at, _new_docx_paragraph(filled)) insert_at += 1 body.remove(paragraph) replaced += 1 logger.info( f" %%{key}%% → {'参考投书表格' if is_reference else '招标表格'} " f"[{getattr(source_table, 'rows', 0)} 行 × {getattr(source_table, 'cols', 0)} 列]" ) return replaced def _replace_native_commitment_placeholder(body, chapter: Chapter) -> set[str]: """把废标承诺块放入模板授权的 %%承诺函%% 位置。""" rejection_nodes = [ node for node in _walk_nodes(chapter) if (getattr(node, "direct_rejection_bindings", []) or []) and (getattr(node, "supplement_content", "") or "").strip() ] if not rejection_nodes: return set() target = rejection_nodes[0] inserted_for = set() for paragraph in list(body): if paragraph.tag != _w_tag("p"): continue if "%%承诺函%%" not in _docx_xml_text(paragraph): continue insert_at = list(body).index(paragraph) for line in target.supplement_content.splitlines(): if not line.strip(): continue replacement = deepcopy(paragraph) _replace_docx_paragraph_text(replacement, line.strip()) body.insert(insert_at, replacement) insert_at += 1 body.remove(paragraph) inserted_for.add(str(target.id)) logger.info( f" %%承诺函%% → " f"{len(getattr(target, 'direct_rejection_bindings', []) or [])} 条废标项," f"归并为 {len(target.supplement_content.splitlines()) - 1} 类承诺" ) return inserted_for def _register_namespaces_from_part(data: bytes) -> ET.Element: """从独立 OOXML 部件(header/footer 等)注册命名空间并解析根节点。""" import io namespace_events = ET.iterparse(io.BytesIO(data), events=("start-ns",)) for _event, (prefix, uri) in namespace_events: try: ET.register_namespace(prefix or "", uri) except ValueError: pass return ET.fromstring(data) def _transform_header_footer_part( data: bytes, placeholder_map: Optional[Dict[str, str]], project_name: str, ) -> bytes: """替换页眉/页脚独立 XML 部件中的项目名称信息。 模板页眉使用 %%项目名称%% 占位符;部分模板还残留历史项目名 “上海市建筑工程学校物业管理服务项目投标文件”。此函数在章节 DOCX 的 header/footer 部件中执行与最终 Step6 一致的信息替换,不改动 页眉中的图片、文本框或公司标识,也不触碰页脚页码域。 """ root = _register_namespaces_from_part(data) placeholder_map = placeholder_map or {} placeholder_re = re.compile(r"%%(.+?)%%") t_tag = _w_tag("t") def _placeholder_value(match: re.Match) -> str: key = match.group(1).strip() value = placeholder_map.get(key, "") if not value and key == "项目名称" and project_name: value = project_name return value if value else match.group(0) for text_node in root.iter(t_tag): original = text_node.text or "" if not original: continue new_text = placeholder_re.sub(_placeholder_value, original) if project_name: new_text = new_text.replace( "上海市建筑工程学校物业管理服务项目投标文件", f"{project_name}项目投标文件", ) new_text = new_text.replace( "上海市建筑工程学校", project_name ) new_text = new_text.replace( "物业管理服务项目投标文件", "项目投标文件" ) if new_text != original: text_node.text = new_text return ET.tostring(root, encoding="utf-8", xml_declaration=True) def _chapter_header_display_text(project_name: str) -> str: """为兜底章节生成与模板页眉一致的项目投标文件名称。""" project_name = (project_name or "").strip() if not project_name: return "" if project_name.endswith("项目"): return f"{project_name}投标文件" return f"{project_name}项目投标文件" def _add_fallback_header_footer( doc, placeholder_map: Optional[Dict[str, str]], project_name: str, ) -> None: """给非原生章节 DOCX 增加基础页眉和页脚页码。""" from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import OxmlElement from docx.oxml.ns import qn placeholder_map = placeholder_map or {} header_project_name = ( placeholder_map.get("项目名称") or (project_name or "") ) header_text = _chapter_header_display_text(header_project_name) section = doc.sections[0] if header_text: section.header.is_linked_to_previous = False header_para = section.header.paragraphs[0] if header_para.runs: for run in list(header_para.runs): run._element.getparent().remove(run._element) header_para.add_run(header_text) section.footer.is_linked_to_previous = False footer_para = section.footer.paragraphs[0] if footer_para.runs: for run in list(footer_para.runs): run._element.getparent().remove(run._element) footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER run = footer_para.add_run() begin = OxmlElement("w:fldChar") begin.set(qn("w:fldCharType"), "begin") run._element.append(begin) instr = OxmlElement("w:instrText") instr.set(qn("xml:space"), "preserve") instr.text = " PAGE " run._element.append(instr) end = OxmlElement("w:fldChar") end.set(qn("w:fldCharType"), "end") run._element.append(end) def _section_break_records(children) -> List[Tuple[int, object]]: """收集 body 中所有分节符及其位置。 OOXML 中分节符可能位于段落 `pPr/sectPr` 内,也可能作为 body 末尾的 直接 `sectPr` 元素。这里只返回 `sectPr` 元素本身,调用方负责深拷贝。 """ records: List[Tuple[int, object]] = [] for index, element in enumerate(children): sect_pr = None if element.tag == _w_tag("sectPr"): sect_pr = element elif element.tag == _w_tag("p"): p_pr = element.find(_w_tag("pPr")) if p_pr is not None: sect_pr = p_pr.find(_w_tag("sectPr")) if sect_pr is not None: records.append((index, sect_pr)) return records def _first_header_footer_refs(children) -> List[Tuple[str, str, str]]: """返回模板第一个带页眉页脚引用的分节引用(统一页眉页脚基准)。""" for _index, sect_pr in _section_break_records(children): refs = _header_footer_refs(sect_pr) if refs: return refs return [] def _header_footer_refs(sect_pr) -> List[Tuple[str, str, str]]: """读取 sectPr 中 header/footer 引用,按 headerReference 在前排序。""" rel_ns = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships" ) by_key: Dict[Tuple[str, str], Tuple[str, str, str]] = {} order_keys: List[Tuple[str, str]] = [] for child in list(sect_pr): local = child.tag.rsplit("}", 1)[-1] if local not in ("headerReference", "footerReference"): continue rel_id = child.get(f"{{{rel_ns}}}id", "") ref_type = child.get(_w_tag("type"), "default") key = (local, ref_type) if key in by_key: continue by_key[key] = (local, rel_id, ref_type) order_keys.append(key) ordered = [by_key[key] for key in order_keys] ordered.sort(key=lambda item: ( 0 if item[0] == "headerReference" else 1, order_keys.index((item[0], item[2])), )) return ordered def _merge_header_footer_refs( current: List[Tuple[str, str, str]], explicit: List[Tuple[str, str, str]], ) -> List[Tuple[str, str, str]]: """合并节间继承关系:显式引用覆盖同类型,未覆盖类型继续继承。""" by_key: Dict[Tuple[str, str], Tuple[str, str, str]] = { (item[0], item[2]): item for item in current } order_keys = list(by_key.keys()) for item in explicit: key = (item[0], item[2]) if key not in by_key: order_keys.append(key) by_key[key] = item return [by_key[key] for key in order_keys] def _apply_header_footer_refs(sect_pr, refs) -> None: """把有效 header/footer 引用写回 sectPr 顶部,删除旧引用。""" rel_ns = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships" ) for child in list(sect_pr): local = child.tag.rsplit("}", 1)[-1] if local in ("headerReference", "footerReference"): sect_pr.remove(child) insert_at = 0 for local, rel_id, ref_type in refs: element = ET.Element(_w_tag(local)) if rel_id: element.set(f"{{{rel_ns}}}id", rel_id) if ref_type: element.set(_w_tag("type"), ref_type) sect_pr.insert(insert_at, element) insert_at += 1 def _chapter_section_properties( children, start: int, ) -> Optional[object]: """为从 start 开始的一级章计算应写入独立 DOCX 的 sectPr。 返回深拷贝后的 sectPr,已把其 header/footer 引用解析为当前章节所在 分节实际生效的引用(含跨分节继承),避免所有章节共用模板末尾的页眉页脚。 """ records = _section_break_records(children) if not records: return None effective_after: Dict[int, List[Tuple[str, str, str]]] = {} current: List[Tuple[str, str, str]] = [] for index, sect_pr in records: explicit = _header_footer_refs(sect_pr) current = _merge_header_footer_refs(current, explicit) effective_after[index] = list(current) previous_index = None for index, _sect_pr in records: if index < start: previous_index = index else: break effective_refs = ( effective_after.get(previous_index, []) if previous_index is not None else [] ) ending_index = next( (index for index, _sect_pr in records if index >= start), None, ) if ending_index is None: ending_index = records[-1][0] base_sect_pr = next( sect_pr for index, sect_pr in records if index == ending_index ) base_explicit = _header_footer_refs(base_sect_pr) resolved_refs = _merge_header_footer_refs(effective_refs, base_explicit) chapter_sect_pr = deepcopy(base_sect_pr) _apply_header_footer_refs(chapter_sect_pr, resolved_refs) return chapter_sect_pr def _trim_trailing_part_marker( children, start: int, end: int, ) -> int: """去掉当前章区域末尾属于下一分区的“技术部分/商务部分”等标记。 模板正文中“技术部分”等分区文字位于真实一级章之间,且不属于其前面 商务章的内容。Step4 按 H1 边界裁章时会把它连同末尾空行一起带进前一个 章节产物,导致第二章产物在新增标题前出现无关的“技术部分”。 """ marker_index = None for index in range(end - 1, start, -1): element = children[index] if element.tag != _w_tag("p"): break text = _docx_xml_text(element).strip() if text in _TEMPLATE_PART_MARKERS: marker_index = index continue if text: break return marker_index if marker_index is not None else end def _save_native_template_chapter_docx( chapter: Chapter, template_path: str, output_path: str, placeholder_map: Optional[Dict[str, str]] = None, company_info: Optional[CompanyInfo] = None, analysis: Optional[TenderAnalysis] = None, project_data: Optional[ProjectData] = None, ) -> bool: """复制模板原生章节 OOXML,仅改授权标题并插入节点补充正文。""" import io import posixpath import zipfile import xml.etree.ElementTree as ET if not template_path or not os.path.isfile(template_path): return False try: with zipfile.ZipFile(template_path, "r") as source_zip: document_bytes = source_zip.read("word/document.xml") styles_root = ET.fromstring(source_zip.read("word/styles.xml")) namespace_events = ET.iterparse( io.BytesIO(document_bytes), events=("start-ns",) ) for _event, (prefix, uri) in namespace_events: try: ET.register_namespace(prefix or "", uri) except ValueError: pass root = ET.fromstring(document_bytes) body = root.find(_w_tag("body")) if body is None: return False style_levels, style_ids = _docx_style_maps(styles_root) children = list(body) h1_positions = [ index for index, element in enumerate(children) if element.tag == _w_tag("p") and _docx_paragraph_level(element, style_levels) == 1 ] # 新增评分章没有模板正文来源,不能用最终章号裁取同号模板章。 new_scoring_chapter = chapter.is_scoring_inserted target_number = "" if new_scoring_chapter else _template_chapter_number(chapter) start = None for position in h1_positions: text = _docx_xml_text(children[position]) match = re.match( r"^第\s*([一二三四五六七八九十百\d]+)\s*章", text ) if not match: continue raw = match.group(1) from step3_outlining.outline_generator import _chinese_to_int number = str(_chinese_to_int(raw) if not raw.isdigit() else int(raw)) if number == target_number: start = position break if start is None: if not new_scoring_chapter and not _is_evaluation_index_chapter(chapter): from step4_writing.difficulty_rewrite import MODE if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)): raise ValueError("模板中未找到专项所属章节,无法保护原生结构") return False start = 0 chapter_sect_pr = _chapter_section_properties(children, 0) selected = [ _new_docx_paragraph( chapter.title, style_ids.get(1, "Heading1"), ) ] else: end = next((pos for pos in h1_positions if pos > start), len(children)) end = _trim_trailing_part_marker(children, start, end) chapter_sect_pr = _chapter_section_properties(children, start) selected = children[start:end] for element in children: body.remove(element) for element in selected: body.append(element) existing_sect_pr = next( ( element for element in list(body) if element.tag == _w_tag("sectPr") ), None, ) if existing_sect_pr is not None: body.remove(existing_sect_pr) if chapter_sect_pr is not None: body.append(chapter_sect_pr) # 模板章内部常包含无 header/footer 引用的分节符;独立章节 DOCX # 中所有分节统一使用模板第一个分节的页眉页脚引用,保证第 1-8 章 # 与 index 的页眉格式一致。 resolved_refs = _first_header_footer_refs(children) for sect_pr in body.iter(_w_tag("sectPr")): _apply_header_footer_refs(sect_pr, resolved_refs) # Step4 在原生段落/表格单元格中完成项目信息填充。只改含已知 # 占位符的段落文本,不触碰图片、表格结构或其他原始内容块。 effective_map = dict(placeholder_map or {}) header_project_name = ( effective_map.get("项目名称") or (analysis.project_name if analysis is not None else "") or "" ) _replace_native_table_placeholders( body, analysis=analysis, project_data=project_data, placeholder_map=effective_map, ) commitment_placeholder_nodes = _replace_native_commitment_placeholder( body, chapter ) for paragraph in body.iter(_w_tag("p")): original = "".join( node.text or "" for node in paragraph.iter(_w_tag("t")) ) if not original: continue filled = _resolve_chapter_text_placeholders( original, effective_map ) if company_info is not None and analysis is not None: filled = replace_bracket_placeholders( filled, company_info=company_info, analysis=analysis, ) if analysis is not None: # 原生裁章会完整保留模板旧正文;评分要求中的当前数值必须 # 同样覆盖模板历史值,不能只修正 generated_content。 filled = _override_rejection_conflicts(filled, analysis) if filled != original: _fill_docx_paragraph_text(paragraph, filled) # 最终大纲是唯一标题树;模板原标题只负责找到原生内容锚点。 all_nodes = list(_walk_nodes(chapter)) node_by_id = {str(node.id): node for node in all_nodes} parent_by_id = {str(chapter.id): None} for node in all_nodes: for child in node.children or []: parent_by_id.setdefault(str(child.id), str(node.id)) def _node_key(node) -> str: for candidate in (node.template_original_title, node.title): key = _artifact_heading_key(candidate) if key: return key return "" def _node_path(node) -> Tuple[str, ...]: path = [] current = node while current is not None: key = _node_key(current) if key: path.append(key) parent_id = parent_by_id.get(str(current.id)) current = node_by_id.get(parent_id) if parent_id else None return tuple(reversed(path)) # 同一组织名称会同时出现在“近三年类似项目业绩”和“类似业绩业主评价” # 等多个父节下,仅按标题文本建索引会把后出现的同父子标题误绑到先出现 # 的节点,导致 Step4 把未绑定的节点再插入一遍。这里按“父路径+标题” # 精确匹配,避免跨父节串绑。 path_nodes: Dict[Tuple[str, ...], List[object]] = {} for node in all_nodes: path_nodes.setdefault(_node_path(node), []).append(node) anchors: Dict[str, object] = {str(chapter.id): selected[0]} heading_stack: List[Tuple[int, str]] = [(1, _node_key(chapter))] for element in list(body): if element.tag != _w_tag("p"): continue level = _docx_paragraph_level(element, style_levels) if not level: continue text = _docx_xml_text(element) key = _artifact_heading_key(text) prefix = [item_key for item_level, item_key in heading_stack if item_level < level] node = next( ( candidate for candidate in path_nodes.get( tuple(prefix + [key]), [] ) if str(candidate.id) not in anchors ), None, ) if node is not None: anchors[str(node.id)] = element _replace_docx_paragraph_text(element, node.title) while heading_stack and heading_stack[-1][0] >= level: heading_stack.pop() heading_stack.append((level, key)) h1_text = ( f"第{_native_chapter_number(chapter)}章 {chapter.title}" if chapter.id.isdigit() else chapter.title ) _replace_docx_paragraph_text(anchors[str(chapter.id)], h1_text) def element_level(element) -> int: return ( _docx_paragraph_level(element, style_levels) if element.tag == _w_tag("p") else 0 ) def insert_children(parent: Chapter) -> None: parent_anchor = anchors[str(parent.id)] for child in parent.children or []: child_id = str(child.id) if child_id not in anchors: body_items = list(body) parent_pos = body_items.index(parent_anchor) final_sect_index = next( ( index for index, element in enumerate(body_items) if element.tag == _w_tag("sectPr") ), len(body_items), ) insert_at = final_sect_index for index in range(parent_pos + 1, final_sect_index): level = element_level(body_items[index]) if level and level <= int(parent.level or 1): insert_at = index break heading = _new_docx_paragraph( child.title, style_ids.get(max(2, min(int(child.level or 2), 4)), "Heading2"), ) body.insert(insert_at, heading) anchors[child_id] = heading insert_children(child) from step4_writing.difficulty_rewrite import MODE for node in all_nodes: if (node.content_generation_mode == MODE and (node.from_template or node.template_original_id) and str(node.id) not in anchors): raise ValueError(f"专项原生标题锚点缺失:{node.id}") insert_children(chapter) from step4_writing.difficulty_rewrite import remove_rewritten_prose remove_rewritten_prose( body, all_nodes, anchors, element_level, _docx_xml_text, _w_tag ) # 每个补充块放在该节点原生区域末尾、下一标题之前;图片/表格不移动。 for node in reversed(all_nodes): supplement = (node.supplement_content or "").strip() if ( not supplement or str(node.id) not in anchors or str(node.id) in commitment_placeholder_nodes ): continue body_items = list(body) anchor_pos = body_items.index(anchors[str(node.id)]) final_sect_index = next( ( index for index, element in enumerate(body_items) if element.tag == _w_tag("sectPr") ), len(body_items), ) insert_at = final_sect_index for index in range(anchor_pos + 1, final_sect_index): if element_level(body_items[index]): insert_at = index break paragraphs = [ _new_docx_paragraph(line) for line in supplement.splitlines() if line.strip() ] for paragraph in reversed(paragraphs): body.insert(insert_at, paragraph) document_out = ET.tostring( root, encoding="utf-8", xml_declaration=True ) # 章节包只携带本章 document.xml 实际引用的图片/嵌入对象。 # 其余 300+ 个模板媒体属于其他章节,复制会让每章都接近模板全量体积。 office_rel_ns = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships" ) package_rel_ns = ( "http://schemas.openxmlformats.org/package/2006/relationships" ) used_rel_ids = { value for element in root.iter() for attr, value in element.attrib.items() if attr.startswith(f"{{{office_rel_ns}}}") } document_rels_name = "word/_rels/document.xml.rels" document_rels_root = ET.fromstring( source_zip.read(document_rels_name) ) selected_package_targets = set() heavy_tokens = ("/image", "/chart", "/oleObject", "/package") for relationship in list(document_rels_root): rel_id = relationship.get("Id", "") rel_type = relationship.get("Type", "") target = relationship.get("Target", "") is_heavy = any(token in rel_type for token in heavy_tokens) if is_heavy and rel_id not in used_rel_ids: document_rels_root.remove(relationship) continue if is_heavy and target and not target.startswith(("http:", "https:")): selected_package_targets.add(posixpath.normpath( posixpath.join("word", target) )) # 页眉页脚可能通过自己的 rels 使用图片;这些关系与媒体一并保留。 for rel_info in source_zip.infolist(): name = rel_info.filename if not name.endswith(".rels") or name == document_rels_name: continue if not name.startswith("word/_rels/"): continue try: rel_root = ET.fromstring(source_zip.read(name)) except Exception: continue source_part = "word/" + name.rsplit("/", 1)[-1][:-5] for relationship in rel_root.findall( f"{{{package_rel_ns}}}Relationship" ): target = relationship.get("Target", "") if target and not target.startswith(("http:", "https:")): resolved = posixpath.normpath(posixpath.join( posixpath.dirname(source_part), target )) if resolved.startswith("word/media/"): selected_package_targets.add(resolved) document_rels_out = ET.tostring( document_rels_root, encoding="utf-8", xml_declaration=True ) os.makedirs(os.path.dirname(output_path), exist_ok=True) with zipfile.ZipFile( output_path, "w", compression=zipfile.ZIP_STORED ) as output_zip: for info in source_zip.infolist(): if ( info.filename.startswith("word/media/") and info.filename not in selected_package_targets ): continue if info.filename == "word/document.xml": data = document_out elif info.filename == document_rels_name: data = document_rels_out elif ( info.filename.startswith("word/header") or info.filename.startswith("word/footer") ) and info.filename.endswith(".xml"): try: data = _transform_header_footer_part( source_zip.read(info.filename), effective_map, header_project_name, ) except Exception as exc: logger.warning( f" [{chapter.id}] 页眉/页脚信息替换失败," f"保留模板原文: {exc}" ) data = source_zip.read(info.filename) else: data = source_zip.read(info.filename) output_zip.writestr( info.filename, data, compress_type=zipfile.ZIP_STORED ) return True except Exception as exc: from step4_writing.difficulty_rewrite import MODE if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)): raise RuntimeError("专项章节原生装配失败,禁止降级丢失受保护结构") from exc logger.warning( f" [{chapter.id}] 原生模板章节产物复制失败,使用结构化文本兜底: {exc}" ) return False def _save_chapter_docx( chapter: Chapter, chapter_dir: str, template_path: str = "", placeholder_map: Optional[Dict[str, str]] = None, company_info: Optional[CompanyInfo] = None, analysis: Optional[TenderAnalysis] = None, project_data: Optional[ProjectData] = None, ) -> str: """保存一个顶层章节 DOCX。 标题树严格采用 Step3 最终 Chapter 树;模板来源编号只用于找回原生 正文,新增评分章不裁取模板正文。该文件供 Step5 审核和 Step6 聚合。 """ import re from docx import Document as DocxDocument from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH os.makedirs(chapter_dir, exist_ok=True) safe_title = re.sub( r'[<>:"/\\|?*]+', "_", chapter.title, ).strip(" ._")[:40] or "章节" path = os.path.abspath( os.path.join(chapter_dir, f"{chapter.id}_{safe_title}.docx") ) if _save_native_template_chapter_docx( chapter, template_path, path, placeholder_map=placeholder_map, company_info=company_info, analysis=analysis, project_data=project_data, ): return path doc = DocxDocument() section = doc.sections[0] section.page_width = Cm(21.0) section.page_height = Cm(29.7) # 章标题是唯一 H1;样式用于让 Word 导航窗格与 Step3 目录一致。 title_para = doc.add_paragraph(style="Heading 1") title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER chapter_number = chapter.id if chapter.id.isdigit(): from step3_outlining.scoring_structure import _int_to_chinese chapter_number = _int_to_chinese(int(chapter.id)) display_title = ( f"第{chapter_number}章 {chapter.title}" if chapter.id.isdigit() else chapter.title ) title_run = title_para.add_run(display_title) title_run.bold = True title_run.font.size = Pt(18) if _is_deferred_evaluation_index(chapter): notice = doc.add_paragraph() notice.add_run( "本索引在全部章节合并、结构检查并稳定分页后," "依据 Step3 最终标题映射构建并填写实际页码。" ).font.size = Pt(12) if not _is_deferred_evaluation_index(chapter): buckets = _split_artifact_content_by_outline(chapter) _render_artifact_lines(doc, buckets.get(str(chapter.id), [])) if chapter.supplement_content.strip(): _render_artifact_lines(doc, chapter.supplement_content.splitlines()) for child in chapter.children or []: _render_artifact_node(doc, child, buckets) fallback_project_name = ( (placeholder_map or {}).get("项目名称") or (analysis.project_name if analysis is not None else "") ) _add_fallback_header_footer( doc, placeholder_map=placeholder_map, project_name=fallback_project_name, ) doc.save(path) if not os.path.isfile(path) or os.path.getsize(path) == 0: raise RuntimeError(f"章节 DOCX 保存失败: {path}") return path def _is_markdown_table_row(line: str) -> bool: """判断一行是否是 Markdown 表格行""" return line.strip().startswith("|") and line.strip().endswith("|") and line.count("|") >= 2 def _has_separator_row(lines: list, start_idx: int) -> bool: """检查下一行是否是表格分隔行(|---|---|)""" if start_idx >= len(lines): return False line = lines[start_idx].strip() return bool(re.match(r"^\|[\s\-:|]+\|$", line)) def _extract_table_block(lines: list, start_idx: int) -> tuple: """提取 Markdown 表格块 Returns: (table_lines, next_idx): 表格行列表和表格结束后的下一个行索引 """ table_lines = [] i = start_idx while i < len(lines): line = lines[i].strip() if _is_markdown_table_row(line): table_lines.append(line) i += 1 else: break return table_lines, i def _render_table_block(doc, table_lines: list) -> None: """将 Markdown 表格行列表渲染为 DOCX 表格""" from docx.shared import Pt if len(table_lines) < 2: return # 解析所有行 rows_data = [] for line in table_lines: cells = [c.strip() for c in line.strip().strip("|").split("|")] rows_data.append(cells) # 过滤分隔行(---|---) data_rows = [r for r in rows_data if not all(re.match(r"^[\-:\s]+$", c) for c in r)] if not data_rows: return num_cols = max(len(r) for r in data_rows) # 创建 DOCX 表格 table = doc.add_table(rows=len(data_rows), cols=num_cols, style="Table Grid") for row_idx, row_data in enumerate(data_rows): for col_idx in range(num_cols): cell = table.cell(row_idx, col_idx) cell_text = row_data[col_idx] if col_idx < len(row_data) else "" # 清空默认段落 for p in cell.paragraphs: p.clear() run = cell.paragraphs[0].add_run(cell_text) run.font.size = Pt(10) # 表头加粗 if row_idx == 0: run.bold = True # 表格后加空行 doc.add_paragraph() __all__ = [ "write_content", # V2 导出 "fill_all_tables", "resolve_all_placeholders", "clean_ai_commentary", "strip_markdown_headings", "_persist_all_chapter_docs", ]