""" 表/函填充器(V2 新增) 负责对投标模板中的表、函、表格进行智能填充。 核心策略:逻辑先行 → LLM 补充 → 逻辑校验 流程: 1. 逻辑层:填标准响应(15+ 常见资格项/实质性条款的标准应答) 2. LLM 层:补充非常规响应项 + 校验全部填充结果 3. 逻辑层:替换 %%占位符%% + 残留检查 迁移自 step4_writing/template_filler.py 的核心逻辑, 与 step6_exporting/table_filler.py 的 DOCX 操作能力结合。 """ from __future__ import annotations import logging import re from typing import Dict, List, Optional, Tuple from docx import Document as DocxDocument from llm_client import LLMClient from models import ( CompanyInfo, ExtractedTable, FormatReconciliation, ReconciliationDecision, TenderAnalysis, ) logger = logging.getLogger(__name__) # ============================================================ # 标准响应库(15+ 常见资格项/实质性条款) # ============================================================ # 映射:关键词 → 标准响应文本 STANDARD_RESPONSES: Dict[str, str] = { # ---- 资格条件 ---- "营业执照": "满足。我公司已提供合法有效的企业营业执照(统一社会信用代码:{unified_code}),详见投标文件对应章节。", "独立法人": "满足。我公司为依法注册的独立法人,具有独立承担民事责任的能力,已提供营业执照及相关证明材料。", "财务状况": "满足。我公司已提供经审计的财务报告,财务状况良好,资金充足,具备履行本项目的财务能力,详见投标文件对应章节。", "财务报告": "满足。我公司已提供{year}年度经审计的财务报告,详见投标文件对应章节。", "审计报告": "满足。我公司已提供经审计的财务审计报告,详见投标文件对应章节。", "税收": "满足。我公司依法缴纳税收,已提供税收完税凭证及纳税证明,详见投标文件对应章节。", "纳税": "满足。我公司依法纳税,已提供完税凭证,详见投标文件对应章节。", "社保": "满足。我公司依法为员工缴纳社会保障资金,已提供社会保险缴纳凭证,详见投标文件对应章节。", "社会保障": "满足。我公司依法为全体员工缴纳社会保障资金(含养老、医疗、失业、工伤、生育保险),已提供社保证明材料,详见投标文件对应章节。", "重大违法": "满足。我公司近三年在经营活动中无重大违法记录,已提供书面声明函,详见投标文件对应章节。", "违法记录": "满足。我公司近三年在经营活动中无重大违法记录,信誉良好,已提供书面声明。", "信用": "满足。我公司信用良好,未被列入失信被执行人、重大税收违法案件当事人名单及政府采购严重违法失信行为记录名单,已提供信用查询截图。", "失信": "满足。我公司未被列入失信被执行人名单,信用记录良好,已提供相关证明材料。", # ---- 实质性要求 ---- "联合体": "响应。我公司为独立投标,不组成联合体参与本项目。", "分包": "响应。我公司不将本项目分包或转包,全部服务由我公司独立承担。", "转包": "响应。我公司承诺不将本项目转包或违法分包。", "不接受": "完全响应招标文件要求。我公司严格按照招标文件规定执行。", "不得": "完全响应。我公司承诺严格遵守招标文件中的相关规定。", # ---- 资质认证 ---- "认证": "满足。我公司已通过ISO9001质量管理体系认证、ISO14001环境管理体系认证、ISO45001职业健康安全管理体系认证,认证证书详见投标文件对应章节。", "质量管理体系": "满足。我公司已通过ISO9001质量管理体系认证,建立了完善的质量管理体系,详见投标文件对应章节。", "环境管理体系": "满足。我公司已通过ISO14001环境管理体系认证,详见投标文件对应章节。", "职业健康安全": "满足。我公司已通过ISO45001职业健康安全管理体系认证,详见投标文件对应章节。", "物业资质": "满足。我公司持有物业管理企业一级资质证书,具备承接本项目的资质条件,详见投标文件对应章节。", # ---- 业绩相关 ---- "业绩": "满足。我公司近三年承接了多个类似物业管理服务项目,具有丰富的同类项目管理经验,业绩证明材料详见投标文件对应章节。", "类似项目": "满足。我公司近三年承接了多个类似项目,积累了丰富的服务经验,详见投标文件对应章节。", # ---- 人员相关 ---- "项目经理": "满足。我公司拟派项目经理持有物业管理师资格证书,具备丰富的项目管理经验,详见投标文件对应章节。", "人员配置": "满足。我公司按照招标文件要求配置服务人员,确保人员数量和资质满足项目需求,详见投标文件对应章节。", # ---- 设备相关 ---- "设备配置": "满足。我公司按照招标文件要求配置服务设备和机具,确保设备满足项目服务需求,详见投标文件对应章节。", # ---- 其他常见项 ---- "关联企业": "满足。我公司与本项目的其他投标人不存在关联关系,符合招标文件要求。", "控股关系": "满足。我公司与采购人、采购代理机构及其他投标人之间不存在控股或管理关系。", "行贿犯罪": "满足。我公司及其法定代表人近三年无行贿犯罪记录。", } # 年份占位符(动态填入当年的前一年;每次调用时计算, # 避免模块导入后跨年运行的进程输出过期年份) import datetime def _current_default_year() -> str: return str(datetime.datetime.now().year - 1) # ============================================================ # 逻辑层:标准响应生成 # ============================================================ def generate_standard_response( requirement_text: str, company_info: CompanyInfo, ) -> Optional[str]: """根据要求描述生成标准响应文本 在 STANDARD_RESPONSES 中按关键词匹配, 返回格式化的响应文本。 Args: requirement_text: 资格条件/实质性要求描述文本 company_info: 公司信息(用于填充模板变量) Returns: 标准响应文本,如果未匹配到则返回 None """ if not requirement_text.strip(): return None # 按优先级顺序匹配关键词(长的先匹配,避免短词误匹配) sorted_keywords = sorted( STANDARD_RESPONSES.keys(), key=lambda k: -len(k) # 长关键词优先 ) for keyword in sorted_keywords: if keyword in requirement_text: response = STANDARD_RESPONSES[keyword] # 替换模板变量 response = response.replace("{unified_code}", company_info.unified_code or "见营业执照") response = response.replace("{year}", _current_default_year()) return response return None def fill_qualification_response_cells( table_cells: List[List[str]], company_info: CompanyInfo, header_row_idx: int = 0, requirement_col: int = 0, response_col: int = 1, ) -> List[List[str]]: """逻辑填充资格/实质性要求响应表的响应列 遍历表格数据行,对每行的要求列内容生成标准响应, 填入响应列。 Args: table_cells: 表格数据(二维列表,含表头) company_info: 公司信息 header_row_idx: 表头行索引 requirement_col: 要求列索引 response_col: 响应列索引 Returns: 填充后的 table_cells(原地修改) """ filled_count = 0 unfilled_count = 0 for row_idx in range(header_row_idx + 1, len(table_cells)): row = table_cells[row_idx] if requirement_col >= len(row): continue requirement = row[requirement_col].strip() if not requirement: continue # 确保响应列存在 while len(row) <= response_col: row.append("") # 已有内容则跳过 existing = row[response_col].strip() if existing and len(existing) > 5: continue response = generate_standard_response(requirement, company_info) if response: row[response_col] = response filled_count += 1 else: unfilled_count += 1 if filled_count > 0 or unfilled_count > 0: logger.info( f" 逻辑填充: {filled_count} 项标准响应, " f"{unfilled_count} 项需 LLM 补充" ) return table_cells # ============================================================ # LLM 层:非常规项补充 + 全表校验 # ============================================================ # LLM 补充 prompt FILL_NONSTANDARD_PROMPT = """你是投标书表格填写专家。你的任务是为资格条件/实质性要求响应表中无法通过逻辑自动填充的条目生成专业响应。 ## 要求 1. 每个响应要具体、专业,明确说明公司如何满足该项要求 2. 使用正式投标语言,第一人称"我公司" 3. 明确标注证明材料所在的章节 4. 响应长度适中(30-100字) 5. 禁止输出"(内容待补充)""详见附件"等敷衍文本 ## 输出格式 对于每个未填充的条目,输出: 序号: 响应文本 每个条目一行,不输出无关内容。""" # LLM 校验 prompt VERIFY_FILLED_TABLE_PROMPT = """你是投标书审核专家。请检查以下投标响应表中的填充结果。 ## 检查项 1. 是否存在空白的响应格(必须全部有内容) 2. 是否存在敷衍文本(如"满足""详见附件"但没有详细说明) 3. 响应内容是否与要求一一对应 4. 是否存在逻辑矛盾或格式问题 ## 输出格式 ```json {"issues": [{"row": 行号, "col": 列名, "problem": "问题描述", "suggestion": "修改建议"}], "passed": true|false} ```""" def llm_fill_nonstandard_responses( table_cells: List[List[str]], unfilled_rows: List[Tuple[int, str]], company_info: CompanyInfo, section_name: str = "", ) -> Dict[int, str]: """使用 LLM 补充非常规响应的填充 Args: table_cells: 表格数据(含已逻辑填充的) unfilled_rows: [(row_idx, requirement_text), ...] 需要补充的行 company_info: 公司信息 section_name: 表格区域名称 Returns: {row_idx: response_text} 需要填入的响应 """ if not unfilled_rows: return {} # 构建 LLM 上下文 context_parts = [ f"表格名称: {section_name}", f"投标人: {company_info.full_name}", "", "以下条目需要生成响应文本:", ] for idx, (row_idx, requirement) in enumerate(unfilled_rows): context_parts.append(f"{idx + 1}. 要求: {requirement}") # 附加已填充的同行其他列作参考 if row_idx < len(table_cells): context_parts.append(f" 该行其他列: {' | '.join(table_cells[row_idx][:5])}") try: llm = LLMClient() raw = llm.generate( system_prompt=FILL_NONSTANDARD_PROMPT, user_prompt="\n".join(context_parts), max_tokens=32768, temperature=0.3, ) # 解析 LLM 输出:每行格式为 "序号: 响应文本" result: Dict[int, str] = {} for line in raw.strip().split("\n"): line = line.strip() # 匹配 "1: xxx" 或 "1. xxx" m = re.match(r"^(\d+)[::.]\s*(.+)", line) if m: llm_idx = int(m.group(1)) - 1 # LLM 输出是 1-based if llm_idx < len(unfilled_rows): row_idx = unfilled_rows[llm_idx][0] result[row_idx] = m.group(2).strip() logger.info(f" LLM 补充: {len(result)}/{len(unfilled_rows)} 项") return result except Exception as e: logger.warning(f"LLM 补充失败: {e}") return {} def llm_verify_table_filling( table_cells: List[List[str]], section_name: str = "", ) -> Tuple[bool, list]: """使用 LLM 校验表格填充质量 Args: table_cells: 填充后的表格数据 section_name: 表格区域名称 Returns: (passed, issues): 是否通过,问题列表 """ # 构建校验上下文 if not table_cells: return True, [] # 将表格转为 Markdown 格式供 LLM 审核 md_lines = [] for row in table_cells[:min(len(table_cells), 20)]: # 最多取前 20 行 md_lines.append("| " + " | ".join(cell[:30] for cell in row) + " |") table_md = "\n".join(md_lines) try: llm = LLMClient() result = llm.extract_json( system_prompt=VERIFY_FILLED_TABLE_PROMPT, user_prompt=f"表格名称: {section_name}\n\n请审核以下表格的填充质量:\n\n{table_md}", max_tokens=16384, ) if isinstance(result, dict): passed = result.get("passed", True) issues = result.get("issues", []) if issues: logger.info(f" LLM 校验: {'通过' if passed else '发现问题'}, " f"{len(issues)} 个问题") return passed, issues except Exception as e: logger.warning(f"LLM 校验失败: {e}") return True, [] # ============================================================ # 逻辑层:占位符替换 # ============================================================ def fill_table_placeholders( table_cells: List[List[str]], placeholder_map: Dict[str, str], ) -> int: """替换表格单元格中的 %%...%% 占位符 Args: table_cells: 表格数据(原地修改) placeholder_map: {键名: 值} Returns: 替换数量 """ placeholder_re = re.compile(r"%%(.+?)%%") count = 0 unfilled: List[str] = [] for row in table_cells: for col_idx, cell in enumerate(row): for match in placeholder_re.finditer(cell): key = match.group(1).strip() value = _resolve_placeholder(key, placeholder_map) if value: row[col_idx] = row[col_idx].replace(match.group(0), value) count += 1 else: unfilled.append(key) if unfilled: unique = list(dict.fromkeys(unfilled)) logger.warning( f" 未解析占位符 ({len(unique)} 个): " + ", ".join(f"%%{k}%%" for k in unique[:8]) ) return count def _resolve_placeholder(key: str, mapping: Dict[str, str]) -> str: """解析单个占位符(精确匹配 + 模糊匹配)""" key_clean = key.strip() # 精确匹配 if key_clean in mapping: return mapping[key_clean] # 模糊匹配 for mk, value in mapping.items(): if mk.replace(" ", "") in key_clean or key_clean in mk.replace(" ", ""): return value return "" # ============================================================ # 主入口:协同填充 # ============================================================ def fill_all_tables( doc: DocxDocument, reconciliations: List[FormatReconciliation], analysis: TenderAnalysis, company_info: CompanyInfo, placeholder_map: Optional[Dict[str, str]] = None, use_llm: bool = True, ) -> int: """对模板 DOCX 中的所有表格执行协同填充 根据 FormatReconciliation 的决策,对每个表格执行对应操作: - REPLACE_WITH_TENDER: 由 docx_builder 处理(此处跳过) - KEEP_TEMPLATE: 仅替换占位符 - KEEP_TEMPLATE_WITH_TENDER_DATA: 逻辑填充 + LLM 补充 + 占位符 Args: doc: 已加载的模板 DOCX(原地修改) reconciliations: 格式比对结果 analysis: 招标分析结果 company_info: 公司信息 placeholder_map: 占位符映射表(如未提供则从 analysis/company_info 构建) use_llm: 是否使用 LLM Returns: 填充的表格数量 """ if placeholder_map is None: from step4_writing.placeholder_engine import build_placeholder_map # build_placeholder_map 需要 project_data,我们不方便获取 # 降级:使用公司信息构建基础映射 placeholder_map = _build_basic_placeholder_map(analysis, company_info) filled_count = 0 for rec in reconciliations: if rec.decision == ReconciliationDecision.REPLACE_WITH_TENDER: # 替换操作由 docx_builder 执行 continue table_idx = rec.template_table_idx if table_idx < 0 or table_idx >= len(doc.tables): continue table = doc.tables[table_idx] if rec.decision == ReconciliationDecision.KEEP_TEMPLATE: # 仅替换占位符 count = _fill_table_placeholders_in_docx(table, placeholder_map) if count > 0: logger.debug( f" [{rec.reconciliation_id}] {rec.section_name}: " f"占位符替换 {count} 处" ) filled_count += 1 elif rec.decision == ReconciliationDecision.KEEP_TEMPLATE_WITH_TENDER_DATA: # 协同填充:逻辑 + LLM + 占位符 _synergistic_fill_table( doc, table, table_idx, rec, analysis, company_info, placeholder_map, use_llm, ) filled_count += 1 logger.info(f"表格填充完成: {filled_count} 个表格") return filled_count def _synergistic_fill_table( doc: DocxDocument, table, table_idx: int, rec: FormatReconciliation, analysis: TenderAnalysis, company_info: CompanyInfo, placeholder_map: Dict[str, str], use_llm: bool, ): """协同填充单个表格(逻辑 + LLM + 占位符) 三步协同: 1. 逻辑:读取表格数据 → 标准响应填充 2. LLM:补充非常规项 + 校验 3. 逻辑:替换占位符 """ # Step 1: 提取当前表格数据(二维文本) table_cells = _extract_table_text(table) if not table_cells: return header_row_idx = _find_header_row(table_cells) # 确定响应列(包含"响应""应答""说明""投标人"等关键词的列) response_col = _find_response_column(table_cells, header_row_idx) requirement_col = 0 # 要求通常在首列 # Step 2: 逻辑填充标准响应 table_cells = fill_qualification_response_cells( table_cells, company_info, header_row_idx=header_row_idx, requirement_col=requirement_col, response_col=response_col, ) # Step 3: LLM 补充非常规项 if use_llm: unfilled = [] for row_idx in range(header_row_idx + 1, len(table_cells)): row = table_cells[row_idx] if response_col < len(row) and not row[response_col].strip(): req = row[requirement_col].strip() if requirement_col < len(row) else "" if req: unfilled.append((row_idx, req)) if unfilled: llm_responses = llm_fill_nonstandard_responses( table_cells, unfilled, company_info, rec.section_name ) for row_idx, response in llm_responses.items(): if row_idx < len(table_cells) and response_col < len(table_cells[row_idx]): table_cells[row_idx][response_col] = response # LLM 校验 passed, issues = llm_verify_table_filling(table_cells, rec.section_name) if not passed: logger.warning( f" [{rec.reconciliation_id}] LLM 校验发现 " f"{len(issues)} 个问题" ) # Step 4: 占位符替换 placeholder_count = fill_table_placeholders(table_cells, placeholder_map) # Step 5: 写回 DOCX 表格 _write_table_text_back(table, table_cells) logger.info( f" [{rec.reconciliation_id}] {rec.section_name[:30]}: " f"协同填充完成" + (f" (含 {placeholder_count} 处占位符替换)" if placeholder_count else "") ) def _extract_table_text(table) -> List[List[str]]: """从 python-docx 表格提取纯文本矩阵""" cells = [] for row in table.rows: row_cells = [] for cell in row.cells: text = cell.text.strip() # 清理多余空白 text = re.sub(r"\s+", " ", text) row_cells.append(text) cells.append(row_cells) return cells def _find_header_row(table_cells: List[List[str]]) -> int: """查找表头行(第一个所有单元格都有内容或第一行)""" if not table_cells: return 0 # 简单策略:第一行就是表头 return 0 def _find_response_column( table_cells: List[List[str]], header_row_idx: int, ) -> int: """在表头中查找响应列""" if header_row_idx >= len(table_cells): return 1 # 默认第 2 列 header = table_cells[header_row_idx] for col_idx, cell in enumerate(header): if any(kw in cell for kw in ["响应", "应答", "说明", "投标人", "偏离"]): return col_idx # 降级:第二列 return min(1, len(header) - 1) if len(header) > 1 else 0 def _write_table_text_back(table, table_cells: List[List[str]]): """将填充后的文本矩阵写回 DOCX 表格(保留原始下划线等格式) 使用 set_cell_text_preserve_format 安全写回: - 原单元格有下划线格式 → 新文本保留下划线 - 原单元格无下划线 → 新文本不添加下划线 """ from step6_exporting.docx_utils import set_cell_text_preserve_format for row_idx, row_data in enumerate(table_cells): if row_idx >= len(table.rows): break row = table.rows[row_idx] for col_idx, text in enumerate(row_data): if col_idx >= len(row.cells): break cell = row.cells[col_idx] # 只更新有变化的单元格 current_text = cell.text.strip() if current_text != text and text: set_cell_text_preserve_format(cell, text) def _fill_table_placeholders_in_docx( table, placeholder_map: Dict[str, str], ) -> int: """在 DOCX 表格中替换占位符(原地修改) 类似 placeholder_engine.apply_placeholders 但仅作用于单个表格。 """ placeholder_re = re.compile(r"%%(.+?)%%") count = 0 for row in table.rows: for cell in row.cells: for para in cell.paragraphs: for run in para.runs: for match in placeholder_re.finditer(run.text): key = match.group(1).strip() value = _resolve_placeholder(key, placeholder_map) if value: if key in ("服务内容", "服务要求", "服务期限"): value = re.sub( r"[。;;]+$", "", value.strip() ) + "。" run.text = re.sub( r"。{2,}", "。", run.text.replace(match.group(0), value), ) count += 1 return count def _build_basic_placeholder_map( analysis: TenderAnalysis, company_info: CompanyInfo, ) -> Dict[str, str]: """构建基础占位符映射(从 analysis 和 company_info)""" mapping: Dict[str, str] = {} # 项目信息 if analysis.project_name: mapping["项目名称"] = analysis.project_name if analysis.buyer_name: mapping["采购人名称"] = analysis.buyer_name mapping["采购人"] = analysis.buyer_name # 公司信息 if company_info.full_name: mapping["公司名称"] = company_info.full_name mapping["公司全称"] = company_info.full_name if company_info.address: mapping["公司地址"] = company_info.address if company_info.phone: mapping["公司电话"] = company_info.phone if company_info.fax: mapping["公司传真"] = company_info.fax if company_info.bank_name: mapping["开户银行"] = company_info.bank_name if company_info.bank_account: mapping["银行账号"] = company_info.bank_account if company_info.legal_rep: mapping["法定代表人"] = company_info.legal_rep if company_info.unified_code: mapping["统一社会信用代码"] = company_info.unified_code return {k: v for k, v in mapping.items() if v} __all__ = [ "generate_standard_response", "fill_qualification_response_cells", "llm_fill_nonstandard_responses", "llm_verify_table_filling", "fill_table_placeholders", "fill_all_tables", ]