""" 数据模型模块 定义投书生成过程中使用的所有结构化数据类型。 使用 Pydantic 进行数据验证和序列化。 """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional, Tuple # ============================================================ # 文档读取结果 # ============================================================ @dataclass class ReferenceCase: """结构化参考案例,明确指定每个参考项目中各文件的具体路径 用于替代原先的目录扫描方式,减少自动检测的负担。 每个字段对应一个参考项目中的特定文件。 Keys: tender_file: 招标文件路径 (.pdf/.docx) procurement_file: 采购需求文件路径 (.pdf/.docx) clarification_file: 澄清公告文件路径 (.pdf/.docx) bid_file: 投书文件路径 (.pdf/.docx) """ tender_file: str = "" procurement_file: str = "" clarification_file: str = "" bid_file: str = "" @classmethod def from_dict(cls, d: dict) -> "ReferenceCase": """从字典创建,key 支持中文和英文两种格式""" key_map = { "招标文件": "tender_file", "采购需求": "procurement_file", "澄清公告": "clarification_file", "投书文件": "bid_file", } kwargs = {} for k, v in d.items(): field_name = key_map.get(k, k) if isinstance(v, str): kwargs[field_name] = v return cls(**kwargs) @dataclass class ParsedDocument: """解析后的文档""" file_path: str # 原始文件路径 file_name: str # 文件名 content: str # 文本内容 doc_type: str = "unknown" # tender / procurement / reference_bid / general_info / clarification metadata: Dict[str, Any] = field(default_factory=dict) @dataclass class ProjectData: """一个投标项目的数据合集""" project_id: str # 如 "1159" project_name: str # 如 "上海理工大学" data_dir: str = "" # 数据目录路径(可选,显式传文件时无需指定) # 各类型文档 tender_docs: List[ParsedDocument] = field(default_factory=list) # 招标文件 procurement_docs: List[ParsedDocument] = field(default_factory=list) # 采购需求 clarification_docs: List[ParsedDocument] = field(default_factory=list) # 澄清公告 reference_bids: List[ParsedDocument] = field(default_factory=list) # 参考标书 general_materials: List[ParsedDocument] = field(default_factory=list) # 通用资料 # Step 1 提取的表/函/表格项目 extracted_items: List["ExtractedItem"] = field(default_factory=list) # 参考投书中提取的表格(招标文件/模板均无对应表时的兜底来源) reference_tables: List["ExtractedTable"] = field(default_factory=list) # 分包信息 packages: List["PackageInfo"] = field(default_factory=list) # V2: 扩展元数据(存储格式比对结果等中间产物) metadata: Dict[str, Any] = field(default_factory=dict) # ============================================================ # 招标分析结果 # ============================================================ @dataclass class ScoringCriterion: """评分细则条目""" id: str # 序号 category: str # 评分大类(如"技术方案"、"人员配置") name: str # 评分项名称 description: str # 详细说明 max_score: float # 满分 weight: Optional[float] = None # 权重(如有) section_ref: str = "" # 在招标文件中的章节位置 @dataclass class RejectionItem: """废标项(投标无效条款)""" id: str # 序号 description: str # 废标条件描述 category: str = "" # 类别(资格条件/符合性检查等) @dataclass class ResponseRequirement: """响应文件清单要求""" id: str category: str # "商务" / "技术" name: str # 条目名称 description: str # 要求描述 required: bool = True # 是否必须提供 template_ref: str = "" # 模板参考(如有指定模板) @dataclass class DocumentTemplate: """招标文件中指定的文档模板""" name: str # 模板名称(如"投标函"、"法定代表人授权书") content: str # 模板内容 page_ref: str = "" # 在招标文件中的页码 @dataclass class PackageInfo: """包件信息""" package_id: str # 包件编号(如"包件一") name: str # 包件名称 description: str = "" # 描述 requirements: str = "" # 特殊要求 @dataclass class TenderAnalysis: """招标文件分析结果""" project_name: str # 项目名称 tender_project_id: str = "" # 招标编号/项目编号(从招标文件提取) buyer_name: str = "" # 采购人名称 packages: List[PackageInfo] = field(default_factory=list) # 核心分析结果 scoring_criteria: List[ScoringCriterion] = field(default_factory=list) # 评分细则 rejection_items: List[RejectionItem] = field(default_factory=list) # 废标项 response_requirements: List[ResponseRequirement] = field(default_factory=list) # 响应清单 templates: List[DocumentTemplate] = field(default_factory=list) # 模板 # 更多招标信息 agency_name: str = "" # 采购代理机构名称 # 供需分析 service_requirements: str = "" # 服务需求概述 qualification_requirements: str = "" # 资格要求概述 procurement_requirements: str = "" # 采购需求关键要求(结构化摘要) # 可直接填入投标模板普通文本/表格单元格的项目字段。键使用模板可读名称, # 如“服务内容”“服务要求”“服务期限”;禁止把 %%表名%% 整表授权放入此映射。 project_fields: Dict[str, str] = field(default_factory=dict) # 招标表格(ExtractedTable = 单元格级,保留合并信息和格式) tender_tables: List[ExtractedTable] = field(default_factory=list) raw_text_snippets: Dict[str, str] = field(default_factory=dict) # 关键原文片段 # ============================================================ # 目录与内容 # ============================================================ class ChapterType(Enum): """章节类型""" BUSINESS = "business" # 商务标 TECHNICAL = "technical" # 技术标 APPENDIX = "appendix" # 附件 @dataclass class Chapter: """投书章节""" id: str # 章节编号(如 "1", "2.1", "3.1.2") title: str # 章节标题 chapter_type: ChapterType = ChapterType.TECHNICAL level: int = 1 # 层级(1=章, 2=节, 3=小节) description: str = "" # 内容描述/写作要求 word_count_target: int = 0 # 目标字数 children: List[Chapter] = field(default_factory=list) # 子章节 # 关联的评分项/要求/废标项 related_criteria: List[str] = field(default_factory=list) related_requirements: List[str] = field(default_factory=list) related_rejections: List[str] = field(default_factory=list) # 依赖标记(V2 并发撰写) depends_on_all: bool = False # True = 需等所有其他章节完成后再撰写(如索引表) # 子节是否来自模板骨架(True = 保留模板结构,LLM 只改内容不改子节) from_template: bool = False # 顶层 generated_content 是否明确由模板章节原文直接载入。 # True/False 由 Step 4 写入;None 表示旧缓存未知,Step 6 才使用有界兜底判断。 preserve_template_layout: Optional[bool] = None # 子节是否属于附件区(附:/附件 之后的制度内容,不参与目录、按正文处理) is_attachment: bool = False # 确定性业务规则锁定的标题树(如评分项结构);LLM 只能填写正文,不能改标题。 structure_locked: bool = False # 评分小项经 LLM 判定由本标题本身完整覆盖。Step6 只向该现有标题正文补充响应, # 不再为这些评分项创建同名或近义子标题。 direct_scoring_criteria: List[str] = field(default_factory=list) # Step3 将一个复合评分项拆成多个可写作维度时,保存 # "SC-xx#n" 绑定。direct_scoring_criteria 仍保留评分 ID, # 供 Step4-Step6 现有逻辑兼容使用。 direct_scoring_bindings: List[str] = field(default_factory=list) # 废标项的唯一直接落点。父章的 related_rejections 仅表示上下文相关, # Step4-Step6 必须使用本字段定位实际承诺正文,禁止重新猜测。 direct_rejection_bindings: List[str] = field(default_factory=list) # Step3/Step4 之间的显式写作协议。复用模板标题后即使展示名 # 改为评分小类,仍可通过 template_original_title 追溯原标题。 template_original_title: str = "" # 评分/废标增强前的模板节点 ID。最终大纲重编号后仍保留,用于 # 将新旧目录节点一一关联。 template_original_id: str = "" content_generation_mode: str = "" # template_project / template_project_scoring / project_scoring # Step4 块式生成协议:模板基块先完成项目/表格填充计划,再追加评分或废标补充块。 # 原生表格、图片、签名等仍由 Step6 在模板 XML 上执行,Step4 不重建这些块。 template_fill_completed: bool = False content_blocks: List[Dict[str, object]] = field(default_factory=list) supplement_content: str = "" # 仅标记“评分大类无法匹配模板、因此获准新增”的一级章。不能用 # structure_locked/related_criteria 间接推断,因为模板原有商务章也可能 # 因绑定评分项而同时具备这两个字段。 is_scoring_inserted: bool = False # 生成后的内容 generated_content: str = "" # Step 4 完整生成后落盘的独立章节 DOCX;Step 6 拼接前必须存在 artifact_path: str = "" # 引用来源(用于追踪) source_refs: List[str] = field(default_factory=list) # Step 3 建立并持久化的“章节 ↔ 依据来源”映射(Step 4 直接复用,避免重复检索) template_section: str = "" # 模板中匹配到的区域名 template_chapter_id: str = "" # 插入评分章前的模板原章号 tender_snippets: List[str] = field(default_factory=list) # 招标文件相关原文片段 reference_snippets: List[str] = field(default_factory=list) # 参考投标书相关原文片段 @dataclass class BidOutline: """投书目录结构""" project_name: str chapters: List[Chapter] = field(default_factory=list) total_word_count_target: int = 170000 part_labels: List[Tuple[str, int]] = field(default_factory=list) # Step3 加入评分项/废标项之前的正式模板 Heading 1-4 快照。 # 用于生成可读对照报告及进入 Step4 前的结构门禁,不参与后续写作。 template_chapters: List[Chapter] = field(default_factory=list) # Step3 批量 LLM 拆分并经程序校验后的评分维度。key 为 # SC-xx#n,value 保存 criterion_id/title/requirement,供报告、 # Step4 写作和 Step5-Step6 闭环共用,禁止各阶段重新拆分。 scoring_dimensions: Dict[str, Dict[str, str]] = field(default_factory=dict) # Step3 最终节点与模板原节点的显式关系表。每项保存最终/模板节点 ID、 # 父节点、关系类型、生成模式以及评分/废标直接绑定,供 Step4-Step6 共用。 heading_mappings: List[Dict[str, object]] = field(default_factory=list) # “与评标有关的投标文件主要内容索引表”的持久化数据源。 # 每条评分维度和废标项均指向最终标题及模板来源,后续不得重新拆分或猜测。 evaluation_index_entries: List[Dict[str, object]] = field(default_factory=list) # part_labels 示例:[("商务部分", 0), ("技术部分", 2)] # 表示 chapters[0]~chapters[1] 属于"商务部分",chapters[2]~end 属于"技术部分" def flatten(self) -> List[Chapter]: """展平所有章节(含子章节)""" result = [] for ch in self.chapters: result.append(ch) result.extend(_flatten_children(ch)) return result def business_chapters(self) -> List[Chapter]: """获取商务标章节(前2章)""" return [ch for ch in self.chapters if ch.chapter_type == ChapterType.BUSINESS] def technical_chapters(self) -> List[Chapter]: """获取技术标章节""" return [ch for ch in self.chapters if ch.chapter_type == ChapterType.TECHNICAL] def _flatten_children(chapter: Chapter) -> List[Chapter]: """递归展平子章节""" result = [] for child in chapter.children: result.append(child) result.extend(_flatten_children(child)) return result # ============================================================ # 审核结果 # ============================================================ @dataclass class ReviewIssue: """审核发现的问题""" chapter_id: str severity: str # "error" / "warning" / "suggestion" issue_type: str # "missing_criterion" / "rejection_involved" / "word_count" / "format" / "style" description: str suggestion: str = "" @dataclass class ReviewReport: """审核报告""" passed: bool = True issues: List[ReviewIssue] = field(default_factory=list) total_word_count: int = 0 missing_criteria: List[str] = field(default_factory=list) rejection_violations: List[str] = field(default_factory=list) template_missing: List[str] = field(default_factory=list) # ============================================================ # 模板解析结果 # ============================================================ @dataclass class TemplatePlaceholder: """模板中的占位符""" key: str # 占位符键名(如 "项目名称") full_text: str # 完整占位符文本(如 "%%项目名称%%") location: str = "paragraph" # "paragraph" | "table_cell" paragraph_idx: int = -1 # 所在段落索引 run_idx: int = -1 # 所在 run 索引(paragraph 模式) @dataclass class TemplateTable: """模板中的可填充表格""" table_idx: int # 表格在 docx 中的索引 caption: str = "" # 表格标题/说明 headers: List[str] = field(default_factory=list) # 表头 row_count: int = 0 # 总行数 fillable: bool = False # 是否需要填充 fill_strategy: str = "" # "replace_placeholder" | "fill_from_material" | "llm_row_by_row" @dataclass class TemplateSection: """模板中的一个可编辑区域""" name: str # 区域名(如"资格条件响应表"、"投标函") start_para: int = -1 # 起始段落索引 end_para: int = -1 # 结束段落索引 section_type: str = "" # "placeholder" | "table_form" | "static_text" | "prose" placeholders: List[TemplatePlaceholder] = field(default_factory=list) tables: List[TemplateTable] = field(default_factory=list) @dataclass class TemplateStructure: """模板完整结构""" file_path: str = "" sections: List[TemplateSection] = field(default_factory=list) total_paragraphs: int = 0 total_tables: int = 0 # ============================================================ # V2 新模型:表格提取 & 格式比对 # ============================================================ @dataclass class TableCell: """单个表格单元格(含合并信息)""" text: str = "" # 单元格文本内容 row: int = 0 # 行索引(0-based) col: int = 0 # 列索引(0-based) rowspan: int = 1 # 跨行数 colspan: int = 1 # 跨列数 is_merged_origin: bool = False # 是否是合并单元格的起始格 is_merged_continuation: bool = False # 是否是被合并覆盖的格(内容为空是正常的) font_size: Optional[float] = None # 字号(从 DOCX 提取时有值) bold: bool = False # 是否加粗 alignment: str = "" # "left" / "center" / "right" @dataclass class ExtractedTable: """从 PDF 或 DOCX 中提取的表格(单元格级数据) 特性: - 保留合并单元格信息(rowspan/colspan) - 保留原始单元格格式(字号、加粗、对齐) - 标记占位符(从模板 DOCX 提取时) - 可双向溯源(来源页/段落索引) """ table_id: str = "" # 唯一标识 source_file: str = "" # 来源文件路径 source_type: str = "" # "tender_pdf" | "template_docx" | "reference_docx" page_num: int = 0 # PDF 页码(0-based) para_idx: int = -1 # DOCX 段落索引(模板/参考文件) rows: int = 0 cols: int = 0 cells: List[List[TableCell]] = field(default_factory=list) # cells[row][col] caption: str = "" # 表格标题/前导文字 title_hint: str = "" # 表格上方标题(如"5.实质性要求响应表"),用于分类 header_fields: List[str] = field(default_factory=list) # 表头与标题间的项目信息行("项目名称:"等) postamble: str = "" # 表格下方紧邻的说明/备注原文 col_widths: List[float] = field(default_factory=list) # 每列宽度(相对比例,与原始表格一致) has_placeholders: bool = False # �Ƿ���� %%...%% ռλ�� table_type: str = "" # scoring / qualification_response / bid_form / reference_format / other is_fragment: bool = False # 是否是需要整体保留的“文字+多张表”片段 fragment_xml: List[bytes] = field(default_factory=list) # 片段内正文段/表格的 OOXML 字节 fragment_docx_path: str = "" # 片段单独导出的 DOCX 路径 fragment_source_indices: List[int] = field(default_factory=list) # 组成片段的物理表序号 artifact_path: str = "" # Step1 独立提取 DOCX;Step6 用于原样复制格式 @dataclass class ExtractedItem: """Step 1 统一提取结果:一个"表/函/表格"项 同时包含 LLM 识别信息(名称、类型、内容)和结构化表格数据(如有)。 """ name: str # 项名称(如"资格条件响应表"、"投标函") item_type: str # "表" / "函" / "表格" source_file: str = "" # 来源文件路径 pages: List[int] = field(default_factory=list) # 涉及的页码 preamble: str = "" # 【仅表/表格】表格前说明文字 postamble: str = "" # 【仅表/表格】表格后说明/备注文字 content: str = "" # 【仅函】完整函件文本 headers: List[str] = field(default_factory=list) # 【仅表/表格】列标题 rows: List[List[str]] = field(default_factory=list) # 【仅表/表格】数据行 source_table: Optional[ExtractedTable] = None # 关联的结构化表格数据 artifact_path: str = "" # Step1 写出的独立 DOCX 产物路径 @dataclass class SkeletonParagraph: """参考投标文件中的骨架段落(通过 paragraph diff 识别) 用于理解模板 DOCX 的标题层级——模板中的标题没有 Word Heading 样式, 通过参考文件中 ≥75% 相似的段落来推断该段落的层级角色。 """ para_idx: int # 在参考文件中的段落索引 text: str # 段落文本 similarity: float # 与模板段落的相似度(0.0-1.0) inferred_level: int = 0 # 推断的标题层级(0=正文, 1=章, 2=节, 3=小节) font_size: Optional[float] = None # 字号 bold: bool = False # 是否加粗 is_heading: bool = False # 是否是标题段落 class ReconciliationDecision(Enum): """格式比对决策""" REPLACE_WITH_TENDER = "replace_with_tender" """用招标文件中的格式/表格完整替换模板中的对应表格""" KEEP_TEMPLATE = "keep_template" """保留模板表格,仅填充占位符""" KEEP_TEMPLATE_WITH_TENDER_DATA = "keep_template_with_tender_data" """保留模板表格结构,但用招标文件的数据填充内容""" APPEND_AS_NEW = "append_as_new" """招标文件中有、模板中没有的表格/函件,追加到文档""" @dataclass class FormatReconciliation: """格式比对结果:一个模板表格/函件与招标要求的比对结论""" reconciliation_id: str = "" # FR-01, FR-02, ... section_name: str = "" # 模板区域名称(如"资格条件响应表") section_type: str = "" # "table" | "form_letter" | "static_text" # 模板侧 template_table_idx: int = -1 # 模板 DOCX 中的表格索引 template_table: Optional[ExtractedTable] = None # 模板表格数据 # 招标文件侧 tender_tables: List[ExtractedTable] = field(default_factory=list) # 匹配的招标表格 best_match: Optional[ExtractedTable] = None # 最佳匹配的招标表格 similarity_score: float = 0.0 # Jaccard 或其他相似度分数 # 决策 decision: ReconciliationDecision = ReconciliationDecision.KEEP_TEMPLATE decision_reason: str = "" # 决策理由(含 LLM 判断结果) # 执行信息 replace_with_tender_table: Optional[ExtractedTable] = None # 替换时使用的招标表格 fill_data: Dict[str, str] = field(default_factory=dict) # 填充时使用的数据映射 llm_judgment: str = "" # LLM 对格式差异的判断原文 # 标记 is_confirmed: bool = False # 是否已经 LLM 确认 needs_manual_review: bool = False # 是否需要人工审核 @dataclass class ReconciliationReport: """格式比对汇总报告""" total_template_tables: int = 0 total_tender_tables: int = 0 reconciliations: List[FormatReconciliation] = field(default_factory=list) replace_count: int = 0 # 需要替换的表格数 keep_count: int = 0 # 保留模板的表格数 append_count: int = 0 # 需要追加的表格数 unpaired_tender_tables: List[ExtractedTable] = field(default_factory=list) # 招标文件中未配对的表格 summary: str = "" # LLM 生成的比对总结 # ============================================================ # 公司信息 # ============================================================ @dataclass class CompanyInfo: """结构化公司基本信息(从通用资料中提取)""" full_name: str = "" # 公司全称 short_name: str = "" # 公司简称 address: str = "" phone: str = "" fax: str = "" zip_code: str = "" bank_name: str = "" bank_account: str = "" legal_rep: str = "" # 法定代表人姓名 legal_rep_id: str = "" # 法定代表人身份证号 registration_capital: str = "" unified_code: str = "" # 统一社会信用代码 website: str = "" # ============================================================ # 工作流状态 # ============================================================ @dataclass class AgentState: """LangGraph 工作流的状态对象""" # 输入(二选一:project_dir 传统模式,或五类显式参数) project_dir: str = "" # 项目数据目录路径 project_id: str = "" # 项目编号 project_name: str = "" # 项目名称 package_ids: List[str] = field(default_factory=list) # 指定的包件(空=全部) # 五类显式参数(优先级高于 project_dir) tender_files: List[str] = field(default_factory=list) procurement_files: List[str] = field(default_factory=list) clarification_files: List[str] = field(default_factory=list) reference_bid_dirs: List[str] = field(default_factory=list) # 向后兼容,旧版目录模式 reference_bids: List[ReferenceCase] = field(default_factory=list) # 新版结构化参考案例 company_info_dirs: List[str] = field(default_factory=list) # 中间结果 project_data: Optional[ProjectData] = None tender_analysis: Optional[TenderAnalysis] = None outline: Optional[BidOutline] = None review_report: Optional[ReviewReport] = None # 输出 output_path: str = "" # 生成的 DOCX 文件路径 error: str = "" # 错误信息 # V2 新字段 template_path: str = "" # 投标模板 DOCX 路径 extracted_tables: Dict[str, Any] = field(default_factory=dict) # 见 step1_parsing/table_extractor reconciliation_report: Optional[Any] = None # FormatReconciliation 报告(延迟导入) heading_levels: Dict[int, int] = field(default_factory=dict) # para_idx → heading_level # 工作流控制 current_step: str = "" # 当前执行步骤 max_review_iterations: int = 2 # 最大审核迭代次数 review_iteration: int = 0 # 当前审核轮次