Forráskód Böngészése

feat(bid_proposal): AI驱动的投标书生成系统

4人Agent团队(manager, a1, a2, test)协作完成:
- manager: 架构设计(ARCHITECTURE.md)
- a1: bid_reader + requirement_analyzer(招标读取+AI需求分析)
- a2: proposal_generator + proposal_writer + pipeline(投标生成+DOCX输出)
- test: 验收测试(TEST_REPORT.md),发现并修复4个Bug

Co-Authored-By: Claude <noreply@anthropic.com>
walson.wang 4 hónapja
szülő
commit
41e5a98704

+ 563 - 0
bid_proposal/ARCHITECTURE.md

@@ -0,0 +1,563 @@
+# Proposa AI — 投标文件生成系统架构设计
+
+> 版本: 1.0
+> 日期: 2026-07-07
+> 状态: 定稿
+
+---
+
+## 1. 概述
+
+### 1.1 目标
+
+构建 `bid_proposal` 包,实现"根据招标文件自动生成投标文件"的核心功能。系统读取招标 PDF,分析其中的评审标准、服务要求、资质要求等,逐项生成对应的投标响应内容,最终输出为格式化 DOCX 投标文件。
+
+### 1.2 设计原则
+
+- **复用优先**:充分利用现有的 `pdf_table_to_docx` 包的表格提取能力,不重复造轮子。
+- **模块隔离**:读取、分析、生成、输出四个阶段严格分离,每个阶段可独立测试和替换。
+- **AI驱动**:需求分析和内容生成依赖 OpenAI API,设计上支持模型可配。
+- **渐进式处理**:处理流程可分段执行(读取→分析→生成→输出),中间结果可缓存检查。
+
+### 1.3 与 `pdf_table_to_docx` 的关系
+
+```
+bid_proposal  (新包)
+    │
+    ├── 依赖 pdf_table_to_docx.extractor.PDFTableExtractor 用于表格提取
+    ├── 依赖 pymupdf (已安装) 用于文本提取
+    └── 依赖 python-docx (已安装) 用于 DOCX 输出
+```
+
+---
+
+## 2. 整体架构
+
+### 2.1 模块划分
+
+```
+bid_proposal/
+├── __init__.py                 # 包入口
+├── models.py                   # 【共享】所有数据类定义(A1 编写,A2 引用)
+├── config.py                   # 【共享】配置管理(A1 编写,A2 引用)
+│
+├── bid_reader.py               # 【A1】招标文件读取
+├── requirement_analyzer.py     # 【A1】需求分析(OpenAI)
+│
+├── proposal_generator.py       # 【A2】投标内容生成(OpenAI)
+├── proposal_writer.py          # 【A2】投标文件 DOCX 输出
+├── pipeline.py                 # 【A2】流程编排
+├── cli.py                      # 【A2】命令行入口
+├── __main__.py                 # 【A2】python -m 支持
+│
+└── tests/                      # 测试
+    ├── test_a1.py
+    └── test_a2.py
+```
+
+### 2.2 数据流
+
+```
+招标PDF文件
+    │
+    ▼
+┌──────────────┐
+│  bid_reader  │  pymupdf → 文本内容
+│              │  pdf_table_to_docx → 表格内容
+└──────┬───────┘
+       │ BidDocument (text + tables + pages)
+       ▼
+┌──────────────┐
+│ requirement  │  OpenAI API → 需求结构化分析
+│ _analyzer    │  识别评审项 / 服务要求 / 资质要求
+└──────┬───────┘
+       │ AnalysisResult (requirements + summary)
+       ▼
+┌──────────────┐
+│  proposal    │  OpenAI API → 逐项生成投标响应
+│  _generator  │  根据需求内容 + 评分标准生成
+└──────┬───────┘
+       │ ProposalDocument (title + sections)
+       ▼
+┌──────────────┐
+│  proposal    │  python-docx → 格式化DOCX
+│  _writer     │  封面 / 目录 / 标题 / 正文 / 表格
+└──────┬───────┘
+       │
+       ▼
+投标文件 DOCX
+```
+
+### 2.3 模块职责
+
+| 模块 | 职责 | 外部依赖 |
+|------|------|---------|
+| `models.py` | 所有数据类定义 | — |
+| `config.py` | 配置读取(API Key、模型名等) | — |
+| `bid_reader.py` | 读取招标PDF,提取文本+表格 | pymupdf, pdf_table_to_docx |
+| `requirement_analyzer.py` | 分析招标要求,结构化输出 | OpenAI API |
+| `proposal_generator.py` | 根据需求生成投标响应内容 | OpenAI API |
+| `proposal_writer.py` | 将投标内容输出为格式化DOCX | python-docx |
+| `pipeline.py` | 编排完整流程,处理异常和日志 | 以上所有模块 |
+| `cli.py` | 命令行入口 | pipeline |
+
+---
+
+## 3. 数据结构设计 (`models.py`)
+
+### 3.1 数据类总览
+
+```python
+from dataclasses import dataclass, field
+from typing import Optional
+from pdf_table_to_docx.table_parser import TableInfo
+
+
+@dataclass
+class BidPage:
+    """招标文件中的一页"""
+    page_num: int           # 页码(1-indexed)
+    text: str               # 页面文本内容
+
+
+@dataclass
+class BidDocument:
+    """招标文件结构化内容"""
+    pdf_path: str                       # PDF 文件路径
+    file_name: str                      # 文件名
+    total_pages: int                    # 总页数
+    pages: list[BidPage]               # 所有页面文本
+    full_text: str                      # 全文拼接文本(供 AI 分析使用)
+    tables: list[TableInfo]            # 从 PDF 提取的表格
+    table_count: int                    # 表格数量
+
+
+@dataclass
+class Requirement:
+    """单个招标要求项"""
+    category: str                       # 类别(evaluation/service/qualification/other)
+    title: str                          # 要求标题
+    description: str                    # 详细描述
+    score: Optional[float] = None       # 分值(评分项专用)
+    detail: str = ""                    # 评分标准详细说明
+
+
+@dataclass
+class AnalysisResult:
+    """招标文件分析结果"""
+    summary: str                                    # 招标概要
+    project_name: str = ""                          # 项目名称
+    evaluation_criteria: list[Requirement] = field(default_factory=list)   # 评审标准
+    service_requirements: list[Requirement] = field(default_factory=list)  # 服务要求
+    qualification_requirements: list[Requirement] = field(default_factory=list)  # 资质要求
+    other_requirements: list[Requirement] = field(default_factory=list)    # 其他要求
+    raw_response: str = ""                          # AI 原始响应(调试用)
+
+
+@dataclass
+class ProposalSection:
+    """投标文件的一个章节"""
+    title: str                          # 章节标题
+    level: int = 1                      # 层级(1=一级标题, 2=二级标题)
+    content: str = ""                   # 章节正文
+    requirement_ref: str = ""           # 对应的招标要求引用
+
+
+@dataclass
+class ProposalDocument:
+    """完整的投标文件内容"""
+    title: str                          # 文档标题
+    project_name: str = ""              # 项目名称
+    sections: list[ProposalSection] = field(default_factory=list)  # 所有章节
+    summary: str = ""                   # 投标概要
+```
+
+### 3.2 分类体系
+
+`Requirement.category` 使用以下四类:
+
+| 分类值 | 说明 | 示例 |
+|--------|------|------|
+| `evaluation` | 评审标准/评分项 | "技术方案(30分)"、"服务团队(20分)" |
+| `service` | 服务要求/技术需求 | "物业管理服务范围"、"人员配置要求" |
+| `qualification` | 资质要求/资格条件 | "具有ISO9001认证"、"注册资本≥500万" |
+| `other` | 其他 | 投标须知、文件格式要求等 |
+
+---
+
+## 4. 模块详细设计
+
+### 4.1 `bid_reader.py` — 招标文件读取
+
+**职责**:
+- 使用 **pymupdf (fitz)** 从 PDF 中提取纯文本内容(逐页提取,保留段落结构)
+- 使用 **pdf_table_to_docx.extractor.PDFTableExtractor** 提取所有表格
+- 输出结构化的 `BidDocument` 对象
+
+**关键设计决策**:
+- 文本提取使用 pymupdf 的 `page.get_text("text")`,对中文 PDF 提取质量较好
+- 表格提取完全复用 `PDFTableExtractor`(含跨页表格合并逻辑)
+- `full_text` 为所有页面文本拼接(供 AI 分析的完整上下文)
+
+**接口定义**:
+
+```python
+def read_bid_pdf(pdf_path: str) -> BidDocument:
+    """读取招标 PDF,提取文本和表格。
+    
+    Args:
+        pdf_path: 招标文件 PDF 路径
+    
+    Returns:
+        BidDocument: 结构化招标内容
+    
+    Raises:
+        FileNotFoundError: PDF 文件不存在
+        BidReadError: PDF 读取/解析失败(如文件损坏、加密等)
+    """
+```
+
+### 4.2 `requirement_analyzer.py` — 需求分析
+
+**职责**:
+- 接收 `BidDocument`,使用 OpenAI API 分析其中的评审要求
+- 识别并分类四类需求:评审标准、服务要求、资质要求、其他
+- 提取关键信息:需求标题、详细描述、分值(如有)
+
+**关键设计决策**:
+- 使用 OpenAI Chat Completions API,通过 system prompt 指导输出 JSON 格式
+- 输出结构化的 `AnalysisResult`
+- 对长文档可进行章节拆分处理,避免超过 token 限制
+- API 调用内置重试(指数退避,最多 3 次)
+
+**接口定义**:
+
+```python
+class RequirementAnalyzer:
+    """招标文件需求分析器。"""
+    
+    def __init__(self, api_key: Optional[str] = None, model: str = "gpt-4o"):
+        """初始化分析器。
+        
+        Args:
+            api_key: OpenAI API Key,默认从 OPENAI_API_KEY 环境变量读取
+            model: 使用的模型名称
+        """
+
+    def analyze(self, doc: BidDocument) -> AnalysisResult:
+        """对招标文件进行 AI 分析,提取所有要求。
+        
+        Args:
+            doc: 招标文件结构化内容
+        
+        Returns:
+            AnalysisResult: 分析结果(结构化需求列表 + 摘要)
+        
+        Raises:
+            AnalysisError: AI 分析失败(API 错误、响应格式异常等)
+        """
+```
+
+### 4.3 `proposal_generator.py` — 投标内容生成
+
+**职责**:
+- 接收 `AnalysisResult`,逐项生成投标响应内容
+- 根据需求类型和评分标准,生成有针对性的响应
+- 输出结构化的 `ProposalDocument`
+
+**关键设计决策**:
+- 按需求分类组织章节结构(评审响应、服务方案、资质证明等)
+- 每个需求独立生成,system prompt 中包含需求详情和评分标准
+- 生成内容附带章节标题,供后续 writer 组织文档结构
+
+**接口定义**:
+
+```python
+class ProposalGenerator:
+    """投标内容生成器。"""
+    
+    def __init__(self, api_key: Optional[str] = None, model: str = "gpt-4o"):
+        """初始化生成器。
+        
+        Args:
+            api_key: OpenAI API Key,默认从 OPENAI_API_KEY 环境变量读取
+            model: 使用的模型名称
+        """
+
+    def generate(self, analysis: AnalysisResult) -> ProposalDocument:
+        """根据招标要求生成投标内容。
+        
+        Args:
+            analysis: 招标分析结果
+        
+        Returns:
+            ProposalDocument: 生成的投标内容
+        
+        Raises:
+            GenerationError: 内容生成失败(API 错误、格式异常等)
+        """
+```
+
+### 4.4 `proposal_writer.py` — 投标文件输出
+
+**职责**:
+- 将 `ProposalDocument` 输出为格式化的 DOCX 文件
+- 设置封面、目录、页眉页脚、章节标题、正文
+- 样式设计符合投标文件的正式格式
+
+**关键设计决策**:
+- 直接使用 python-docx(已在环境中安装)
+- 样式定义集中在模块常量中,便于调整
+- 从 `pdf_table_to_docx/docx_writer.py` 借鉴经验,但投标文件有独立样式需求
+
+**接口定义**:
+
+```python
+def write_proposal_docx(
+    content: ProposalDocument,
+    output_path: str,
+    *,
+    page_width_cm: float = 21.0,
+    page_height_cm: float = 29.7,
+    margin_cm: float = 2.5,
+) -> str:
+    """将投标内容写入格式化的 DOCX 文件。
+    
+    Args:
+        content: 投标内容
+        output_path: 输出路径
+        page_width_cm: 页面宽度(默认 A4)
+        page_height_cm: 页面高度(默认 A4)
+        margin_cm: 页边距
+    
+    Returns:
+        str: 输出文件路径
+    
+    Raises:
+        WriteError: DOCX 写入失败
+    """
+```
+
+### 4.5 `pipeline.py` — 流程编排
+
+**职责**:
+- 编排读取→分析→生成→输出的完整流程
+- 管理配置、日志、异常处理
+- 提供高级 API 一键执行
+
+**接口定义**:
+
+```python
+def run_pipeline(
+    pdf_path: str,
+    output_path: Optional[str] = None,
+    api_key: Optional[str] = None,
+    model: str = "gpt-4o",
+) -> str:
+    """运行完整的招标→投标生成流程。
+    
+    Args:
+        pdf_path: 招标文件 PDF 路径
+        output_path: 输出 DOCX 路径(默认与输入同目录)
+        api_key: OpenAI API Key(默认从环境变量读取)
+        model: AI 模型名
+    
+    Returns:
+        str: 输出文件路径
+    
+    Raises:
+        BidReadError: PDF 读取失败
+        AnalysisError: 需求分析失败
+        GenerationError: 内容生成失败
+        WriteError: DOCX 输出失败
+    """
+```
+
+### 4.6 `cli.py` — 命令行入口
+
+**接口定义**:
+
+```bash
+# 一键生成
+python -m bid_proposal 招标文件.pdf
+
+# 指定输出路径
+python -m bid_proposal 招标文件.pdf -o 投标文件.docx
+
+# 指定模型
+python -m bid_proposal 招标文件.pdf --model gpt-4o
+
+# 详细日志
+python -m bid_proposal 招标文件.pdf --verbose
+```
+
+---
+
+## 5. 与现有代码的集成点
+
+### 5.1 复用的现有能力
+
+| 现有模块 | 复用方式 | 说明 |
+|---------|---------|------|
+| `pdf_table_to_docx.extractor.PDFTableExtractor` | 直接实例化 | 复用表格提取和跨页合并 |
+| `pdf_table_to_docx.table_parser.TableInfo` | 类型引用 | 在 `BidDocument.tables` 中使用 |
+
+### 5.2 不直接复用的模块
+
+| 现有模块 | 原因 |
+|---------|------|
+| `pdf_table_to_docx.docx_writer` | 投标文件的 DOCX 样式需求不同(封面、标题层级、页眉等) |
+| `pdf_table_to_docx.text_scorer` | 投标生成不需要 NLP 评分 |
+
+### 5.3 新增依赖
+
+- **openai**(已在 `pyproject.toml` 中声明)— OpenAI API 调用
+- **pymupdf**(已在 `pyproject.toml` 中声明)— PDF 文本提取
+- 无需额外安装其他依赖
+
+---
+
+## 6. 错误处理策略
+
+### 6.1 异常层级
+
+```
+BidProposalError (base)
+├── BidReadError          — PDF 读取/解析失败
+├── AnalysisError         — AI 分析失败(API 错误、格式异常)
+├── GenerationError       — 内容生成失败
+└── WriteError            — DOCX 输出失败
+```
+
+### 6.2 重试策略
+
+- **OpenAI API 调用**:指数退避重试(最多 3 次),超时 120 秒
+- **PDF 读取**:一次性失败(文件不存在或损坏直接报错)
+- **长文档分析**:按章节拆分,单个章节分析失败不影响其他章节
+
+### 6.3 降级策略
+
+| 场景 | 处理方式 |
+|------|---------|
+| 表格提取失败 | 仅使用文本内容继续分析,日志记录警告 |
+| API 部分失败 | 标记失败项为"【待人工补充】",继续后续步骤 |
+| 输出目录不可写 | 回退到系统临时目录 |
+
+---
+
+## 7. 环境与配置
+
+### 7.1 环境变量
+
+| 变量 | 用途 | 默认值 |
+|------|------|--------|
+| `OPENAI_API_KEY` | OpenAI API 密钥 | —(必需) |
+
+### 7.2 `config.py` 设计
+
+```python
+import os
+
+class Config:
+    """配置管理。"""
+    
+    @staticmethod
+    def get_openai_api_key() -> str:
+        """获取 OpenAI API Key。"""
+        api_key = os.environ.get("OPENAI_API_KEY")
+        if not api_key:
+            raise ValueError(
+                "未设置 OPENAI_API_KEY 环境变量"
+            )
+        return api_key
+    
+    @staticmethod
+    def get_default_model() -> str:
+        """获取默认模型名。"""
+        return os.environ.get("OPENAI_MODEL", "gpt-4o")
+```
+
+---
+
+## 8. 工作拆分
+
+### 8.1 模块依赖关系
+
+```
+A1 工作包(先决)                  A2 工作包(依赖 A1 的接口定义)
+┌──────────────────────┐          ┌──────────────────────────┐
+│ models.py (共享)     │          │ proposal_generator.py    │
+│ config.py (共享)     │◀─────────│ proposal_writer.py       │
+│ bid_reader.py        │          │ pipeline.py              │
+│ requirement_         │          │ cli.py                   │
+│   analyzer.py        │          │ __main__.py              │
+└──────────────────────┘          └──────────────────────────┘
+```
+
+### 8.2 A1 工作包(`bid_reader.py` + `requirement_analyzer.py`)
+
+**实现文件**:
+- `bid_proposal/__init__.py`
+- `bid_proposal/models.py` — 所有数据类定义
+- `bid_proposal/config.py` — 配置管理
+- `bid_proposal/bid_reader.py` — 招标文件读取
+- `bid_proposal/requirement_analyzer.py` — 需求分析
+
+**接口契约**(A1 对 A2 提供的接口):
+
+```python
+# models.py
+@dataclass class BidDocument: ...
+@dataclass class Requirement: ...
+@dataclass class AnalysisResult: ...
+@dataclass class ProposalSection: ...
+@dataclass class ProposalDocument: ...
+
+# bid_reader.py
+def read_bid_pdf(pdf_path: str) -> BidDocument: ...
+
+# requirement_analyzer.py
+class RequirementAnalyzer:
+    def analyze(self, doc: BidDocument) -> AnalysisResult: ...
+```
+
+**验收标准**:
+1. 能从测试 PDF 中正确提取文本和表格
+2. 能够分析招标文件,输出结构化的需求列表
+3. 支持评审标准、服务要求、资质要求三类分类
+4. 单元测试覆盖正常路径和异常路径
+
+**详细任务**见 `task_a1.md`。
+
+### 8.3 A2 工作包(`proposal_generator.py` + `proposal_writer.py` + `pipeline.py`)
+
+**实现文件**:
+- `bid_proposal/proposal_generator.py` — 投标内容生成
+- `bid_proposal/proposal_writer.py` — 投标文件输出
+- `bid_proposal/pipeline.py` — 流程编排
+- `bid_proposal/cli.py` — 命令行入口
+- `bid_proposal/__main__.py` — `python -m` 支持
+
+**接口契约**(A2 对 A1 的依赖):
+- 引用 `models.py` 中定义的数据类
+- 调用 `bid_reader.read_bid_pdf()` 和 `RequirementAnalyzer.analyze()`
+
+**验收标准**:
+1. 能基于分析结果生成完整的投标响应内容
+2. 能输出格式化的 DOCX 文件(含封面、目录、章节、正文)
+3. 端到端流程可运行:PDF → DOCX
+4. 单元测试 + 集成测试覆盖
+
+**详细任务**见 `task_a2.md`。
+
+---
+
+## 9. 后续扩展方向
+
+| 方向 | 说明 | 优先级 |
+|------|------|--------|
+| 多轮对话优化 | 用户可对生成内容提出修改意见,AI 根据反馈调整 | 中 |
+| 模板匹配 | 支持用户提供历史投标文件作为风格模板 | 低 |
+| 评分预测 | 根据生成内容预测评审得分,指导内容优化 | 低 |
+| 参考文档库 | 支持参考多个招标文件和投标文件进行综合生成 | 低 |
+| 并行生成 | 章节级并行调用 OpenAI API,加速生成 | 低 |

+ 223 - 0
bid_proposal/TEST_REPORT.md

@@ -0,0 +1,223 @@
+# 验收测试报告
+
+**项目**: Proposa AI - `bid_proposal` 包  
+**测试日期**: 2026-07-07  
+**测试人员**: 测试工程师(test)
+
+---
+
+## 测试概要
+
+| 测试项 | 状态 | 说明 |
+|--------|------|------|
+| 测试1: 模块导入测试 | ✅ 通过 | 所有模块可正确导入 |
+| 测试2: bid_reader 功能测试 | ✅ 通过 | PDF读取正常: 76页, 36个表格, 64990字符 |
+| 测试3: proposal_writer 生成测试 | ⚠️ 有警告 | DOCX成功生成(38KB),但表格解析有边界错误 |
+| 测试4: 代码质量检查 | ⚠️ 有发现 | 代码质量良好,发现少量改进点 |
+| 测试5: Pipeline dry-run测试 | ✅ 通过 | 流程编排逻辑正常 |
+
+---
+
+## 详细测试结果
+
+### 测试1: 模块导入测试
+
+所有模块均可正确导入,包括:
+- `bid_proposal`
+- `bid_proposal.bid_reader`
+- `bid_proposal.config`
+- `bid_proposal.models`
+- `bid_proposal.pipeline`
+- `bid_proposal.proposal_generator`
+- `bid_proposal.proposal_writer`
+- `bid_proposal.requirement_analyzer`
+- `bid_proposal.utils`
+
+**结论**: ✅ 通过
+
+---
+
+### 测试2: bid_reader 功能测试
+
+| 指标 | 结果 |
+|------|------|
+| 输入文件 | `data/松江区机关事务管理局物业管理服务招标文件.pdf` |
+| 文件大小 | 3,033,606 bytes (约2.9MB) |
+| 页数 | 76 |
+| 表格数 | 36 |
+| 文本总字符数 | 64,990 |
+
+**结论**: ✅ 通过
+
+---
+
+### 测试3: proposal_writer 生成测试
+
+| 指标 | 结果 |
+|------|------|
+| 生成文件 | `data/test_proposal_output.docx` |
+| 文件大小 | 38,332 bytes (38KB) |
+| 生成耗时 | < 1秒 |
+| 错误 | 无 |
+
+**发现的问题**:
+
+1. **表格分隔行检测Bug** ⚠️: Markdown表格的隔行 `|---------|---------|` 被 `stripped.startswith("|---")` 误判为表格结束标记,因为 `|---------` 的前4个字符是 `|` `-` `-` `-`,恰好匹配 `|---`。这导致 `_write_table` 被提前调用(仅传入表头行),触发 "表格数据不足,跳过表格写入" 警告。
+
+   **影响范围**: 如果内容中某个 Markdown 表格后面紧跟其他非表格内容,该表格会被错误地截断,只有表头被写入 DOCX。
+
+   **建议修复**:
+   
+   方案1 (推荐): 在 `_write_markdown_content` 中用正则替换 `stripped.startswith("|---")` 条件为更精确的匹配:
+   ```python
+   # 替换第353行附近的条件
+   if stripped.startswith("|") and not re.match(r'^\|[\s\-:]+\|?$', stripped):
+   ```
+   
+   方案2: 将 `not stripped.startswith("|---")` 改为 `not re.match(r'^\|[-]{3,}', stripped)`,只匹配连续的三个以上短横线。
+
+2. **表格数据校验过于严格**: `_parse_markdown_table` 中 `len(data) < 2` 的判断使得单行表格(仅表头)被过滤。对于某些仅含表头无数据的场景,可以考虑放宽限制。
+
+**结论**: ⚠️ 有警告 — 功能正常,但表格解析存在边界Bug,建议修复。
+
+---
+
+### 测试4: 代码质量检查
+
+#### 4.1 模块文档字符串
+
+| 文件 | 行数 | 代码行 | 模块Docstring | 函数数 | 类数 |
+|------|------|--------|---------------|--------|------|
+| `__init__.py` | 6 | 4 | ✅ | 0 | 0 |
+| `__main__.py` | 126 | 102 | ✅ | 2 | 0 |
+| `bid_reader.py` | 128 | 91 | ✅ | 3 | 1 |
+| `config.py` | 56 | 38 | ✅ | 3 | 1 |
+| `models.py` | 133 | 106 | ✅ | 12 | 6 |
+| `pipeline.py` | 127 | 100 | ✅ | 1 | 0 |
+| `proposal_generator.py` | 424 | 362 | ✅ | 8 | 1 |
+| `proposal_writer.py` | 549 | 414 | ✅ | 15 | 0 |
+| `requirement_analyzer.py` | 303 | 249 | ✅ | 6 | 2 |
+| `utils.py` | 105 | 73 | ✅ | 3 | 0 |
+
+**结论**: ✅ 所有文件均有模块级文档字符串。
+
+#### 4.2 异常处理分析
+
+| 文件 | try/except | raise | 分析 |
+|------|-----------|-------|------|
+| `bid_reader.py` | 4/3 | 4 | **良好** — 自定义 `BidReadError` 异常,表格提取有 graceful degradation |
+| `config.py` | 0/0 | 0 | N/A — 纯数据类 + 环境读取,无需异常处理 |
+| `models.py` | 0/0 | 0 | N/A — 纯数据类,无需异常处理 |
+| `pipeline.py` | 4/4 | 6 | **良好** — 每一步有 try/except,用 RuntimeError 包装 |
+| `proposal_generator.py` | 6/6 | 2 | **良好** — 有 fallback 机制和重试 |
+| `proposal_writer.py` | 0/0 | 2 | ⚠️ `write_proposal_docx` 声明会抛出 IOError,但未捕获 `doc.save()` 的异常 |
+| `requirement_analyzer.py` | 3/3 | 5 | **良好** — 有重试逻辑和分块容错 |
+| `__main__.py` | 1/4 | 0 | **良好** — 顶层异常按类型分级处理 |
+| `utils.py` | 0/0 | 0 | ⚠️ `table_to_markdown` 和 `chunk_text` 缺少输入校验 |
+
+**发现的问题**:
+
+1. **`proposal_writer.py` — `doc.save()` 未保护**: 第530行 `doc.save(output_path)` 在磁盘空间不足或路径不可写时会抛出 OSError/IOError,该异常未被捕获或包装为更明确的错误信息。
+
+2. **`utils.py` — `table_to_markdown` 健壮性不足**: 函数依赖 `hasattr` 检查,但如果 `cells` 内容格式不符预期(如非嵌套列表),会抛出 AttributeError。
+
+#### 4.3 日志覆盖分析
+
+| 文件 | logging.getLogger | logger.xxx 调用数 | 分析 |
+|------|------------------|-------------------|------|
+| `bid_reader.py` | 是 | 6 | 充分覆盖关键流程节点 |
+| `pipeline.py` | 是 | 10 | 充分覆盖每一步 |
+| `proposal_generator.py` | 是 | 13 | 充分覆盖 |
+| `proposal_writer.py` | 是 | 3 | 偏少:仅入口和出口有日志,表格写入失败有日志 |
+| `requirement_analyzer.py` | 是 | 10 | 充分覆盖,含重试日志 |
+| `__main__.py` | 是 | 14 | 充分覆盖 |
+| `__init__.py` | 否 | 0 | N/A — 初始化文件 |
+| `config.py` | 是 | 1 | 较少但可接受 |
+| `models.py` | 否 | 0 | N/A — 数据类,无需日志 |
+| `utils.py` | 否 | 0 | ⚠️ `chunk_text` 没有日志 |
+
+**发现的问题**:
+
+1. **`utils.py` 缺少日志**: 工具函数模块没有定义 logger,`chunk_text` 和 `table_to_markdown` 的关键路径无日志输出,调试时难以追踪。
+
+2. **`proposal_writer.py` 日志偏少**: 在 `_write_markdown_content` 中处理各个 Markdown 元素时缺少 DEBUG 级别日志。
+
+#### 4.4 敏感信息检查
+
+| 检查项 | 结果 |
+|--------|------|
+| 代码中硬编码 API Key | ✅ 未发现 |
+| 代码中硬编码密码/令牌 | ✅ 未发现 |
+| `api_key` 参数传递 | ✅ 通过函数参数和环境变量传递,无硬编码 |
+| `.env` 或配置文件泄露 | N/A — 无此类文件 |
+
+**结论**: ✅ 无敏感信息泄露风险。
+
+#### 4.5 代码质量综合评分
+
+| 维度 | 评分 | 说明 |
+|------|------|------|
+| 代码结构 | 4/5 | 模块职责清晰,符合单一职责原则 |
+| 注释/docstring | 5/5 | 所有模块、类、函数均有详细 docstring |
+| 异常处理 | 3.5/5 | 主要模块良好,`proposal_writer` 和 `utils.py` 有待加强 |
+| 日志覆盖 | 3.5/5 | 主要流程充分,工具函数偏少 |
+| 命名规范 | 5/5 | 符合 Python 命名规范(snake_case, PascalCase) |
+| 类型注解 | 4/5 | 主要函数有类型注解,部分内部函数缺少 |
+
+**结论**: ⚠️ 有发现 — 代码质量总体良好,存在少量需要改进的点。
+
+---
+
+### 测试5: Pipeline dry-run 测试
+
+| 检查项 | 结果 |
+|--------|------|
+| `run_pipeline` 函数签名 | ✅ 正确 |
+| 输入PDF文件存在性校验 | ✅ `FileNotFoundError` 在文件不存在时抛出的逻辑正确 |
+| 默认输出路径计算 | ✅ 输出路径为 `data/松江区机关事务管理局物业管理服务招标文件_投标文件.docx` |
+| 模块导入 | ✅ 所有依赖模块可正确导入 |
+| 流程步骤逻辑 | ✅ 参数校验 → 文件读取 → 分析 → 生成 → DOCX输出,顺序正确 |
+
+**结论**: ✅ 通过
+
+---
+
+## 综合评估
+
+### 总体评价
+
+`bid_proposal` 包整体质量良好,模块结构清晰,文档完善。各模块之间的数据模型对齐,接口兼容。
+
+### 评分
+
+| 维度 | 等级 | 说明 |
+|------|------|------|
+| 功能完整性 | ⭐⭐⭐⭐☆ | 核心功能完整,从PDF读取到DOCX生成流程通顺 |
+| 代码质量 | ⭐⭐⭐⭐☆ | 规范良好,少量改进点 |
+| 健壮性 | ⭐⭐⭐☆☆ | 主流程有异常处理,但边界情况和工具函数偏弱 |
+| 可维护性 | ⭐⭐⭐⭐⭐ | 模块划分清晰,文档注释充分 |
+| 兼容性 | ⭐⭐⭐⭐☆ | 数据结构统一,接口对齐 |
+
+### 关键问题(建议高优先级修复)
+
+1. **表格隔行检测Bug** (proposal_writer.py:353): `stripped.startswith("|---")` 误匹配 `|---------`,导致表格在含有隔行时被截断。
+
+### 次要改进建议
+
+1. **`proposal_writer.py`** `doc.save()` 应加 try/except,捕获 IOError 并提供更有意义的错误信息。
+2. **`utils.py`** 添加 logger 定义,在关键路径增加 DEBUG 日志,便于调试。
+3. **添加单元测试**: 项目目前没有测试目录(`tests/`),建议为关键函数(`_parse_markdown_table`、`chunk_text`、`_extract_text`)添加 pytest 单元测试。
+4. **`utils.py`** `table_to_markdown` 函数增加对异常输入(空对象、异常嵌套结构)的处理。
+5. **`pyproject.toml`** 中的依赖列表不完整,缺少 `python-docx`、`pdf_table_to_docx` 等运行时依赖。
+
+---
+
+## 测试数据文件
+
+- 测试输入PDF: `data/松江区机关事务管理局物业管理服务招标文件.pdf` (3.0 MB)
+- 测试输出DOCX: `data/test_proposal_output.docx` (38 KB)
+- 测试依据PDF: `data/松江区机关事务管理局物业管理服务项目投标文件.pdf` (1.2 MB)
+
+---
+
+*报告生成于 2026-07-07,由 `proposa-ai` 项目的验收测试脚本自动生成。*

+ 39 - 0
bid_proposal/__init__.py

@@ -0,0 +1,39 @@
+"""Proposa AI — 投标书生成系统
+
+基于招标文件(PDF),使用 AI 自动生成投标文件(DOCX)。
+
+核心流程:
+    招标文件 PDF → 文本+表格提取 → AI需求分析 → AI内容生成 → 投标文件 DOCX
+
+典型用法:
+    >>> from bid_proposal.pipeline import run_pipeline
+    >>> output = run_pipeline("招标文件.pdf", "投标文件.docx")
+
+CLI 用法:
+    $ python -m bid_proposal 招标文件.pdf -o 投标文件.docx
+"""
+__version__ = "0.1.0"
+
+from .models import (
+    BidPage, BidDocument,
+    Requirement, AnalysisResult,
+    ProposalSection, ProposalDocument,
+)
+from .config import Config, get_config, set_config
+from .bid_reader import read_bid_pdf
+from .requirement_analyzer import RequirementAnalyzer
+from .proposal_generator import ProposalGenerator
+from .proposal_writer import write_proposal_docx
+from .pipeline import run_pipeline
+
+__all__ = [
+    "BidPage", "BidDocument",
+    "Requirement", "AnalysisResult",
+    "ProposalSection", "ProposalDocument",
+    "Config", "get_config", "set_config",
+    "read_bid_pdf",
+    "RequirementAnalyzer",
+    "ProposalGenerator",
+    "write_proposal_docx",
+    "run_pipeline",
+]

+ 125 - 0
bid_proposal/__main__.py

@@ -0,0 +1,125 @@
+"""
+__main__.py - CLI入口模块
+
+命令行用法:
+    python -m bid_proposal <招标文件.pdf> [-o 输出文件.docx] [--api-key KEY] [--model MODEL] [--verbose]
+
+通过 run_pipeline() 运行完整的招标→投标生成流程。
+"""
+
+import argparse
+import logging
+import sys
+import traceback
+
+from .config import set_config, Config
+from .pipeline import run_pipeline
+from .utils import setup_logging
+
+logger = logging.getLogger(__name__)
+
+
+def build_parser() -> argparse.ArgumentParser:
+    """构建命令行参数解析器。"""
+    parser = argparse.ArgumentParser(
+        description="AI驱动的投标书生成系统 - 从招标PDF生成投标文件",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=(
+            "示例:\n"
+            "  python -m bid_proposal 招标文件.pdf\n"
+            "  python -m bid_proposal 招标文件.pdf -o 投标文件.docx\n"
+            "  python -m bid_proposal 招标文件.pdf --api-key sk-xxx --model gpt-4o -v\n"
+            "\n"
+            "环境变量:\n"
+            "  OPENAI_API_KEY    OpenAI API密钥(推荐通过环境变量设置)\n"
+            "  VERBOSE          设置为1/true启用详细日志\n"
+        ),
+    )
+
+    parser.add_argument(
+        "pdf_path",
+        type=str,
+        help="招标文件PDF路径",
+    )
+
+    parser.add_argument(
+        "-o",
+        "--output",
+        type=str,
+        default=None,
+        help="输出DOCX文件路径(默认:招标文件所在目录/{文件名}_投标文件.docx)",
+    )
+
+    parser.add_argument(
+        "--api-key",
+        type=str,
+        default=None,
+        help="OpenAI API密钥(默认从 OPENAI_API_KEY 环境变量读取)",
+    )
+
+    parser.add_argument(
+        "--model",
+        type=str,
+        default=None,
+        help="OpenAI模型名(默认:gpt-4o)",
+    )
+
+    parser.add_argument(
+        "-v",
+        "--verbose",
+        action="store_true",
+        help="详细日志输出",
+    )
+
+    return parser
+
+
+def main():
+    """CLI入口函数。"""
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # 配置日志
+    setup_logging(verbose=args.verbose or args.pdf_path == "debug")
+
+    # 如果指定了 api-key,更新配置
+    if args.api_key:
+        config = Config(openai_api_key=args.api_key)
+        set_config(config)
+
+    logger.info("=" * 50)
+    logger.info("投标书生成系统 v0.1.0")
+    logger.info("=" * 50)
+    logger.info("招标文件: %s", args.pdf_path)
+
+    try:
+        output_path = run_pipeline(
+            pdf_path=args.pdf_path,
+            output_path=args.output,
+            api_key=args.api_key,
+            model=args.model,
+        )
+        logger.info("")
+        logger.info("=" * 50)
+        logger.info("生成完成!")
+        logger.info("输出文件: %s", output_path)
+        logger.info("=" * 50)
+    except FileNotFoundError as e:
+        logger.error("文件未找到: %s", e)
+        sys.exit(1)
+    except ValueError as e:
+        logger.error("参数错误: %s", e)
+        sys.exit(2)
+    except ImportError as e:
+        logger.error("缺少依赖模块: %s", e)
+        logger.error("请确保已安装所有依赖:pip install -r requirements.txt")
+        sys.exit(3)
+    except Exception as e:
+        logger.error("运行失败: %s", e)
+        if args.verbose:
+            traceback.print_exc()
+        sys.exit(4)
+
+
+if __name__ == "__main__":
+    main()

+ 127 - 0
bid_proposal/bid_reader.py

@@ -0,0 +1,127 @@
+"""
+bid_reader.py - 招标文件读取模块
+
+负责读取招标 PDF,提取文本和表格内容,返回结构化的 BidDocument 对象。
+"""
+
+import logging
+import os
+from typing import Optional
+
+from bid_proposal.models import BidDocument, BidPage
+
+logger = logging.getLogger(__name__)
+
+
+class BidReadError(Exception):
+    """招标文件读取失败异常"""
+
+    pass
+
+
+def read_bid_pdf(pdf_path: str) -> BidDocument:
+    """读取招标 PDF,提取文本和表格。
+
+    实现步骤:
+    1. 使用 pymupdf (import fitz) 打开 PDF,逐页提取文本
+    2. 使用 pdf_table_to_docx 提取表格
+    3. 组合成 BidDocument 返回
+
+    Args:
+        pdf_path: 招标文件 PDF 路径
+
+    Returns:
+        BidDocument: 结构化的招标文件内容
+
+    Raises:
+        FileNotFoundError: PDF 文件不存在
+        BidReadError: PDF 读取/解析失败(如文件损坏、加密等)
+    """
+    if not os.path.exists(pdf_path):
+        raise FileNotFoundError(f"招标文件不存在: {pdf_path}")
+
+    logger.info("开始读取招标文件: %s", pdf_path)
+
+    # 1. 使用 pymupdf 提取文本
+    pages = _extract_text(pdf_path)
+
+    # 2. 使用 pdf_table_to_docx 提取表格
+    tables = _extract_tables(pdf_path)
+
+    # 3. 构建 BidDocument
+    doc = BidDocument(
+        pdf_path=os.path.abspath(pdf_path),
+        pages=pages,
+        tables=tables,
+    )
+
+    logger.info("读取完成: %d 页, %d 个表格, %d 字符", doc.total_pages, doc.table_count, len(doc.full_text))
+    return doc
+
+
+def _extract_text(pdf_path: str) -> list[BidPage]:
+    """使用 pymupdf 从 PDF 中逐页提取文本。
+
+    Args:
+        pdf_path: PDF 文件路径
+
+    Returns:
+        list[BidPage]: 每页的文本内容列表
+
+    Raises:
+        BidReadError: PDF 打开或读取失败
+    """
+    try:
+        import fitz  # pymupdf
+    except ImportError:
+        raise ImportError("请安装 pymupdf: pip install pymupdf")
+
+    try:
+        doc = fitz.open(pdf_path)
+    except Exception as e:
+        raise BidReadError(f"无法打开 PDF 文件: {e}")
+
+    pages: list[BidPage] = []
+    try:
+        for page_num in range(len(doc)):
+            page = doc[page_num]
+            text = page.get_text().strip()
+            bid_page = BidPage(page_num=page_num + 1, text=text)
+            pages.append(bid_page)
+            logger.debug("第 %d 页: %d 字符", page_num + 1, len(text))
+    finally:
+        doc.close()
+
+    if not pages:
+        raise BidReadError(f"PDF 文件为空或无法提取任何文本: {pdf_path}")
+
+    return pages
+
+
+def _extract_tables(pdf_path: str) -> list:
+    """使用 pdf_table_to_docx 从 PDF 中提取表格。
+
+    Args:
+        pdf_path: PDF 文件路径
+
+    Returns:
+        list[TableInfo]: 表格列表
+
+    Note:
+        如果表格提取失败,记录警告并返回空列表,不影响文本提取结果。
+    """
+    try:
+        from pdf_table_to_docx.extractor import PDFTableExtractor
+
+        extractor = PDFTableExtractor(pdf_path)
+        extracted_tables = extractor.extract()
+        if extracted_tables:
+            tables = list(extracted_tables)
+            logger.info("提取到 %d 个表格", len(tables))
+            return tables
+        else:
+            logger.info("PDF 中未发现表格")
+            return []
+    except Exception as e:
+        logger.warning("表格提取失败,仅使用文本内容继续: %s", e)
+        return []

+ 55 - 0
bid_proposal/config.py

@@ -0,0 +1,55 @@
+"""
+config.py - 配置管理模块
+
+从环境变量读取配置,提供默认值。
+支持通过 set_config() 覆盖默认配置。
+"""
+
+import os
+import logging
+from dataclasses import dataclass, field
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class Config:
+    """系统配置"""
+    # OpenAI 配置
+    openai_api_key: str = ""        # API密钥,默认从 OPENAI_API_KEY 环境变量读取
+    openai_model: str = "gpt-4o"   # 模型名
+    openai_temperature: float = 0.7  # 生成温度
+    openai_max_tokens: int = 4096    # 最大生成token数
+    openai_timeout: int = 120        # 请求超时(秒)
+
+    # 系统配置
+    verbose: bool = False           # 详细日志模式
+    max_text_length: int = 100000   # 发送给AI的最大文本长度
+
+
+# 全局配置实例
+_default_config: Optional[Config] = None
+
+
+def _load_from_env() -> Config:
+    """从环境变量加载配置"""
+    return Config(
+        openai_api_key=os.environ.get("OPENAI_API_KEY", ""),
+        verbose=os.environ.get("VERBOSE", "").lower() in ("1", "true", "yes"),
+    )
+
+
+def get_config() -> Config:
+    """获取当前配置(单例)"""
+    global _default_config
+    if _default_config is None:
+        _default_config = _load_from_env()
+    return _default_config
+
+
+def set_config(config: Config):
+    """覆盖默认配置"""
+    global _default_config
+    _default_config = config
+    logger.info("配置已更新")

+ 132 - 0
bid_proposal/models.py

@@ -0,0 +1,132 @@
+"""
+models.py - 投标书生成系统的数据结构定义
+
+定义了系统中所有核心数据类,包括:
+- BidPage / BidDocument: 招标文件解析结果
+- Requirement / AnalysisResult: 招标要求分析结果
+- ProposalSection / ProposalDocument: 投标内容生成结果
+"""
+
+from dataclasses import dataclass, field
+from typing import Optional
+from pdf_table_to_docx.table_parser import TableInfo
+
+
+@dataclass
+class BidPage:
+    """招标文件中的一页"""
+    page_num: int           # 页码(1-indexed)
+    text: str               # 页面文本内容
+
+    def __str__(self) -> str:
+        return f"第{self.page_num}页 ({len(self.text)}字符)"
+
+
+@dataclass
+class BidDocument:
+    """招标文件结构化内容"""
+    pdf_path: str                           # PDF 文件路径
+    file_name: str = ""                     # 文件名
+    total_pages: int = 0                    # 总页数
+    pages: list[BidPage] = field(default_factory=list)  # 所有页面文本
+    full_text: str = ""                     # 全文拼接文本(供 AI 分析使用)
+    tables: list[TableInfo] = field(default_factory=list)  # 从 PDF 提取的表格
+    table_count: int = 0                    # 表格数量
+
+    def __post_init__(self):
+        """自动计算派生字段"""
+        if not self.file_name and self.pdf_path:
+            import os
+            self.file_name = os.path.basename(self.pdf_path)
+        if not self.total_pages:
+            self.total_pages = len(self.pages)
+        if not self.full_text:
+            self.full_text = "\n\n".join(p.text for p in self.pages)
+        if not self.table_count:
+            self.table_count = len(self.tables)
+
+    def __str__(self) -> str:
+        return (f"招标文件: {self.file_name}\n"
+                f"  共 {self.total_pages} 页, {self.table_count} 个表格")
+
+
+@dataclass
+class Requirement:
+    """单个招标要求项"""
+    category: str                       # 类别(evaluation/service/qualification/other)
+    title: str                          # 要求标题
+    description: str = ""               # 详细描述
+    score: Optional[float] = None       # 分值(评分项专用)
+    detail: str = ""                    # 评分标准详细说明
+    source_table_id: Optional[int] = None  # 来源表格ID
+
+    def __str__(self) -> str:
+        score_str = f" [{self.score}分]" if self.score is not None else ""
+        return f"[{self.category}]{score_str} {self.title[:50]}"
+
+
+@dataclass
+class AnalysisResult:
+    """招标文件分析结果"""
+    summary: str = ""                                       # 招标概要
+    project_name: str = ""                                  # 项目名称
+    evaluation_criteria: list[Requirement] = field(default_factory=list)   # 评审标准
+    service_requirements: list[Requirement] = field(default_factory=list)  # 服务要求
+    qualification_requirements: list[Requirement] = field(default_factory=list)  # 资质要求
+    other_requirements: list[Requirement] = field(default_factory=list)    # 其他要求
+    raw_response: str = ""                                  # AI 原始响应(调试用)
+
+    @property
+    def all_requirements(self) -> list[Requirement]:
+        """获取所有要求(合并列表)"""
+        return (self.evaluation_criteria
+                + self.service_requirements
+                + self.qualification_requirements
+                + self.other_requirements)
+
+    @property
+    def total_requirements(self) -> int:
+        return len(self.all_requirements)
+
+    def by_category(self, category: str) -> list[Requirement]:
+        """按类别筛选要求"""
+        return [r for r in self.all_requirements if r.category == category]
+
+    def __str__(self) -> str:
+        return (f"招标分析结果: {self.project_name or '未命名项目'}\n"
+                f"  评审标准: {len(self.evaluation_criteria)}项, "
+                f"服务要求: {len(self.service_requirements)}项, "
+                f"资质要求: {len(self.qualification_requirements)}项")
+
+
+@dataclass
+class ProposalSection:
+    """投标文件的一个章节"""
+    title: str                          # 章节标题
+    level: int = 1                      # 层级(1=一级标题, 2=二级标题)
+    content: str = ""                   # 章节正文
+    requirement_ref: str = ""           # 对应的招标要求引用
+
+    def __str__(self) -> str:
+        return f"{'  ' * (self.level-1)}#{self.level} {self.title} ({len(self.content)}字符)"
+
+
+@dataclass
+class ProposalDocument:
+    """完整的投标文件内容"""
+    title: str = ""                     # 文档标题
+    project_name: str = ""              # 项目名称
+    sections: list[ProposalSection] = field(default_factory=list)  # 所有章节
+    summary: str = ""                   # 投标概要
+
+    @property
+    def total_sections(self) -> int:
+        return len(self.sections)
+
+    def by_level(self, level: int) -> list[ProposalSection]:
+        """按层级筛选章节"""
+        return [s for s in self.sections if s.level == level]
+
+    def __str__(self) -> str:
+        return (f"投标内容: {self.title}\n"
+                f"  共 {self.total_sections} 个章节")

+ 126 - 0
bid_proposal/pipeline.py

@@ -0,0 +1,126 @@
+"""
+pipeline.py - 流程编排模块
+
+串联招标文件读取、招标要求分析、投标内容生成和DOCX输出,
+提供统一的 run_pipeline() 入口函数。
+"""
+
+import logging
+import os
+from typing import Optional
+
+from .config import get_config
+from .models import AnalysisResult
+from .proposal_generator import ProposalGenerator
+from .proposal_writer import write_proposal_docx
+
+logger = logging.getLogger(__name__)
+
+
+def run_pipeline(
+    pdf_path: str,
+    output_path: Optional[str] = None,
+    api_key: Optional[str] = None,
+    model: Optional[str] = None,
+    analysis: Optional[AnalysisResult] = None,
+) -> str:
+    """运行完整的招标文件分析到投标文件生成流程。
+
+    流程步骤:
+    1. 读取招标文件 PDF(通过 read_bid_pdf)
+    2. 分析招标要求(通过 RequirementAnalyzer.analyze)
+    3. 生成投标内容(通过 ProposalGenerator.generate)
+    4. 输出格式化 DOCX 文件(通过 write_proposal_docx)
+
+    Args:
+        pdf_path: 招标文件 PDF 路径
+        output_path: 输出 DOCX 文件路径
+                     (默认:在 PDF 同目录下生成 {pdf_stem}_投标文件.docx)
+        api_key: OpenAI API 密钥(默认从配置 / 环境变量读取)
+        model: OpenAI 模型名(默认 gpt-4o)
+        analysis: 可选的预分析结果。如果提供,则跳过分析和 PDF 读取步骤。
+
+    Returns:
+        str: 输出 DOCX 文件的绝对路径
+
+    Raises:
+        FileNotFoundError: pdf_path 不存在
+        ValueError: 配置无效或 API 密钥未设置
+        RuntimeError: 流程中某一步骤执行失败
+    """
+    # ---- 参数校验 ----
+    if not pdf_path:
+        raise ValueError("pdf_path 不能为空")
+
+    pdf_path = os.path.abspath(pdf_path)
+    if not os.path.isfile(pdf_path):
+        raise FileNotFoundError(f"招标文件不存在: {pdf_path}")
+
+    if not pdf_path.lower().endswith(".pdf"):
+        logger.warning("文件扩展名不是 .pdf,但仍将尝试处理: %s", pdf_path)
+
+    # 设置默认输出路径
+    if output_path is None:
+        base_dir = os.path.dirname(pdf_path)
+        pdf_stem = os.path.splitext(os.path.basename(pdf_path))[0]
+        output_path = os.path.join(base_dir, f"{pdf_stem}_投标文件.docx")
+
+    # 确保输出目录存在
+    output_dir = os.path.dirname(output_path)
+    if output_dir:
+        os.makedirs(output_dir, exist_ok=True)
+
+    # ---- 步骤1: 读取招标文件 ----
+    if analysis is None:
+        logger.info("步骤 1/3: 读取招标文件...")
+        try:
+            from bid_proposal.bid_reader import read_bid_pdf
+            bid_doc = read_bid_pdf(pdf_path)
+            logger.info(
+                "招标文件读取完成:%s(%d 页,%d 个表格)",
+                os.path.basename(pdf_path),
+                bid_doc.total_pages,
+                bid_doc.table_count,
+            )
+        except Exception as e:
+            raise RuntimeError(f"读取招标文件失败: {e}") from e
+
+        # ---- 步骤2: 分析招标要求 ----
+        logger.info("步骤 2/3: 分析招标要求...")
+        try:
+            from bid_proposal.requirement_analyzer import RequirementAnalyzer
+            analyzer = RequirementAnalyzer()
+            analysis = analyzer.analyze(bid_doc)
+            logger.info(
+                "招标要求分析完成:共 %d 项要求",
+                analysis.total_requirements,
+            )
+        except Exception as e:
+            raise RuntimeError(f"招标要求分析失败: {e}") from e
+    else:
+        logger.info(
+            "跳过分析和 PDF 读取,使用提供的预分析结果(%d 项要求)",
+            analysis.total_requirements,
+        )
+
+    # ---- 步骤3: 生成投标内容 ----
+    logger.info("步骤 3/4: 生成投标内容...")
+    try:
+        generator = ProposalGenerator(api_key=api_key, model=model)
+        proposal_content = generator.generate(analysis)
+        logger.info(
+            "投标内容生成完成:共 %d 个章节",
+            proposal_content.total_sections,
+        )
+    except Exception as e:
+        raise RuntimeError(f"投标内容生成失败: {e}") from e
+
+    # ---- 步骤4: 输出 DOCX ----
+    logger.info("步骤 4/4: 输出 DOCX 文件...")
+    try:
+        result_path = write_proposal_docx(proposal_content, output_path)
+        logger.info("投标文件已生成: %s", result_path)
+    except Exception as e:
+        raise RuntimeError(f"DOCX 文件输出失败: {e}") from e
+
+    return os.path.abspath(result_path)

+ 423 - 0
bid_proposal/proposal_generator.py

@@ -0,0 +1,423 @@
+"""
+proposal_generator.py - 投标内容生成模块
+
+根据招标分析结果(AnalysisResult),使用OpenAI生成对应的投标响应内容。
+支持按类别采用不同生成策略,可并行调用API以提高性能。
+"""
+
+import json
+import logging
+import re
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Optional
+
+from openai import OpenAI
+
+from .config import get_config
+from .models import AnalysisResult, ProposalDocument, ProposalSection, Requirement
+
+logger = logging.getLogger(__name__)
+
+# 类别对应的生成策略提示词
+_CATEGORY_PROMPTS = {
+    "evaluation": (
+        "你正在撰写一份投标文件的评分响应部分。请针对以下评审因素,提供具体、可量化的响应方案。\n"
+        "内容要求:\n"
+        "1. 明确响应评审指标,逐条对应\n"
+        "2. 提供具体的技术方案、组织措施或管理方法\n"
+        "3. 如有分值权重,重点突出高分项\n"
+        "4. 强调我方优势和经验\n"
+        "5. 语言正式、专业,符合标书风格"
+    ),
+    "service": (
+        "你正在撰写一份投标文件的服务方案部分。请针对以下服务要求,制定详细的服务方案。\n"
+        "内容要求:\n"
+        "1. 详细描述服务方案、实施流程和质量标准\n"
+        "2. 说明服务团队配置和职责分工\n"
+        "3. 列出服务承诺和保障措施\n"
+        "4. 包含服务响应时限和SLA指标\n"
+        "5. 可适当引用过往成功案例"
+    ),
+    "qualification": (
+        "你正在撰写一份投标文件的资质响应部分。请针对以下资质要求,说明我方满足条件的证明材料。\n"
+        "内容要求:\n"
+        "1. 明确说明我方具备的相应资质和证书\n"
+        "2. 提供资质等级、编号、有效期等关键信息\n"
+        "3. 说明资质对应的业务范围和能力\n"
+        "4. 附上证明材料清单\n"
+        "5. 如暂无某项资质,说明已申请或替代方案"
+    ),
+    "other": (
+        "你正在撰写一份投标文件的响应部分。请针对以下要求,提供具体、专业的响应内容。\n"
+        "内容要求:\n"
+        "1. 直接回应招标要求,不回避、不跑题\n"
+        "2. 内容具体可行,避免空泛承诺\n"
+        "3. 语言正式、专业、规范\n"
+        "4. 适当使用数据和事实支撑"
+    ),
+}
+
+_DEFAULT_CATEGORY_PROMPT = (
+    "你正在撰写一份投标文件的响应部分。请针对以下要求,提供具体、专业的响应内容。\n"
+    "内容要求:\n"
+    "1. 直接回应招标要求,不回避、不跑题\n"
+    "2. 内容具体可行,避免空泛承诺\n"
+    "3. 语言正式、专业、规范\n"
+    "4. 适当使用数据和事实支撑"
+)
+
+# JSON Schema for structured output
+_SECTION_SCHEMA = {
+    "name": "proposal_section",
+    "strict": True,
+    "schema": {
+        "type": "object",
+        "properties": {
+            "title": {"type": "string"},
+            "content": {"type": "string"},
+            "requirement_ref": {"type": "string"},
+            "level": {"type": "number"},
+        },
+        "required": ["title", "content", "requirement_ref", "level"],
+        "additionalProperties": False,
+    },
+}
+
+_COVER_LETTER_SYSTEM_PROMPT = (
+    "你是一个专业的投标文件撰写专家。请根据项目名称,撰写一份正式的投标函。\n"
+    "投标函应包含以下要素:\n"
+    "1. 致送单位(使用'致:招标人/采购代理机构')\n"
+    "2. 投标意向声明\n"
+    "3. 报价承诺(标明'按招标文件要求报价')\n"
+    "4. 工期/交货期承诺\n"
+    "5. 质量保证承诺\n"
+    "6. 投标有效期承诺\n"
+    "7. 法定代表人签字和公司盖章声明\n"
+    "使用正式的公文格式,语言庄重得体。"
+)
+
+_SUMMARY_SYSTEM_PROMPT = (
+    "你是一个专业的投标文件撰写专家。请根据招标分析结果和已生成的投标内容,"
+    "撰写一份投标概要(编制说明)。\n"
+    "概要应包含:\n"
+    "1. 项目背景和理解\n"
+    "2. 我方优势概述\n"
+    "3. 本投标文件的编制思路和结构\n"
+    "4. 核心承诺\n"
+    "语言简洁有力,突出我方优势。"
+)
+
+
+class ProposalGenerator:
+    """投标内容生成器。
+
+    根据招标分析结果,使用OpenAI生成对应的投标响应内容。
+    支持结构化输出(JSON Schema),可按类别采用不同生成策略。
+    """
+
+    def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
+        config = get_config()
+        self.client = OpenAI(
+            api_key=api_key or config.openai_api_key,
+            timeout=config.openai_timeout,
+        )
+        self.model = model or config.openai_model
+        self.temperature = config.openai_temperature
+        self.max_tokens = config.openai_max_tokens
+
+    def generate(self, analysis: AnalysisResult) -> ProposalDocument:
+        """根据招标要求生成完整的投标内容。
+
+        对每个 Requirement 调用API生成响应,同时生成投标函和概要。
+        多个要求的生成任务并行执行以提高效率。
+
+        Args:
+            analysis: 招标分析结果
+
+        Returns:
+            ProposalDocument: 完整的投标内容
+
+        Raises:
+            ValueError: 如果API密钥未配置或analysis为空
+            RuntimeError: 如果所有要求生成均失败
+        """
+        if not self.client.api_key:
+            raise ValueError(
+                "OpenAI API密钥未配置。请设置 OPENAI_API_KEY 环境变量或在调用时传入 api_key。"
+            )
+
+        project_name = analysis.project_name or "未命名项目"
+        sections: list[ProposalSection] = []
+        errors: list[str] = []
+
+        # 获取所有要求并生成响应
+        all_reqs = analysis.all_requirements
+        logger.info(
+            "开始生成投标内容:项目 '%s',共 %d 项要求",
+            project_name,
+            len(all_reqs),
+        )
+
+        # 并行生成各个要求的响应
+        with ThreadPoolExecutor(max_workers=min(len(all_reqs), 10)) as executor:
+            future_map = {
+                executor.submit(self._generate_section, req): req
+                for req in all_reqs
+            }
+            for future in as_completed(future_map):
+                req = future_map[future]
+                try:
+                    section = future.result()
+                    sections.append(section)
+                    logger.info("生成完成:%s", section.title)
+                except Exception as e:
+                    error_msg = f"要求 '{req.title[:40]}...' 生成失败: {e}"
+                    errors.append(error_msg)
+                    logger.error(error_msg)
+
+        # 按层级排序,保持文档结构合理
+        sections.sort(key=lambda s: (s.level, s.title))
+
+        if not sections:
+            raise RuntimeError(
+                "所有要求的响应生成均失败。\n  " + "\n  ".join(errors)
+            )
+
+        # 生成投标函
+        logger.info("开始生成投标函...")
+        try:
+            cover_letter = self._generate_cover_letter(project_name)
+            logger.info("投标函生成完成")
+        except Exception as e:
+            cover_letter = self._fallback_cover_letter(project_name)
+            logger.warning("投标函AI生成失败,使用模板: %s", e)
+
+        # 添加投标函作为首个章节
+        cover_section = ProposalSection(
+            title="投标函",
+            level=1,
+            content=cover_letter,
+            requirement_ref="投标函",
+        )
+        sections.insert(0, cover_section)
+
+        # 构建完整的投标文档
+        proposal = ProposalDocument(
+            title=project_name,
+            project_name=project_name,
+            sections=sections,
+        )
+
+        # 生成投标概要
+        logger.info("开始生成投标概要...")
+        try:
+            proposal.summary = self._generate_summary(analysis, proposal)
+            logger.info("投标概要生成完成")
+        except Exception as e:
+            proposal.summary = self._fallback_summary(analysis)
+            logger.warning("投标概要AI生成失败,使用模板: %s", e)
+
+        if errors:
+            logger.warning(
+                "部分要求生成失败(%d/%d):\n  %s",
+                len(errors),
+                len(all_reqs),
+                "\n  ".join(errors),
+            )
+
+        return proposal
+
+    def _build_section_prompt(self, req: Requirement) -> str:
+        """根据要求类别构建对应的系统提示词。"""
+        base_prompt = _CATEGORY_PROMPTS.get(req.category, _DEFAULT_CATEGORY_PROMPT)
+
+        score_hint = ""
+        if req.score is not None and req.score > 0:
+            score_hint = f"\n【分值权重】此项评审分值为 {req.score} 分,请重点响应,确保覆盖所有得分点。"
+
+        detail_hint = ""
+        if req.detail:
+            detail_hint = f"\n【详细说明】{req.detail}"
+
+        return (
+            f"{base_prompt}"
+            f"\n\n【要求类别】{req.category}"
+            f"{score_hint}"
+            f"{detail_hint}"
+        )
+
+    def _generate_section(self, req: Requirement) -> ProposalSection:
+        """为单个要求生成响应内容。
+
+        Args:
+            req: 招标要求
+
+        Returns:
+            ProposalSection: 生成的响应章节
+
+        Raises:
+            Exception: API调用失败时抛出
+        """
+        system_prompt = self._build_section_prompt(req)
+
+        user_prompt = (
+            f"请针对以下招标要求撰写响应内容:\n\n"
+            f"【要求标题】{req.title}\n"
+            f"【要求描述】{req.description}\n\n"
+            f"请按 JSON Schema 格式返回,包含以下字段:\n"
+            f"- title: 响应章节标题\n"
+            f"- content: 响应正文(使用 markdown 格式,可包含段落、列表、表格等)\n"
+            f"- requirement_ref: 引用原文要求(简明扼要)\n"
+            f"- level: 章节层级(1=一级标题, 2=二级标题, 3=三级标题)"
+        )
+
+        try:
+            response = self.client.chat.completions.create(
+                model=self.model,
+                messages=[
+                    {"role": "system", "content": system_prompt},
+                    {"role": "user", "content": user_prompt},
+                ],
+                response_format={
+                    "type": "json_schema",
+                    "json_schema": _SECTION_SCHEMA,
+                },
+                temperature=self.temperature,
+                max_tokens=self.max_tokens,
+            )
+
+            result = json.loads(response.choices[0].message.content)
+
+            return ProposalSection(
+                title=result.get("title", f"响应:{req.title[:30]}"),
+                content=result.get("content", ""),
+                requirement_ref=result.get("requirement_ref", req.title),
+                level=result.get("level", 2),
+            )
+        except Exception as e:
+            logger.error(
+                "生成要求 '%s...' 的响应时出错: %s",
+                req.title[:50],
+                e,
+            )
+            raise
+
+    def _generate_cover_letter(self, project_name: str) -> str:
+        """生成投标函。
+
+        Args:
+            project_name: 项目名称
+
+        Returns:
+            str: 投标函文本(Markdown格式)
+        """
+        try:
+            response = self.client.chat.completions.create(
+                model=self.model,
+                messages=[
+                    {"role": "system", "content": _COVER_LETTER_SYSTEM_PROMPT},
+                    {
+                        "role": "user",
+                        "content": (
+                            f"请为以下项目撰写正式投标函:\n\n"
+                            f"项目名称:{project_name}\n\n"
+                            f"使用正式的公文格式撰写,包含投标人信息(请使用'我方'代称),"
+                            f"语言庄重得体。"
+                        ),
+                    },
+                ],
+                temperature=self.temperature,
+                max_tokens=self.max_tokens,
+            )
+            return response.choices[0].message.content.strip()
+        except Exception as e:
+            logger.error("投标函生成失败: %s", e)
+            raise
+
+    def _generate_summary(
+        self, analysis: AnalysisResult, proposal: Optional[ProposalDocument] = None
+    ) -> str:
+        """生成投标概要/编制说明。
+
+        Args:
+            analysis: 招标分析结果
+            proposal: 已生成的投标内容(可选,用于参考)
+
+        Returns:
+            str: 投标概要文本(Markdown格式)
+        """
+        sections_summary = ""
+        if proposal and proposal.sections:
+            section_titles = "\n".join(
+                f"- {s.title}" for s in proposal.sections[:10]
+            )
+            sections_summary = f"\n本投标文件包含以下章节:\n{section_titles}"
+
+        try:
+            response = self.client.chat.completions.create(
+                model=self.model,
+                messages=[
+                    {"role": "system", "content": _SUMMARY_SYSTEM_PROMPT},
+                    {
+                        "role": "user",
+                        "content": (
+                            f"项目名称:{analysis.project_name or '未命名'}\n"
+                            f"招标概要:{analysis.summary}\n\n"
+                            f"请撰写投标概要/编制说明。"
+                        ),
+                    },
+                ],
+                temperature=self.temperature,
+                max_tokens=self.max_tokens,
+            )
+            return response.choices[0].message.content.strip()
+        except Exception as e:
+            logger.error("投标概要生成失败: %s", e)
+            raise
+
+    def _fallback_cover_letter(self, project_name: str) -> str:
+        """当API不可用时使用的备用投标函模板。"""
+        import datetime
+        today = datetime.date.today().strftime("%Y年%m月%d日")
+        return (
+            f"# 投标函\n\n"
+            f"致:招标人/采购代理机构\n\n"
+            f"我方仔细研究了《{project_name}》招标文件的全部内容,决定参与本项目的投标。\n\n"
+            f"1. 我方愿意按照招标文件要求,承担本项目的实施工作。\n\n"
+            f"2. 我方承诺按招标文件规定的工期和质量标准完成本项目。\n\n"
+            f"3. 我方承诺本投标文件在投标有效期内保持有效。\n\n"
+            f"4. 我方郑重声明,所递交的投标文件及有关资料内容完整、真实、准确。\n\n"
+            f"5. 我方承诺将严格按照合同约定履行各项义务。\n\n"
+            f"特此函告。\n\n"
+            f"投标人:我方\n"
+            f"日期:{today}"
+        )
+
+    def _fallback_summary(self, analysis: AnalysisResult) -> str:
+        """当API不可用时使用的备用概要模板。"""
+        all_reqs = analysis.all_requirements
+        categories = set(r.category for r in all_reqs)
+        category_names = {
+            "evaluation": "评审标准响应",
+            "service": "服务方案",
+            "qualification": "资质证明",
+            "other": "其他响应",
+        }
+        category_strs = [category_names.get(c, c) for c in categories if c in category_names]
+
+        return (
+            f"## 编制说明\n\n"
+            f"### 1. 项目概述\n\n"
+            f"本项目为「{analysis.project_name or '本次招标项目'}」,"
+            f"我方在认真研究招标文件的基础上,编制本投标文件。\n\n"
+            f"### 2. 编制依据\n\n"
+            f"- 本项目招标文件及其补充文件\n"
+            f"- 国家相关法律法规和技术标准\n"
+            f"- 我方多年来在相关领域的项目经验\n\n"
+            f"### 3. 投标文件结构\n\n"
+            f"本投标文件针对以下类别的要求进行了详细响应:\n"
+            + "\n".join(f"- {cat}" for cat in category_strs) +
+            f"\n\n### 4. 核心承诺\n\n"
+            f"我方郑重承诺,将严格按照招标文件和合同约定,"
+            f"以优质的服务和专业技术,确保项目圆满完成。"
+        )

+ 556 - 0
bid_proposal/proposal_writer.py

@@ -0,0 +1,556 @@
+"""
+proposal_writer.py - 投标文件DOCX输出模块
+
+将 ProposalDocument 格式化为结构化的 Word 文档(.docx),
+支持封面、目录、标题层级、表格、列表、页脚页码等排版要素。
+"""
+
+import logging
+import re
+from typing import Optional
+
+from docx import Document
+from docx.enum.table import WD_TABLE_ALIGNMENT
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
+from docx.oxml.ns import qn, nsdecls
+from docx.oxml import parse_xml
+from docx.shared import Cm, Pt, RGBColor
+from docx.table import _Cell
+
+from .models import ProposalDocument, ProposalSection
+
+logger = logging.getLogger(__name__)
+
+# ---- 常量 ----
+PAGE_WIDTH_CM = 21.0
+PAGE_HEIGHT_CM = 29.7
+MARGIN_CM = 2.0
+BODY_WIDTH_CM = PAGE_WIDTH_CM - 2 * MARGIN_CM  # 17.0 cm
+
+FONT_NAME = "宋体"
+FONT_NAME_ASCII = "Times New Roman"
+
+HEADING_FONTS = {1: 16, 2: 14, 3: 12}
+BODY_FONT_SIZE = 12  # pt
+
+
+def _set_cell_border(cell: _Cell, **kwargs):
+    """设置单元格边框。"""
+    tc = cell._tc
+    tcPr = tc.get_or_add_tcPr()
+    tcBorders = parse_xml(
+        f'<w:tcBorders {nsdecls("w")}>'
+        '  <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '</w:tcBorders>'
+    )
+    tcPr.append(tcBorders)
+
+
+def _set_paragraph_spacing(paragraph, line_spacing: float = 1.5, space_before: float = 0.5, space_after: float = 0.5):
+    """设置段落间距。"""
+    pf = paragraph.paragraph_format
+    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+    pf.line_spacing = line_spacing
+    pf.space_before = Pt(space_before * BODY_FONT_SIZE)
+    pf.space_after = Pt(space_after * BODY_FONT_SIZE)
+
+
+def _run_format(run, font_name: str = FONT_NAME, font_size: int = BODY_FONT_SIZE,
+                bold: bool = False, color: Optional[str] = None):
+    """设置 run 的字体格式。"""
+    run.font.name = font_name
+    run.font.size = Pt(font_size)
+    run.font.bold = bold
+    run._element.rPr.rFonts.set(qn("w:eastAsia"), font_name)
+    if color:
+        run.font.color.rgb = RGBColor(*bytes.fromhex(color.lstrip("#")))
+
+
+def _setup_page(doc: Document):
+    """设置页面格式:A4、2cm页边距。"""
+    section = doc.sections[0]
+    section.page_width = Cm(PAGE_WIDTH_CM)
+    section.page_height = Cm(PAGE_HEIGHT_CM)
+    section.top_margin = Cm(MARGIN_CM)
+    section.bottom_margin = Cm(MARGIN_CM)
+    section.left_margin = Cm(MARGIN_CM)
+    section.right_margin = Cm(MARGIN_CM)
+
+
+def _add_footer_page_number(doc: Document):
+    """添加页脚页码。"""
+    section = doc.sections[0]
+    footer = section.footer
+    footer.is_linked_to_previous = False
+    p = footer.paragraphs[0]
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    # "第 X 页 / 共 Y 页"
+    run1 = p.add_run("第 ")
+    _run_format(run1, font_size=10)
+
+    fld_char1 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>')
+    run2 = p.add_run()
+    run2._element.append(fld_char1)
+
+    instr = parse_xml(f'<w:instrText {nsdecls("w")} xml:space="preserve"> PAGE </w:instrText>')
+    run3 = p.add_run()
+    run3._element.append(instr)
+
+    fld_char2 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>')
+    run4 = p.add_run()
+    run4._element.append(fld_char2)
+
+    run5 = p.add_run(" 页 / 共 ")
+    _run_format(run5, font_size=10)
+
+    fld_char3 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>')
+    run6 = p.add_run()
+    run6._element.append(fld_char3)
+
+    instr2 = parse_xml(f'<w:instrText {nsdecls("w")} xml:space="preserve"> NUMPAGES </w:instrText>')
+    run7 = p.add_run()
+    run7._element.append(instr2)
+
+    fld_char4 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>')
+    run8 = p.add_run()
+    run8._element.append(fld_char4)
+
+    run9 = p.add_run(" 页")
+    _run_format(run9, font_size=10)
+
+
+def _create_cover_page(doc: Document, title: str, date_str: str = ""):
+    """创建封面页。
+
+    封面排版:居中,从上到下依次为:
+    - "投标文件"(大号标题)
+    - 项目名称
+    - 编制日期
+    """
+    import datetime
+    if not date_str:
+        date_str = datetime.date.today().strftime("%Y年%m月%d日")
+
+    # 上部留白
+    for _ in range(6):
+        doc.add_paragraph()
+
+    # 大标题:投标文件
+    p_title = doc.add_paragraph()
+    p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = p_title.add_run("投 标 文 件")
+    _run_format(run, font_name=FONT_NAME, font_size=28, bold=True)
+
+    # 装饰线
+    p_line = doc.add_paragraph()
+    p_line.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run_line = p_line.add_run("━" * 20)
+    _run_format(run_line, font_size=14, color="333333")
+
+    doc.add_paragraph()
+
+    # 项目名称
+    p_proj = doc.add_paragraph()
+    p_proj.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run_proj = p_proj.add_run(f"项目名称:{title}")
+    _run_format(run_proj, font_size=16)
+
+    doc.add_paragraph()
+    doc.add_paragraph()
+
+    # 编制日期
+    p_date = doc.add_paragraph()
+    p_date.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run_date = p_date.add_run(f"编制日期:{date_str}")
+    _run_format(run_date, font_size=14)
+
+    # 分页
+    doc.add_page_break()
+
+
+def _add_heading(doc: Document, text: str, level: int = 1):
+    """添加格式化标题。
+
+    使用预定义的字体大小:Heading1=16pt, Heading2=14pt, Heading3=12pt。
+    """
+    heading = doc.add_heading(text, level=min(level, 3))
+    font_size = HEADING_FONTS.get(min(level, 3), 12)
+    for run in heading.runs:
+        run.font.name = FONT_NAME
+        run.font.size = Pt(font_size)
+        run._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_NAME)
+    return heading
+
+
+def _add_body_paragraph(doc: Document, text: str):
+    """添加格式化正文段落。"""
+    p = doc.add_paragraph()
+    p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
+    _set_paragraph_spacing(p)
+
+    # 处理内联格式:**粗体**
+    parts = re.split(r'(\*\*.+?\*\*)', text)
+    for part in parts:
+        if part.startswith("**") and part.endswith("**"):
+            run = p.add_run(part[2:-2])
+            _run_format(run, bold=True)
+        else:
+            run = p.add_run(part)
+            _run_format(run)
+
+    p.style = doc.styles["Normal"]
+    return p
+
+
+def _add_bullet_list(doc: Document, items: list[str]):
+    """添加无序列表。"""
+    for item in items:
+        p = doc.add_paragraph(style="List Bullet")
+        _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
+        p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
+        run = p.add_run(item.strip())
+        _run_format(run)
+
+
+def _add_numbered_list(doc: Document, items: list[str]):
+    """添加有序列表。"""
+    for item in items:
+        p = doc.add_paragraph(style="List Number")
+        _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
+        p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
+        run = p.add_run(item.strip())
+        _run_format(run)
+
+
+def _parse_markdown_table(md_table: str) -> list[list[str]]:
+    """解析Markdown表格为二维列表。
+
+    Args:
+        md_table: Markdown表格文本
+
+    Returns:
+        list[list[str]]: 二维字符串列表,第一行为表头
+    """
+    lines = [line.strip() for line in md_table.strip().split("\n")]
+    lines = [line for line in lines if line and not line.startswith("| ---") and not line.startswith("|---")]
+
+    if not lines:
+        return []
+
+    table_data = []
+    for line in lines:
+        line = line.strip()
+        if line.startswith("|"):
+            line = line[1:]
+        if line.endswith("|"):
+            line = line[:-1]
+        cells = [cell.strip() for cell in line.split("|")]
+        table_data.append(cells)
+
+    return table_data
+
+
+def _write_table(doc: Document, table_lines: list[str]):
+    """将Markdown表格写入DOCX表格。"""
+    table_text = "\n".join(table_lines)
+    data = _parse_markdown_table(table_text)
+
+    if not data or len(data) < 2:
+        logger.warning("表格数据不足,跳过表格写入")
+        return
+
+    rows = len(data)
+    cols = max(len(row) for row in data)
+
+    doc_table = doc.add_table(rows=rows, cols=cols)
+    doc_table.autofit = True
+    doc_table.alignment = WD_TABLE_ALIGNMENT.CENTER
+
+    # 设置表格边框
+    tbl = doc_table._tbl
+    tblPr = tbl.tblPr if tbl.tblPr is not None else parse_xml(
+        f'<w:tblPr {nsdecls("w")}/>'
+    )
+    borders = parse_xml(
+        f'<w:tblBorders {nsdecls("w")}>'
+        '  <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '  <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
+        '</w:tblBorders>'
+    )
+    tblPr.append(borders)
+
+    for r_idx, row_data in enumerate(data):
+        for c_idx in range(cols):
+            cell_text = row_data[c_idx] if c_idx < len(row_data) else ""
+            cell = doc_table.cell(r_idx, c_idx)
+
+            # 清空并重新设置内容
+            cell.text = ""
+            p = cell.paragraphs[0]
+            p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+            run = p.add_run(cell_text)
+            is_header = r_idx == 0
+            _run_format(run, font_size=10, bold=is_header)
+
+    doc.add_paragraph()  # 表后空行
+
+
+def _write_markdown_content(doc, markdown_text: str, base_level: int = 2):
+    """将Markdown格式的内容写入DOCX。
+
+    支持的Markdown元素:
+    - # 标题 → Heading样式
+    - **粗体** → 粗体
+    - 普通段落 → Normal样式
+    - - 列表项 → 无序列表
+    - 1. 列表项 → 有序列表
+    - | 表格 | → DOCX表格
+    - 空行 → 段落分隔
+
+    Args:
+        doc: Document对象
+        markdown_text: Markdown格式的文本
+        base_level: 基础标题层级偏移(默认2,即# -> Heading 2)
+    """
+    lines = markdown_text.split("\n")
+
+    i = 0
+    in_table = False
+    table_lines = []
+    in_code_block = False
+    code_lines = []
+
+    while i < len(lines):
+        line = lines[i]
+        stripped = line.strip()
+
+        # 代码块处理
+        if stripped.startswith("```"):
+            if in_code_block:
+                # 结束代码块,作为普通段落写入
+                for code_line in code_lines:
+                    _add_body_paragraph(doc, code_line)
+                code_lines = []
+                in_code_block = False
+            else:
+                in_code_block = True
+            i += 1
+            continue
+        if in_code_block:
+            code_lines.append(line)
+            i += 1
+            continue
+
+        # 表格处理:连续以 | 开头的行
+        # 判断是否为 Markdown 表格分隔行(如 |---|---| 或 |:---|:---:|)
+        _is_separator = bool(re.match(r'^\|[\s\-:]+\|?$', stripped))
+
+        if stripped.startswith("|") and not _is_separator:
+            in_table = True
+            table_lines.append(line)
+            i += 1
+            continue
+        elif _is_separator:
+            # 分隔行,跳过
+            i += 1
+            continue
+        else:
+            if in_table:
+                # 表格结束,写入表格
+                _write_table(doc, table_lines)
+                table_lines = []
+                in_table = False
+
+        # 空行
+        if not stripped:
+            i += 1
+            continue
+
+        # 标题
+        heading_match = re.match(r'^(#{1,6})\s+(.+)$', stripped)
+        if heading_match:
+            level = min(len(heading_match.group(1)) + base_level - 1, 3)
+            # level最小为1
+            level = max(level, 1)
+            title_text = heading_match.group(2)
+
+            # 去掉可能的内联格式标记(标题中保留文字)
+            title_text = re.sub(r'\*\*(.+?)\*\*', r'\1', title_text)
+
+            _add_heading(doc, title_text, level)
+            i += 1
+            continue
+
+        # 无序列表
+        if stripped.startswith("- ") or stripped.startswith("* "):
+            items = []
+            while i < len(lines):
+                s = lines[i].strip()
+                if s.startswith("- ") or s.startswith("* "):
+                    items.append(s[2:])
+                    i += 1
+                else:
+                    break
+            _add_bullet_list(doc, items)
+            continue
+
+        # 有序列表
+        ordered_match = re.match(r'^\d+\.\s+(.+)$', stripped)
+        if ordered_match:
+            items = []
+            while i < len(lines):
+                s = lines[i].strip()
+                if re.match(r'^\d+\.\s+.+$', s):
+                    items.append(re.match(r'^\d+\.\s+(.+)$', s).group(1))
+                    i += 1
+                else:
+                    break
+            _add_numbered_list(doc, items)
+            continue
+
+        # 普通段落
+        paragraph_text = stripped
+        i += 1
+        # 合并后续非空、非特殊行
+        while i < len(lines):
+            next_line = lines[i]
+            next_stripped = next_line.strip()
+            if not next_stripped:
+                break
+            if next_stripped.startswith("#"):
+                break
+            if next_stripped.startswith("- ") or next_stripped.startswith("* "):
+                break
+            if re.match(r'^\d+\.\s+', next_stripped):
+                break
+            if next_stripped.startswith("|") and not next_stripped.startswith("| ---"):
+                break
+            paragraph_text += " " + next_stripped
+            i += 1
+
+        _add_body_paragraph(doc, paragraph_text)
+
+    # 如果文档以表格结尾
+    if in_table and table_lines:
+        _write_table(doc, table_lines)
+
+
+def write_proposal_docx(content: ProposalDocument, output_path: str) -> str:
+    """将投标内容写入格式化的DOCX文件。
+
+    文档结构:
+    1. 封面("投标文件"大标题 + 项目名称 + 日期)
+    2. 目录
+    3. 投标函(从 sections 中识别)
+    4. 投标概要(content.summary)
+    5. 正文章节(按层级组织)
+    6. 页脚页码
+
+    Args:
+        content: 投标文档内容(ProposalDocument)
+        output_path: 输出文件路径(应以 .docx 结尾)
+
+    Returns:
+        str: 输出文件路径
+
+    Raises:
+        ValueError: 如果 content 为空或 output_path 不合法
+        IOError: 如果文件写入失败
+    """
+    if not content:
+        raise ValueError("投标内容不能为空")
+    if not output_path:
+        raise ValueError("输出路径不能为空")
+
+    logger.info("开始生成DOCX文档:%s", output_path)
+
+    doc = Document()
+    _setup_page(doc)
+
+    # 从 sections 中分离出投标函和内容章节
+    cover_letter_section = None
+    content_sections: list[ProposalSection] = []
+    for s in content.sections:
+        if s.title == "投标函" and cover_letter_section is None:
+            cover_letter_section = s
+        else:
+            content_sections.append(s)
+
+    # 1. 封面
+    _create_cover_page(doc, content.title or content.project_name or "投标文件")
+
+    # 2. 目录
+    _add_heading(doc, "目 录", 1)
+    toc_num = 1
+    if cover_letter_section:
+        p = doc.add_paragraph(f"{_to_chinese_number(toc_num)}、{cover_letter_section.title}")
+        _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
+        toc_num += 1
+    if content.summary:
+        p = doc.add_paragraph(f"{_to_chinese_number(toc_num)}、编制说明")
+        _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
+        toc_num += 1
+    for s in content_sections:
+        indent = "  " * (s.level - 1) if s.level > 1 else ""
+        p = doc.add_paragraph(f"{indent}{s.title}")
+        _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
+    doc.add_page_break()
+
+    # 3. 投标函
+    section_counter = 1
+    if cover_letter_section:
+        _add_heading(doc, f"{_to_chinese_number(section_counter)}、{cover_letter_section.title}", 1)
+        section_counter += 1
+        _write_markdown_content(doc, cover_letter_section.content, base_level=2)
+        doc.add_page_break()
+
+    # 4. 投标概要
+    if content.summary:
+        _add_heading(doc, f"{_to_chinese_number(section_counter)}、编制说明", 1)
+        section_counter += 1
+        _write_markdown_content(doc, content.summary, base_level=2)
+        doc.add_page_break()
+
+    # 5. 各章节
+    for section in content_sections:
+        numbered_title = f"{_to_chinese_number(section_counter)}、{section.title}"
+        section_counter += 1
+        _add_heading(doc, numbered_title, 1)
+        _write_markdown_content(doc, section.content, base_level=2)
+
+    # 6. 页脚页码
+    _add_footer_page_number(doc)
+
+    try:
+        doc.save(output_path)
+    except (OSError, IOError) as e:
+        raise OSError(
+            f"无法保存DOCX文件到路径: {output_path}\n"
+            f"请检查目录是否存在、磁盘空间是否充足。\n"
+            f"原始错误: {e}"
+        ) from e
+    logger.info("DOCX文档已保存至: %s", output_path)
+    return output_path
+
+
+def _to_chinese_number(n: int) -> str:
+    """将阿拉伯数字转为中文数字(一、二、三...)。
+
+    Args:
+        n: 阿拉伯数字
+
+    Returns:
+        str: 中文数字
+    """
+    chinese_nums = ["〇", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十",
+                    "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十"]
+    if 1 <= n <= len(chinese_nums):
+        return chinese_nums[n]
+    return str(n)

+ 302 - 0
bid_proposal/requirement_analyzer.py

@@ -0,0 +1,302 @@
+"""
+requirement_analyzer.py - 招标要求分析模块
+
+使用 OpenAI API 分析招标文件内容,识别并分类其中的各项要求。
+"""
+
+import json
+import logging
+import time
+from typing import Optional
+
+from .config import get_config
+from .models import AnalysisResult, BidDocument, Requirement
+from .utils import chunk_text, table_to_markdown
+
+logger = logging.getLogger(__name__)
+
+
+class AnalysisError(Exception):
+    """AI 分析失败异常"""
+    pass
+
+
+class RequirementAnalyzer:
+    """招标文件需求分析器。
+
+    使用 OpenAI 分析招标文件中的各项要求,包括评分标准、服务要求、
+    资质要求等,返回结构化的分析结果。
+    """
+
+    CATEGORY_MAP = {
+        "evaluation": "评审标准",
+        "service": "服务要求",
+        "qualification": "资质要求",
+        "other": "其他",
+    }
+    REVERSE_CATEGORY_MAP = {v: k for k, v in CATEGORY_MAP.items()}
+
+    def __init__(self, api_key: Optional[str] = None, model: str = "gpt-4o"):
+        """初始化分析器。
+
+        Args:
+            api_key: OpenAI API Key,默认从环境变量读取
+            model: 使用的模型名称
+        """
+        try:
+            from openai import OpenAI
+        except ImportError:
+            raise ImportError("请安装 openai: pip install openai")
+
+        key = api_key or get_config().openai_api_key
+        if not key:
+            raise ValueError(
+                "未提供 OpenAI API Key。请通过参数传入或设置 OPENAI_API_KEY 环境变量。"
+            )
+        self.client = OpenAI(api_key=key)
+        self.model = model
+
+    def analyze(self, doc: BidDocument) -> AnalysisResult:
+        """对招标文件进行 AI 分析,提取所有要求。
+
+        Args:
+            doc: 招标文件结构化内容
+
+        Returns:
+            AnalysisResult: 分析结果(结构化需求列表 + 摘要)
+
+        Raises:
+            AnalysisError: AI 分析失败
+        """
+        full_text = doc.full_text.strip()
+        if not full_text:
+            raise AnalysisError("招标文件内容为空,无法进行分析")
+
+        logger.info("开始分析招标文件: %s (%d 字符)", doc.pdf_path, len(full_text))
+
+        config = get_config()
+        max_length = config.max_text_length
+
+        if len(full_text) <= max_length:
+            prompt = self._build_prompt(doc)
+            result = self._call_api(prompt)
+            logger.info(
+                "分析完成: 评审标准 %d 项, 服务要求 %d 项, 资质要求 %d 项, 其他 %d 项",
+                len(result.evaluation_criteria),
+                len(result.service_requirements),
+                len(result.qualification_requirements),
+                len(result.other_requirements),
+            )
+            return result
+
+        # 文本过长,分块处理
+        logger.info("文本过长 (%d 字符,超过 %d),进行分块分析", len(full_text), max_length)
+        return self._analyze_by_chunks(doc, full_text, max_length)
+
+    def _analyze_by_chunks(
+        self, doc: BidDocument, full_text: str, max_length: int
+    ) -> AnalysisResult:
+        """将长文本分块后分别分析,再合并结果。"""
+        text_chunks = chunk_text(full_text, max_length)
+        logger.info("文本已切分为 %d 个块", len(text_chunks))
+
+        all_reqs: list[Requirement] = []
+        summaries: list[str] = []
+        project_names: list[str] = []
+
+        for i, chunk in enumerate(text_chunks):
+            logger.info("分析第 %d/%d 块...", i + 1, len(text_chunks))
+            partial_prompt = self._build_chunk_prompt(chunk, i + 1, len(text_chunks))
+            try:
+                result = self._call_api(partial_prompt)
+                all_reqs.extend(result.all_requirements)
+                summaries.append(result.summary)
+                if result.project_name:
+                    project_names.append(result.project_name)
+            except AnalysisError as e:
+                logger.warning("第 %d 块分析失败: %s,跳过该块", i + 1, e)
+
+        # 合并结果,按类别分类
+        combined = AnalysisResult(
+            summary="\n".join(summaries),
+            project_name=project_names[0] if project_names else "",
+            evaluation_criteria=[r for r in all_reqs if r.category == "evaluation"],
+            service_requirements=[r for r in all_reqs if r.category == "service"],
+            qualification_requirements=[r for r in all_reqs if r.category == "qualification"],
+            other_requirements=[r for r in all_reqs if r.category == "other"],
+        )
+        logger.info(
+            "分块分析完成: %d 项要求",
+            combined.total_requirements,
+        )
+        return combined
+
+    def _build_chunk_prompt(self, chunk: str, chunk_num: int, total_chunks: int) -> str:
+        """为单个文本块构建分析 prompt。"""
+        return f"""=== 招标文件内容(第 {chunk_num}/{total_chunks} 部分)===
+
+{chunk}
+
+---
+
+请分析以上招标文件内容片段,提取其中包含的所有要求。"""
+
+    def _build_prompt(self, doc: BidDocument) -> str:
+        """构建包含招标文件完整内容的 prompt。
+
+        将文本和表格按页组织,方便 AI 理解内容结构。
+        """
+        sections: list[str] = ["=== 招标文件内容 ==="]
+
+        # 构建表格索引:页码 → 表格索引
+        table_idx_by_page: dict[int, list[int]] = {}
+        for ti, table in enumerate(doc.tables):
+            page_num = getattr(table, 'page_num', 0) + 1  # page_num是0-indexed
+            table_idx_by_page.setdefault(page_num, []).append(ti)
+
+        for page in doc.pages:
+            page_num = page.page_num
+            sections.append(f"\n--- 第 {page_num} 页 ---")
+
+            if page.text:
+                sections.append(page.text)
+            else:
+                sections.append("(本页无文本内容)")
+
+            # 添加本页中的表格
+            page_table_indices = table_idx_by_page.get(page_num, [])
+            for ti in page_table_indices:
+                if ti < len(doc.tables):
+                    table = doc.tables[ti]
+                    md_table = table_to_markdown(table)
+                    if md_table and md_table != "(空表格)":
+                        sections.append(f"\n本页中的表格:\n{md_table}")
+
+        return "\n".join(sections)
+
+    def _call_api(self, user_prompt: str) -> AnalysisResult:
+        """调用 OpenAI API 进行需求分析,使用结构化输出。"""
+        system_prompt = """你是一个专业的招标文件分析专家。请仔细阅读招标文件内容,提取并分类其中的所有要求。
+
+请识别以下类别的要求:
+1. **evaluation(评审标准)**:评审因素、评分项、分值分配、评审标准等
+2. **service(服务要求)**:服务范围、服务内容、服务标准、人员配置等
+3. **qualification(资质要求)**:投标人资格条件、资质证书、注册资金、业绩要求等
+4. **other(其他)**:商务条款、技术规范、预算金额、付款方式、服务期限等
+
+对于每一项要求,尽可能提取其标题(title)、详细描述(description)、分值(score)和评分标准详情(detail)。"""
+
+        max_retries = 2
+        last_error: Optional[Exception] = None
+
+        for attempt in range(max_retries + 1):
+            try:
+                response = self.client.chat.completions.create(
+                    model=self.model,
+                    messages=[
+                        {"role": "system", "content": system_prompt},
+                        {"role": "user", "content": user_prompt},
+                    ],
+                    temperature=get_config().openai_temperature,
+                    max_tokens=get_config().openai_max_tokens,
+                    timeout=get_config().openai_timeout,
+                    response_format={
+                        "type": "json_schema",
+                        "json_schema": {
+                            "name": "analysis_result",
+                            "strict": True,
+                            "schema": {
+                                "type": "object",
+                                "properties": {
+                                    "summary": {
+                                        "type": "string",
+                                        "description": "招标文件概要总结,不超过300字",
+                                    },
+                                    "project_name": {
+                                        "type": "string",
+                                        "description": "项目名称",
+                                    },
+                                    "requirements": {
+                                        "type": "array",
+                                        "items": {
+                                            "type": "object",
+                                            "properties": {
+                                                "category": {
+                                                    "type": "string",
+                                                    "enum": ["evaluation", "service", "qualification", "other"],
+                                                    "description": "要求类别",
+                                                },
+                                                "title": {
+                                                    "type": "string",
+                                                    "description": "要求标题",
+                                                },
+                                                "description": {
+                                                    "type": "string",
+                                                    "description": "详细描述",
+                                                },
+                                                "score": {
+                                                    "type": ["number", "null"],
+                                                    "description": "分值(如明确有分值)",
+                                                },
+                                                "detail": {
+                                                    "type": "string",
+                                                    "description": "评分标准详细说明",
+                                                },
+                                            },
+                                            "required": ["category", "title", "description", "score", "detail"],
+                                            "additionalProperties": False,
+                                        },
+                                    },
+                                },
+                                "required": ["summary", "project_name", "requirements"],
+                                "additionalProperties": False,
+                            },
+                        },
+                    },
+                )
+
+                content = response.choices[0].message.content
+                if not content:
+                    raise AnalysisError("API 返回了空响应")
+
+                data = json.loads(content)
+
+                # 按类别分类构建 Requirement
+                all_reqs: list[Requirement] = []
+                for req in data.get("requirements", []):
+                    category = req.get("category", "other")
+                    # 校验category合法性
+                    if category not in ("evaluation", "service", "qualification", "other"):
+                        category = "other"
+                    all_reqs.append(Requirement(
+                        category=category,
+                        title=req.get("title", ""),
+                        description=req.get("description", ""),
+                        score=req.get("score"),
+                        detail=req.get("detail", ""),
+                    ))
+
+                result = AnalysisResult(
+                    summary=data.get("summary", ""),
+                    project_name=data.get("project_name", ""),
+                    evaluation_criteria=[r for r in all_reqs if r.category == "evaluation"],
+                    service_requirements=[r for r in all_reqs if r.category == "service"],
+                    qualification_requirements=[r for r in all_reqs if r.category == "qualification"],
+                    other_requirements=[r for r in all_reqs if r.category == "other"],
+                )
+                logger.debug("API 分析成功: %d 项要求", result.total_requirements)
+                return result
+
+            except Exception as e:
+                last_error = e
+                if attempt < max_retries:
+                    wait = 2 ** (attempt + 1)
+                    logger.warning(
+                        "API 调用失败(第 %d 次),%ds 后重试: %s",
+                        attempt + 1, wait, e,
+                    )
+                    time.sleep(wait)
+                else:
+                    logger.error("API 调用最终失败: %s", e)
+
+        raise AnalysisError(f"AI 分析失败,已重试 {max_retries} 次: {last_error}")

+ 501 - 0
bid_proposal/task_a1.md

@@ -0,0 +1,501 @@
+# A1 工作包:招标文件读取与需求分析
+
+## 任务概述
+
+实现 `bid_proposal` 包的前半部分:招标文件读取(`bid_reader.py`)和需求分析(`requirement_analyzer.py`),以及两个模块共享的基础设施(`models.py`、`config.py`、`__init__.py`)。
+
+## 输出文件
+
+| 文件 | 说明 |
+|------|------|
+| `bid_proposal/__init__.py` | 包入口(导出所有公共 API) |
+| `bid_proposal/models.py` | 所有数据类定义 + 自定义异常 |
+| `bid_proposal/config.py` | 配置管理(API Key、模型名) |
+| `bid_proposal/bid_reader.py` | 招标文件读取 |
+| `bid_proposal/requirement_analyzer.py` | 需求分析 |
+
+---
+
+## 模块实现详细要求
+
+### 1. `models.py` — 数据类定义
+
+定义以下数据类(使用 `from __future__ import annotations`):
+
+```python
+from __future__ import annotations
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+# ---- 异常层次 ----
+class BidProposalError(Exception):
+    """所有 bid_proposal 异常的基类。"""
+    pass
+
+class BidReadError(BidProposalError):
+    """PDF 读取/解析失败。"""
+    pass
+
+class AnalysisError(BidProposalError):
+    """AI 分析失败(API 错误、响应格式异常等)。"""
+    pass
+
+class GenerationError(BidProposalError):
+    """内容生成失败。"""
+    pass
+
+class WriteError(BidProposalError):
+    """DOCX 输出失败。"""
+    pass
+
+
+# ---- 招标文件数据结构 ----
+@dataclass
+class BidPage:
+    """招标文件中的一页"""
+    page_num: int           # 页码(1-indexed)
+    text: str               # 页面文本内容
+
+
+@dataclass
+class BidDocument:
+    """招标文件结构化内容"""
+    pdf_path: str                       # PDF 文件路径
+    file_name: str                      # 文件名(不含路径)
+    total_pages: int                    # PDF 总页数
+    pages: list[BidPage]               # 所有页面文本
+    full_text: str                      # 全文拼接文本(供 AI 分析使用)
+    tables: list                        # list[TableInfo] — 来自 pdf_table_to_docx
+    table_count: int                    # 表格数量
+
+
+# ---- 需求分析数据结构 ----
+@dataclass
+class Requirement:
+    """单个招标要求项"""
+    category: str                       # 类别: evaluation / service / qualification / other
+    title: str                          # 要求标题
+    description: str                    # 详细描述
+    score: Optional[float] = None       # 分值(评分项专用)
+    detail: str = ""                    # 评分标准详细说明
+
+
+@dataclass
+class AnalysisResult:
+    """招标文件分析结果"""
+    summary: str                                    # 招标概要
+    project_name: str = ""                          # 项目名称
+    evaluation_criteria: list[Requirement] = field(default_factory=list)   # 评审标准(评分项)
+    service_requirements: list[Requirement] = field(default_factory=list)  # 服务要求
+    qualification_requirements: list[Requirement] = field(default_factory=list)  # 资质要求
+    other_requirements: list[Requirement] = field(default_factory=list)    # 其他要求
+    raw_response: str = ""                          # AI 原始响应内容(调试用)
+
+
+# ---- 投标内容数据结构(A2 也会用到,由 A1 先行定义) ----
+@dataclass
+class ProposalSection:
+    """投标文件的一个章节"""
+    title: str                          # 章节标题
+    level: int = 1                      # 层级(1=一级标题, 2=二级标题, ...)
+    content: str = ""                   # 章节正文
+    requirement_ref: str = ""           # 对应的招标要求引用
+
+
+@dataclass
+class ProposalDocument:
+    """完整的投标文件内容"""
+    title: str                          # 文档标题
+    project_name: str = ""              # 项目名称
+    sections: list[ProposalSection] = field(default_factory=list)  # 所有章节
+    summary: str = ""                   # 投标概要
+```
+
+### 2. `config.py` — 配置管理
+
+```python
+import os
+
+
+class Config:
+    """配置管理。"""
+    
+    @staticmethod
+    def get_openai_api_key() -> str:
+        """获取 OpenAI API Key。
+        
+        Returns:
+            str: API Key
+        
+        Raises:
+            ValueError: 未设置 OPENAI_API_KEY 环境变量
+        """
+        api_key = os.environ.get("OPENAI_API_KEY")
+        if not api_key:
+            raise ValueError(
+                "未设置 OPENAI_API_KEY 环境变量。\n"
+                "请通过 export OPENAI_API_KEY='sk-...' 设置"
+            )
+        return api_key
+    
+    @staticmethod
+    def get_default_model() -> str:
+        """获取默认模型名。"""
+        return os.environ.get("OPENAI_MODEL", "gpt-4o")
+```
+
+### 3. `bid_reader.py` — 招标文件读取
+
+#### 核心函数
+
+```python
+def read_bid_pdf(pdf_path: str) -> BidDocument:
+    """读取招标 PDF,提取文本和表格。
+    
+    流程:
+    1. 使用 pymupdf (fitz) 逐页提取文本
+    2. 使用 pdf_table_to_docx.PDFTableExtractor 提取表格
+    3. 合并为 BidDocument 返回
+    
+    Args:
+        pdf_path: 招标文件 PDF 路径
+    
+    Returns:
+        BidDocument: 结构化招标内容(含文本 + 表格)
+    
+    Raises:
+        FileNotFoundError: PDF 文件不存在
+        BidReadError: PDF 读取/解析失败
+    """
+```
+
+#### 实现步骤
+
+1. **验证输入**:
+   ```python
+   if not os.path.exists(pdf_path):
+       raise FileNotFoundError(f"PDF 文件不存在: {pdf_path}")
+   ```
+
+2. **提取文本**(使用 pymupdf):
+   ```python
+   import fitz  # pymupdf Python 绑定
+   
+   doc = fitz.open(pdf_path)
+   pages_data = []
+   all_text_parts = []
+   
+   for page_num in range(doc.page_count):
+       page = doc[page_num]
+       text = page.get_text("text")
+       pages_data.append(BidPage(page_num=page_num + 1, text=text))
+       all_text_parts.append(text)
+   
+   full_text = "\n".join(all_text_parts)
+   ```
+
+3. **提取表格**(复用 pdf_table_to_docx):
+   ```python
+   from pdf_table_to_docx.extractor import PDFTableExtractor
+   
+   try:
+       extractor = PDFTableExtractor(pdf_path)
+       tables = extractor.extract()
+       table_count = len(tables)
+   except Exception as e:
+       logger.warning(f"表格提取失败(不影响文本提取): {e}")
+       tables = []
+       table_count = 0
+   ```
+
+4. **构造返回**:
+   ```python
+   return BidDocument(
+       pdf_path=os.path.abspath(pdf_path),
+       file_name=os.path.basename(pdf_path),
+       total_pages=len(pages_data),
+       pages=pages_data,
+       full_text=full_text,
+       tables=tables,
+       table_count=table_count,
+   )
+   ```
+
+#### 注意事项
+
+- **编码问题**:中文 PDF 需要确保 text 提取正确,pymupdf 默认处理 UTF-8
+- **大型 PDF**:超大文件(>100MB)注意内存使用
+- **表格提取失败**:降级处理,仅返回文本内容,记录警告日志
+- **加密 PDF**:`fitz.open()` 对加密 PDF 需要密码,捕获异常抛 `BidReadError`
+
+#### 日志记录
+
+```python
+import logging
+logger = logging.getLogger(__name__)
+# 记录关键步骤:文件大小、页数、表格数等
+```
+
+### 4. `requirement_analyzer.py` — 需求分析
+
+#### 核心类
+
+```python
+from openai import OpenAI
+from .config import Config
+from .models import BidDocument, AnalysisResult, Requirement, AnalysisError
+
+
+class RequirementAnalyzer:
+    """招标文件需求分析器。"""
+    
+    def __init__(self, api_key: str | None = None, model: str = "gpt-4o"):
+        """初始化分析器。
+        
+        Args:
+            api_key: OpenAI API Key,默认从环境变量读取
+            model: 使用的模型名称(默认 gpt-4o)
+        
+        Raises:
+            ValueError: API Key 未提供且环境变量未设置
+        """
+        api_key = api_key or Config.get_openai_api_key()
+        self.client = OpenAI(api_key=api_key, timeout=120)
+        self.model = model
+    
+    def analyze(self, doc: BidDocument) -> AnalysisResult:
+        """对招标文件进行 AI 分析,提取所有要求。
+        
+        Args:
+            doc: 招标文件结构化内容
+        
+        Returns:
+            AnalysisResult: 分析结果(结构化需求列表 + 摘要)
+        
+        Raises:
+            AnalysisError: AI 分析失败
+        """
+```
+
+#### System Prompt 设计
+
+```python
+SYSTEM_PROMPT = """你是一个专业的招标文件分析专家。请分析以下招标文件内容,提取并分类所有要求。
+
+请严格按照以下 JSON Schema 输出(仅输出 JSON,不要添加任何其他文字):
+
+{
+  "project_name": "项目名称(字符串)",
+  "summary": "招标内容概要,不超过200字(字符串)",
+  "evaluation_criteria": [
+    {
+      "title": "评审项名称",
+      "description": "详细要求描述",
+      "score": 分值(数字,或null如果没有明确分值),
+      "detail": "评分标准详细说明"
+    }
+  ],
+  "service_requirements": [
+    {
+      "title": "服务要求名称",
+      "description": "详细要求描述",
+      "score": null,
+      "detail": ""
+    }
+  ],
+  "qualification_requirements": [...],
+  "other_requirements": [...]
+}
+
+分类说明:
+- evaluation(评审标准):投标评审表中的评分项,通常带有明确分值
+- service(服务要求):需要投标人响应的具体服务内容或技术需求
+- qualification(资质要求):投标人必须满足的资格条件,如证书、注册资金等
+- other(其他要求):不属于以上三类的其他要求
+
+注意:
+- 尽量全面地提取所有要求,不要遗漏重要内容
+- description 应尽量保留原文的关键信息
+- 没有明确分值的项,score 设为 null
+- 每个类别下的条目数量不限,按实际内容提取"""
+```
+
+#### 实现步骤
+
+1. **构造用户消息**:
+   ```python
+   # 将全文发送给 AI
+   user_message = f"请分析以下招标文件:\n\n{doc.full_text}"
+   
+   # 如果文件过大(>100K字符),只发送前 100K 字符
+   # 并在末尾说明截断
+   MAX_CHARS = 100000
+   if len(user_message) > MAX_CHARS:
+       user_message = user_message[:MAX_CHARS]
+       user_message += "\n\n[注意:原文过长,以上为前 100K 字符的截取内容]"
+   ```
+
+2. **调用 OpenAI API**:
+   ```python
+   with retry(max_attempts=3, delay=1.0, backoff=2.0):
+       response = self.client.chat.completions.create(
+           model=self.model,
+           messages=[
+               {"role": "system", "content": SYSTEM_PROMPT},
+               {"role": "user", "content": user_message},
+           ],
+           response_format={"type": "json_object"},
+           temperature=0.3,
+       )
+   ```
+
+3. **解析响应**:
+   ```python
+   raw = response.choices[0].message.content
+   data = json.loads(raw)
+   
+   evaluation = [Requirement(category="evaluation", **item) for item in data.get("evaluation_criteria", [])]
+   service = [Requirement(category="service", **item) for item in data.get("service_requirements", [])]
+   qualification = [Requirement(category="qualification", **item) for item in data.get("qualification_requirements", [])]
+   other = [Requirement(category="other", **item) for item in data.get("other_requirements", [])]
+   
+   return AnalysisResult(
+       summary=data.get("summary", ""),
+       project_name=data.get("project_name", ""),
+       evaluation_criteria=evaluation,
+       service_requirements=service,
+       qualification_requirements=qualification,
+       other_requirements=other,
+       raw_response=raw,
+   )
+   ```
+
+4. **重试逻辑**:
+   ```python
+   import time
+   
+   def _call_with_retry(self, messages, max_attempts=3):
+       last_error = None
+       for attempt in range(max_attempts):
+           try:
+               return self.client.chat.completions.create(...)
+           except Exception as e:
+               last_error = e
+               if attempt < max_attempts - 1:
+                   wait = (2 ** attempt)
+                   logger.warning(f"API 调用失败(第{attempt+1}次),{wait}秒后重试: {e}")
+                   time.sleep(wait)
+       raise AnalysisError(f"AI 分析失败(已重试{max_attempts}次): {last_error}")
+   ```
+
+#### 注意事项
+
+- **JSON 解析异常**:有时 AI 返回的 JSON 可能格式不规范,需要做鲁棒性处理
+- **空结果处理**:如果某个分类没有提取到任何项,返回空列表而非 None
+- **长文本截断**:超过 token 限制时做截断处理,并在结果中标注
+- **超时设置**:API 客户端设置 120 秒超时
+- **温度设置**:使用 `temperature=0.3` 保证输出的一致性
+
+---
+
+## 5. `__init__.py` — 包入口
+
+```python
+"""bid_proposal - AI 驱动的投标文件生成工具。
+
+基于招标文件 PDF,自动分析需求并生成投标文件。
+"""
+
+from .models import (
+    BidPage, BidDocument, Requirement, AnalysisResult,
+    ProposalSection, ProposalDocument,
+    BidProposalError, BidReadError, AnalysisError,
+    GenerationError, WriteError,
+)
+from .bid_reader import read_bid_pdf
+from .requirement_analyzer import RequirementAnalyzer
+
+__all__ = [
+    "BidPage", "BidDocument", "Requirement", "AnalysisResult",
+    "ProposalSection", "ProposalDocument",
+    "BidProposalError", "BidReadError", "AnalysisError",
+    "GenerationError", "WriteError",
+    "read_bid_pdf",
+    "RequirementAnalyzer",
+]
+__version__ = "0.2.0"
+```
+
+---
+
+## 接口契约(A1 → A2)
+
+A1 工作包完成后的输出接口:
+
+```python
+# --- 从 models.py ---
+BidDocument          # 招标数据结构
+Requirement          # 需求项
+AnalysisResult       # 分析结果
+ProposalSection      # 投标章节(A2 也会使用)
+ProposalDocument     # 投标文档(A2 也会使用)
+BidProposalError     # 异常基类
+AnalysisError        # 分析异常
+
+# --- 从 bid_reader.py ---
+read_bid_pdf(pdf_path: str) -> BidDocument
+
+# --- 从 requirement_analyzer.py ---
+RequirementAnalyzer(api_key?: str, model?: str)
+RequirementAnalyzer.analyze(doc: BidDocument) -> AnalysisResult
+
+# --- 从 config.py ---
+Config.get_openai_api_key() -> str
+Config.get_default_model() -> str
+```
+
+---
+
+## 验收标准
+
+| # | 标准 | 验证方式 |
+|---|------|---------|
+| 1 | 对测试 PDF 调用 `read_bid_pdf()` 能正确提取文本 | `assert doc.full_text` 非空 |
+| 2 | 对测试 PDF 调用 `read_bid_pdf()` 能提取表格 | `assert doc.table_count > 0` |
+| 3 | 对不存在的 PDF 抛 `FileNotFoundError` | `pytest.raises` |
+| 4 | 对损坏的 PDF 抛 `BidReadError` | `pytest.raises` |
+| 5 | API Key 缺失时抛 `ValueError` | `pytest.raises` |
+| 6 | `RequirementAnalyzer.analyze()` 输出正确的结构 | 验证 `AnalysisResult` 各字段类型 |
+| 7 | 分析结果能区分三类需求 | 检查 `evaluation_criteria` / `service_requirements` / `qualification_requirements` |
+| 8 | Mock API 测试覆盖率 >= 80% | `pytest --cov` |
+
+---
+
+## 测试建议
+
+创建 `bid_proposal/tests/test_a1.py`,包含以下测试:
+
+```python
+# 1. test_read_bid_pdf_success
+#    - 使用测试 PDF 验证提取文本和表格
+# 2. test_read_bid_pdf_not_found
+#    - 文件不存在场景
+# 3. test_requirement_analyzer_success
+#    - Mock OpenAI API 响应,验证分析结果
+# 4. test_requirement_analyzer_api_error
+#    - API 调用失败后的重试行为
+# 5. test_requirement_analyzer_no_api_key
+#    - 未设置 API Key 时的错误提示
+# 6. test_analysis_result_structure
+#    - 分析结果的字段类型验证
+```
+
+---
+
+## 参考文档
+
+- 架构文档:`bid_proposal/ARCHITECTURE.md`
+- 现有代码参考:`pdf_table_to_docx/extractor.py`、`pdf_table_to_docx/table_parser.py`
+- 测试数据:`data/松江区机关事务管理局物业管理服务招标文件.pdf`
+- 相关任务:A2 工作包(`task_a2.md`)

+ 881 - 0
bid_proposal/task_a2.md

@@ -0,0 +1,881 @@
+# A2 工作包:投标内容生成 + 文档输出 + 流程编排
+
+## 任务概述
+
+实现 `bid_proposal` 包的后半部分:投标内容生成(`proposal_generator.py`)、文档输出(`proposal_writer.py`)、流程编排(`pipeline.py`)、命令行入口(`cli.py` / `__main__.py`)。
+
+**先决条件**:A1 工作包已完成,`models.py`、`config.py`、`bid_reader.py`、`requirement_analyzer.py` 已就位。
+
+## 输出文件
+
+| 文件 | 说明 |
+|------|------|
+| `bid_proposal/proposal_generator.py` | 投标内容生成 |
+| `bid_proposal/proposal_writer.py` | 投标文件 DOCX 输出 |
+| `bid_proposal/pipeline.py` | 流程编排 |
+| `bid_proposal/cli.py` | 命令行入口 |
+| `bid_proposal/__main__.py` | `python -m` 支持 |
+
+---
+
+## 模块实现详细要求
+
+### 1. `proposal_generator.py` — 投标内容生成
+
+#### 职责
+
+接收 A1 输出的 `AnalysisResult`(已分析的结构化需求),使用 OpenAI API 逐项生成对应的投标响应内容,输出 `ProposalDocument`。
+
+#### 核心类
+
+```python
+from openai import OpenAI
+from .config import Config
+from .models import (
+    AnalysisResult, Requirement,
+    ProposalSection, ProposalDocument, GenerationError,
+)
+
+
+class ProposalGenerator:
+    """投标内容生成器。"""
+
+    def __init__(self, api_key: str | None = None, model: str = "gpt-4o"):
+        """初始化生成器。
+
+        Args:
+            api_key: OpenAI API Key,默认从环境变量读取
+            model: 使用的模型名称(默认 gpt-4o)
+
+        Raises:
+            ValueError: API Key 未提供且环境变量未设置
+        """
+        api_key = api_key or Config.get_openai_api_key()
+        self.client = OpenAI(api_key=api_key, timeout=120)
+        self.model = model
+
+    def generate(self, analysis: AnalysisResult) -> ProposalDocument:
+        """根据招标分析结果生成投标内容。
+
+        生成策略:
+        1. 根据 AnalysisResult 中各分类下的 Requirement 规划章节结构
+        2. 按分类逐项生成响应内容(每项调用一次 API)
+        3. 合并为 ProposalDocument
+
+        Args:
+            analysis: A1 输出的招标分析结果
+
+        Returns:
+            ProposalDocument: 完整的投标内容
+
+        Raises:
+            GenerationError: 内容生成失败
+        """
+```
+
+#### 章节规划逻辑
+
+```python
+def _build_sections(self, analysis: AnalysisResult) -> list[dict]:
+    """根据分析结果规划章节结构。
+
+    返回每个章节的规划信息:
+    - 章节标题
+    - 对应哪些 Requirement
+    - 章节层级
+    """
+    sections = []
+
+    # 章节 1:项目概述(固定)
+    sections.append({
+        "title": "项目概述",
+        "level": 1,
+        "requirements": [],
+        "description": f"对{analysis.project_name}项目的整体理解和响应承诺",
+    })
+
+    # 章节 2:技术方案响应(对 evaluation_criteria 的响应)
+    if analysis.evaluation_criteria:
+        sections.append({
+            "title": "技术方案",
+            "level": 1,
+            "requirements": analysis.evaluation_criteria,
+            "description": "针对评审标准的逐项响应方案",
+        })
+
+    # 章节 3:服务方案(对 service_requirements 的响应)
+    if analysis.service_requirements:
+        sections.append({
+            "title": "服务方案",
+            "level": 1,
+            "requirements": analysis.service_requirements,
+            "description": "针对招标服务要求的详细服务方案",
+        })
+
+    # 章节 4:资质证明(对 qualification_requirements 的响应)
+    if analysis.qualification_requirements:
+        sections.append({
+            "title": "资质与资格证明",
+            "level": 1,
+            "requirements": analysis.qualification_requirements,
+            "description": "资质响应及证明材料说明",
+        })
+
+    # 章节 5:其他响应(对 other_requirements 的响应)
+    if analysis.other_requirements:
+        sections.append({
+            "title": "其他承诺与说明",
+            "level": 1,
+            "requirements": analysis.other_requirements,
+            "description": "对其他要求的响应说明",
+        })
+
+    return sections
+```
+
+#### Prompt 设计
+
+**生成单个章节的 System Prompt**:
+
+```python
+SYSTEM_PROMPT = """你是一个专业的投标文件撰写专家。请根据招标要求生成对应的投标响应内容。
+
+生成原则:
+1. 内容必须紧扣招标要求,逐条响应
+2. 使用正式、专业的标书语言
+3. 具体而非空泛(提供可量化的承诺、具体方案)
+4. 突出优势,体现对项目的深入理解
+5. 对于有明确分值的评分项,确保响应内容充分覆盖评分要点
+
+输出格式:
+- 每个响应以「针对《XXX》要求」开头
+- 正文使用 Markdown 格式(支持标题 ##、段落、列表 -、表格)
+- 条理清晰,分点阐述"""
+
+def _build_section_prompt(
+    self,
+    section_title: str,
+    section_desc: str,
+    requirements: list[Requirement],
+    project_context: str,
+) -> tuple[str, str]:
+    """构建章节生成的 system prompt 和 user prompt。"""
+
+    reqs_text = ""
+    for i, req in enumerate(requirements, 1):
+        reqs_text += f"\n【要求 {i}】\n"
+        reqs_text += f"标题:{req.title}\n"
+        reqs_text += f"描述:{req.description}\n"
+        if req.score is not None:
+            reqs_text += f"分值:{req.score}分\n"
+        if req.detail:
+            reqs_text += f"评分标准:{req.detail}\n"
+
+    user_message = (
+        f"项目背景:{project_context}\n\n"
+        f"请生成以下章节的投标响应内容:\n"
+        f"章节标题:{section_title}\n"
+        f"章节说明:{section_desc}\n\n"
+        f"需要响应的招标要求:{reqs_text}\n\n"
+        f"请生成完整的章节内容,包含小标题和正文。"
+    )
+
+    return SYSTEM_PROMPT, user_message
+```
+
+#### 实现步骤
+
+```python
+def generate(self, analysis: AnalysisResult) -> ProposalDocument:
+    sections_plan = self._build_sections(analysis)
+    proposal_sections = []
+
+    for plan in sections_plan:
+        if plan["requirements"]:
+            # 有具体需求项,逐项生成
+            sys_prompt, user_msg = self._build_section_prompt(
+                plan["title"],
+                plan["description"],
+                plan["requirements"],
+                f"项目名称:{analysis.project_name}\n招标概况:{analysis.summary}",
+            )
+            try:
+                section_content = self._call_api(sys_prompt, user_msg)
+            except Exception as e:
+                logger.warning(f"章节「{plan['title']}」生成失败: {e}")
+                section_content = "【待人工补充】\n\n该部分内容生成失败,请根据招标要求手动补充。"
+        else:
+            # 无具体需求项(如项目概述),生成概要内容
+            section_content = self._generate_overview(analysis)
+
+        proposal_sections.append(ProposalSection(
+            title=plan["title"],
+            level=plan["level"],
+            content=section_content,
+            requirement_ref=", ".join(
+                r.title for r in plan.get("requirements", [])
+            ),
+        ))
+
+    return ProposalDocument(
+        title=f"{analysis.project_name}投标文件",
+        project_name=analysis.project_name,
+        sections=proposal_sections,
+        summary=analysis.summary,
+    )
+```
+
+#### API 调用封装(带重试)
+
+```python
+import time
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _call_api(self, system_prompt: str, user_message: str) -> str:
+    """调用 OpenAI API 生成内容,带重试逻辑。"""
+    last_error = None
+    for attempt in range(3):
+        try:
+            response = self.client.chat.completions.create(
+                model=self.model,
+                messages=[
+                    {"role": "system", "content": system_prompt},
+                    {"role": "user", "content": user_message},
+                ],
+                temperature=0.7,
+                max_tokens=4096,
+            )
+            return response.choices[0].message.content
+        except Exception as e:
+            last_error = e
+            if attempt < 2:
+                wait = 2 ** attempt
+                logger.warning(f"API 调用失败(第{attempt+1}次),{wait}秒后重试: {e}")
+                time.sleep(wait)
+
+    raise GenerationError(f"内容生成失败(已重试3次): {last_error}")
+```
+
+#### 概述章节生成
+
+```python
+def _generate_overview(self, analysis: AnalysisResult) -> str:
+    """生成项目概述章节内容。"""
+    text = (
+        f"## 项目背景\n\n"
+        f"{analysis.summary}\n\n"
+        f"## 响应承诺\n\n"
+        f"我公司对{analysis.project_name}项目进行了深入研究和全面分析,"
+        f"充分理解招标人的需求和评审标准。"
+        f"我公司郑重承诺将严格按照招标文件要求,"
+        f"提供优质的{'/'.join(
+            r.title for r in (
+                analysis.service_requirements
+                + analysis.evaluation_criteria
+            )[:3]
+        )}服务。\n\n"
+        f"## 项目优势\n\n"
+        f"我公司在相关领域具有丰富的经验和专业团队,"
+        f"能够为本项目提供有力保障。"
+    )
+    return text
+```
+
+#### 注意事项
+
+- **逐个章节调用 API**:每个章节独立生成,避免长上下文导致的 token 浪费和关注力衰减
+- **错误隔离**:单个章节生成失败不影响其他章节,失败项输出占位文本
+- **温度设置**:使用 `temperature=0.7` 在创造性写作和一致性之间取得平衡
+- **简要上下文传递**:在后续章节可引用前文结论,保持逻辑一致性
+
+---
+
+### 2. `proposal_writer.py` — 投标文件输出
+
+#### 职责
+
+将 `ProposalDocument` 输出为格式化的 DOCX 投标文件。样式设计符合投标文件的正式格式。
+
+#### 核心函数
+
+```python
+from .models import ProposalDocument, ProposalSection, WriteError
+
+
+def write_proposal_docx(
+    proposal: ProposalDocument,
+    output_path: str,
+    *,
+    page_width_cm: float = 21.0,
+    page_height_cm: float = 29.7,
+    margin_cm: float = 2.5,
+    company_name: str = "投标单位名称",
+) -> str:
+    """将投标内容写入格式化的 DOCX 文件。
+
+    生成的文档结构:
+    ┌────────────────────────────────┐
+    │        投 标 文 件              │  ← 封面(大号居中)
+    │                                │
+    │  项目名称:xxx                 │
+    │  投标单位:xxx                 │
+    │  日    期:xxxx-xx-xx          │
+    ├────────────────────────────────┤
+    │  目  录                         │  ← 目录页
+    ├────────────────────────────────┤
+    │  一、项目概述                   │  ← Heading 1
+    │    ......                      │
+    │  二、技术方案                   │  ← Heading 1
+    │    2.1 评审项响应               │  ← Heading 2
+    │    ......                      │
+    │  三、服务方案                   │  ← Heading 1
+    │    ......                      │
+    │  四、资质与资格证明             │  ← Heading 1
+    │    ......                      │
+    │  五、其他承诺与说明             │  ← Heading 1
+    │    ......                      │
+    └────────────────────────────────┘
+
+    Args:
+        proposal: 投标内容
+        output_path: 输出 DOCX 路径
+        page_width_cm: 页面宽度(默认 A4)
+        page_height_cm: 页面高度(默认 A4)
+        margin_cm: 页边距
+        company_name: 投标单位名称(用于封面)
+
+    Returns:
+        str: 输出文件路径
+
+    Raises:
+        WriteError: DOCX 写入失败
+    """
+```
+
+#### 格式规格(参考 pdf_table_to_docx/docx_writer.py)
+
+1. **页面设置**:
+   - A4(21.0cm x 29.7cm)
+   - 页边距:上下 2.0cm、左右 2.5cm
+
+2. **封面设计**:
+   - 标题"投标文件":居中对齐,28pt,加粗
+   - 项目名称:居中对齐,16pt,加粗
+   - 投标单位、日期:居中对齐,14pt
+   - 用空段落实现垂直居中
+
+3. **正文排版**:
+   - Heading 1:16pt,加粗,段前12pt段后6pt
+   - Heading 2:14pt,加粗,段前6pt段后3pt
+   - 正文:12pt(小四),宋体,1.5倍行距
+   - 首行缩进约 0.75cm(两个字符)
+
+4. **Markdown 渲染**(解析 `section.content` 中的内容):
+   - `# ` / `## ` / `### ` → Heading 1 / 2 / 3
+   - `**文本**` → 加粗
+   - `- item` → 无序列表(符号:圆点)
+   - `1. item` → 有序列表
+   - Markdown 表格 → python-docx 表格(有边框,表头加粗)
+
+5. **页脚**:居中的页码(第 X 页)
+
+#### 实现步骤
+
+```python
+import logging
+import os
+import datetime
+from docx import Document
+from docx.shared import Pt, Cm, Emu
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml.ns import qn, nsdecls
+from docx.oxml import parse_xml
+
+logger = logging.getLogger(__name__)
+
+
+def write_proposal_docx(proposal, output_path, **kwargs):
+    doc = Document()
+
+    # ---- 1. 设置页面 ----
+    section = doc.sections[0]
+    section.page_width = Cm(page_width_cm)
+    section.page_height = Cm(page_height_cm)
+    section.top_margin = Cm(margin_cm)
+    section.bottom_margin = Cm(margin_cm)
+    section.left_margin = Cm(margin_cm)
+    section.right_margin = Cm(margin_cm)
+
+    # ---- 2. 添加封面 ----
+    _add_cover_page(doc, proposal, company_name)
+    doc.add_page_break()
+
+    # ---- 3. 添加目录 ----
+    _add_toc(doc)
+    doc.add_page_break()
+
+    # ---- 4. 添加正文章节 ----
+    for section_data in proposal.sections:
+        _add_section_to_doc(doc, section_data)
+        doc.add_paragraph()  # 章节间距
+
+    # ---- 5. 添加页脚 ----
+    _add_page_number(doc)
+
+    # ---- 6. 保存 ----
+    os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
+    doc.save(output_path)
+    logger.info(f"投标文件已保存至: {output_path}")
+    return output_path
+```
+
+#### 封面页
+
+```python
+def _add_cover_page(doc, proposal, company_name):
+    """添加封面页。"""
+    # 空行占位,将标题推到页面中间
+    for _ in range(6):
+        doc.add_paragraph()
+
+    # 项目名称
+    p = doc.add_paragraph()
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = p.add_run(proposal.project_name)
+    run.font.size = Pt(16)
+    run.font.bold = True
+    run.font.name = "宋体"
+    run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
+
+    # 间距
+    doc.add_paragraph()
+
+    # "投标文件" 标题
+    p = doc.add_paragraph()
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = p.add_run("投  标  文  件")
+    run.font.size = Pt(28)
+    run.font.bold = True
+    run.font.name = "宋体"
+    run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
+
+    # 间距
+    for _ in range(4):
+        doc.add_paragraph()
+
+    # 投标单位
+    p = doc.add_paragraph()
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = p.add_run(f"投标单位:{company_name}")
+    run.font.size = Pt(14)
+    run.font.name = "宋体"
+    run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
+
+    # 日期
+    p = doc.add_paragraph()
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = p.add_run(f"日    期:{datetime.date.today().strftime('%Y年%m月%d日')}")
+    run.font.size = Pt(14)
+    run.font.name = "宋体"
+    run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
+```
+
+#### 目录页
+
+```python
+def _add_toc(doc):
+    """添加目录页。"""
+    p = doc.add_paragraph()
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = p.add_run("目  录")
+    run.font.size = Pt(18)
+    run.font.bold = True
+    run.font.name = "宋体"
+    run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
+
+    doc.add_paragraph()
+
+    # 插入 Word TOC 超链接字段(Word 中按 Ctrl+A → F9 更新)
+    paragraph = doc.add_paragraph()
+    run = paragraph.add_run()
+    fld_begin = parse_xml(
+        f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>'
+    )
+    run._element.append(fld_begin)
+
+    run2 = paragraph.add_run()
+    instr = parse_xml(
+        f'<w:instrText {nsdecls("w")} xml:space="preserve">'
+        f' TOC \\o "1-3" \\h \\z \\u '
+        f'</w:instrText>'
+    )
+    run2._element.append(instr)
+
+    run3 = paragraph.add_run()
+    fld_end = parse_xml(
+        f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>'
+    )
+    run3._element.append(fld_end)
+```
+
+#### 章节内容写入
+
+```python
+def _add_section_to_doc(doc, section: ProposalSection):
+    """将章节内容写入文档(支持简单 Markdown 解析)。"""
+    # 章节标题
+    heading = doc.add_heading(section.title, level=section.level)
+    for run in heading.runs:
+        run.font.name = "宋体"
+        run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
+
+    if not section.content:
+        return
+
+    # 解析 Markdown 内容并写入
+    lines = section.content.split("\n")
+    for line in lines:
+        stripped = line.strip()
+        if not stripped:
+            continue
+
+        # 标题解析
+        if stripped.startswith("### "):
+            h = doc.add_heading(stripped[4:], level=3)
+        elif stripped.startswith("## "):
+            h = doc.add_heading(stripped[3:], level=2)
+        elif stripped.startswith("# "):
+            h = doc.add_heading(stripped[2:], level=1)
+        elif stripped.startswith("- ") or stripped.startswith("* "):
+            # 无序列表
+            p = doc.add_paragraph(style="List Bullet")
+            run = p.add_run(stripped[2:])
+            _set_run_font(run)
+        elif stripped[0].isdigit() and ". " in stripped[:4]:
+            # 有序列表
+            p = doc.add_paragraph(style="List Number")
+            run = p.add_run(stripped.split(". ", 1)[1])
+            _set_run_font(run)
+        else:
+            # 普通正文
+            p = doc.add_paragraph()
+            run = p.add_run(stripped)
+            _set_run_font(run)
+            p.paragraph_format.first_line_indent = Cm(0.75)
+
+
+def _set_run_font(run):
+    """设置运行文本的字体格式。"""
+    run.font.size = Pt(12)
+    run.font.name = "宋体"
+    run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
+```
+
+#### 页脚页码
+
+```python
+def _add_page_number(doc):
+    """添加页脚页码。"""
+    from docx.oxml import OxmlElement
+
+    section = doc.sections[0]
+    footer = section.footer
+    footer.is_linked_to_previous = False
+
+    p = footer.paragraphs[0]
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+
+    # "第 " 文本
+    run = p.add_run("第 ")
+    run.font.size = Pt(10)
+
+    # 页码字段
+    fld_char1 = OxmlElement("w:fldChar")
+    fld_char1.set(qn("w:fldCharType"), "begin")
+    run2 = p.add_run()
+    run2._element.append(fld_char1)
+
+    instr = OxmlElement("w:instrText")
+    instr.set(qn("xml:space"), "preserve")
+    instr.text = " PAGE "
+    run3 = p.add_run()
+    run3._element.append(instr)
+
+    fld_char2 = OxmlElement("w:fldChar")
+    fld_char2.set(qn("w:fldCharType"), "end")
+    run4 = p.add_run()
+    run4._element.append(fld_char2)
+
+    # " 页" 文本
+    run5 = p.add_run(" 页")
+    run5.font.size = Pt(10)
+```
+
+#### 注意事项
+
+- **字体设置**:中文文档必须显式设置中文字体(`rFonts.set(qn("w:eastAsia"), "宋体")`)
+- **段落缩进**:正文首行缩进两个汉字宽度(约 0.75cm)
+- **表格渲染**:如果 Markdown 内容包含表格,可检测并转换为 docx 表格(可选进阶功能)
+- **异常处理**:输出目录不存在时自动创建;创建失败时抛 `WriteError`
+- **参考实现**:可以借鉴 `pdf_table_to_docx/docx_writer.py` 中边框设置、列宽等写法
+
+---
+
+### 3. `pipeline.py` — 流程编排
+
+#### 职责
+
+编排读取→分析→生成→输出的完整流程,提供一键式 API。
+
+#### 核心函数
+
+```python
+import logging
+import os
+from .bid_reader import read_bid_pdf
+from .requirement_analyzer import RequirementAnalyzer
+from .proposal_generator import ProposalGenerator
+from .proposal_writer import write_proposal_docx
+
+logger = logging.getLogger(__name__)
+
+
+def run_pipeline(
+    pdf_path: str,
+    output_path: str | None = None,
+    api_key: str | None = None,
+    model: str = "gpt-4o",
+    company_name: str = "投标单位名称",
+) -> str:
+    """运行完整的招标→投标生成流程。
+
+    流程步骤:
+    1. read_bid_pdf(pdf_path) → BidDocument        (招标文件读取)
+    2. RequirementAnalyzer.analyze(doc) → AnalysisResult  (需求分析)
+    3. ProposalGenerator.generate(analysis) → ProposalDocument  (内容生成)
+    4. write_proposal_docx(proposal, path) → str    (DOCX输出)
+
+    每一步都有详细的日志输出,方便跟踪进度。
+
+    Args:
+        pdf_path: 招标文件 PDF 路径
+        output_path: 输出 DOCX 路径(默认与输入同目录)
+        api_key: OpenAI API Key(默认从环境变量读取)
+        model: 使用的模型名
+        company_name: 投标单位名称(用于封面)
+
+    Returns:
+        str: 输出 DOCX 文件路径
+
+    Raises:
+        FileNotFoundError: PDF 文件不存在
+        BidReadError: 招标文件读取失败
+        AnalysisError: 需求分析失败
+        GenerationError: 内容生成失败
+        WriteError: DOCX 输出失败
+    """
+    # Step 1
+    logger.info("Step 1/4: 读取招标文件...")
+    bid_doc = read_bid_pdf(pdf_path)
+    logger.info(f"  → 共 {bid_doc.total_pages} 页, {bid_doc.table_count} 个表格")
+
+    # Step 2
+    logger.info("Step 2/4: 分析招标要求(调用 AI)...")
+    analyzer = RequirementAnalyzer(api_key=api_key, model=model)
+    analysis = analyzer.analyze(bid_doc)
+    total = (len(analysis.evaluation_criteria) + len(analysis.service_requirements)
+             + len(analysis.qualification_requirements) + len(analysis.other_requirements))
+    logger.info(f"  → 识别到 {total} 项要求")
+    logger.info(f"    项目名称: {analysis.project_name}")
+
+    # Step 3
+    logger.info("Step 3/4: 生成投标内容(调用 AI)...")
+    generator = ProposalGenerator(api_key=api_key, model=model)
+    proposal = generator.generate(analysis)
+    logger.info(f"  → 生成 {len(proposal.sections)} 个章节")
+
+    # Step 4
+    if output_path is None:
+        base = os.path.splitext(pdf_path)[0]
+        output_path = f"{base}_投标文件.docx"
+    logger.info("Step 4/4: 输出投标文件...")
+    result = write_proposal_docx(proposal, output_path, company_name=company_name)
+    logger.info(f"  → 已保存: {result}")
+
+    logger.info("=== 完成 ===")
+    return result
+```
+
+#### 注意事项
+
+- **进度日志**:每个步骤开始和结束时都记录日志,格式统一
+- **统计信息**:输出页数、表格数、需求项数、章节数等
+- **异常传递**:不在 pipeline 中吞异常,让调用方处理
+- **输出路径**:默认与输入 PDF 同目录,文件名为 `{原文件名}_投标文件.docx`
+
+---
+
+### 4. `cli.py` — 命令行入口
+
+```python
+"""bid_proposal 命令行入口。"""
+
+import argparse
+import logging
+import sys
+
+from .pipeline import run_pipeline
+
+
+def setup_logging(verbose: bool = False):
+    level = logging.DEBUG if verbose else logging.INFO
+    fmt = "%(asctime)s [%(levelname)s] %(message)s"
+    datefmt = "%H:%M:%S"
+    logging.basicConfig(level=level, format=fmt, datefmt=datefmt, stream=sys.stderr)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="AI 驱动的投标文件生成工具 — 根据招标文件 PDF 自动生成投标文件 DOCX",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+使用示例:
+  %(prog)s 招标文件.pdf                                # 一键生成
+  %(prog)s 招标文件.pdf -o 投标文件.docx                 # 指定输出
+  %(prog)s 招标文件.pdf --model gpt-4o --verbose         # 指定模型+详细日志
+  %(prog)s 招标文件.pdf --company "XX公司"               # 指定投标单位
+        """,
+    )
+
+    parser.add_argument("pdf_path", help="输入的招标文件 PDF 路径")
+    parser.add_argument("-o", "--output", help="输出 DOCX 文件路径(默认自动生成)")
+    parser.add_argument("--model", default="gpt-4o", help="OpenAI 模型名称(默认 gpt-4o)")
+    parser.add_argument("--api-key", help="OpenAI API 密钥(默认从 OPENAI_API_KEY 环境变量读取)")
+    parser.add_argument("--company", default="投标单位名称", help="投标单位名称(用于封面)")
+    parser.add_argument("--verbose", "-v", action="store_true", help="输出详细日志")
+
+    args = parser.parse_args()
+    setup_logging(verbose=args.verbose)
+
+    logger = logging.getLogger(__name__)
+
+    try:
+        output_path = run_pipeline(
+            pdf_path=args.pdf_path,
+            output_path=args.output,
+            api_key=args.api_key,
+            model=args.model,
+            company_name=args.company,
+        )
+        print(f"\n✅ 投标文件已生成: {output_path}", file=sys.stderr)
+    except FileNotFoundError as e:
+        logger.error(f"文件未找到: {e}")
+        sys.exit(1)
+    except Exception as e:
+        logger.exception(f"处理失败: {e}")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
+```
+
+### 5. `__main__.py` — 模块入口
+
+```python
+"""支持 python -m bid_proposal 调用。"""
+from .cli import main
+
+main()
+```
+
+---
+
+## 接口依赖(A2 → A1)
+
+A2 工作包依赖 A1 提供的以下接口:
+
+```python
+# ---- 从 bid_proposal.models ----
+BidDocument              # 招标文件数据结构
+Requirement              # 需求项
+AnalysisResult           # 分析结果
+ProposalSection          # 投标章节
+ProposalDocument         # 投标内容
+GenerationError          # 生成异常
+WriteError               # 写入异常
+
+# ---- 从 bid_proposal.bid_reader ----
+read_bid_pdf(pdf_path: str) -> BidDocument
+
+# ---- 从 bid_proposal.requirement_analyzer ----
+RequirementAnalyzer(api_key: str | None, model: str)
+RequirementAnalyzer.analyze(doc: BidDocument) -> AnalysisResult
+
+# ---- 从 bid_proposal.config ----
+Config.get_openai_api_key() -> str
+Config.get_default_model() -> str
+```
+
+**A2 对 AnalysisResult 的使用方式**:
+
+```python
+result.evaluation_criteria       → 逐项生成评审响应(技术方案章节)
+result.service_requirements      → 逐项生成服务方案(服务方案章节)
+result.qualification_requirements → 逐项生成资质响应(资质证明章节)
+result.other_requirements         → 逐项生成其他响应(其他承诺章节)
+result.summary                    → 项目概况章节内容
+result.project_name               → 文档标题和封面
+```
+
+---
+
+## 验收标准
+
+| # | 标准 | 验证方式 |
+|---|------|---------|
+| 1 | `ProposalGenerator.generate()` 能基于 `AnalysisResult` 生成 `ProposalDocument` | `assert len(proposal.sections) > 0` |
+| 2 | 生成的章节结构符合预期(5个章节:概述、技术方案、服务方案、资质、其他) | 检查标题列表 |
+| 3 | `write_proposal_docx()` 输出有效的 DOCX 文件 | `os.path.exists(path)` 且文件大小 > 1KB |
+| 4 | DOCX 文件包含封面页(项目名称、"投标文件"居中标题) | 手动打开验证,或解析 XML 检查 |
+| 5 | `run_pipeline()` 端到端执行成功 | Mock OpenAI API 的情况下模拟完整流程 |
+| 6 | CLI 调用正常 `python -m bid_proposal <pdf>` | 子进程测试,exit code 0 |
+| 7 | 单个章节生成失败不影响其他章节 | Mock 部分 API 失败,验证其他章节仍正常 |
+| 8 | Mock API 测试覆盖率 >= 80% | `pytest --cov` |
+
+---
+
+## 测试建议
+
+创建 `bid_proposal/tests/test_a2.py`,包含以下测试:
+
+```python
+# 1. test_generator_success
+#    - Mock OpenAI API,验证 ProposalDocument 结构和章节数量
+# 2. test_generator_partial_failure
+#    - Mock 部分 API 调用失败,验证降级为占位文本
+# 3. test_generator_empty_requirements
+#    - 所有需求列表为空时,仍能生成合理的文档结构
+# 4. test_writer_creates_docx
+#    - 验证输出有效的 DOCX 文件
+# 5. test_writer_cover_page_and_toc
+#    - 验证封面和目录存在
+# 6. test_pipeline_success (集成测试)
+#    - Mock 所有外部依赖,验证完整流程
+# 7. test_pipeline_file_not_found
+#    - PDF 不存在时抛出 FileNotFoundError
+# 8. test_cli_help
+#    - 验证 --help 输出
+```
+
+---
+
+## 参考文档
+
+- 架构文档:`bid_proposal/ARCHITECTURE.md`
+- A1 工作包:`bid_proposal/task_a1.md`
+- DOCX 输出参考:`pdf_table_to_docx/docx_writer.py`
+- 测试数据:`data/松江区机关事务管理局物业管理服务招标文件.pdf`

+ 120 - 0
bid_proposal/utils.py

@@ -0,0 +1,120 @@
+"""
+utils.py - 工具函数模块
+
+提供日志配置、文本分块和表格转换等通用工具函数。
+"""
+
+import logging
+import sys
+
+logger = logging.getLogger(__name__)
+
+
+def setup_logging(verbose: bool = False):
+    """配置日志格式。
+
+    - verbose=True: DEBUG级别,显示文件名行号
+    - verbose=False: INFO级别,简洁格式
+
+    Args:
+        verbose: 是否开启详细日志模式
+    """
+    level = logging.DEBUG if verbose else logging.INFO
+    fmt = (
+        "%(asctime)s [%(levelname)s] %(name)s (%(filename)s:%(lineno)d): %(message)s"
+        if verbose
+        else "[%(levelname)s] %(message)s"
+    )
+    logging.basicConfig(
+        level=level,
+        format=fmt,
+        stream=sys.stderr,
+        force=True,
+    )
+
+
+def chunk_text(text: str, max_chars: int = 4000) -> list[str]:
+    """将长文本按最大字符数切分,尽量在段落边界处切分。
+
+    Args:
+        text: 待切分的文本
+        max_chars: 每个块的最大字符数(默认 4000)
+
+    Returns:
+        切分后的文本块列表
+    """
+    if not text:
+        logger.debug("chunk_text 收到空文本,返回空列表")
+        return []
+    if max_chars <= 0:
+        logger.warning("max_chars=%d 无效,使用默认值 4000", max_chars)
+        max_chars = 4000
+    if len(text) <= max_chars:
+        logger.debug("文本长度 %d <= %d,无需切分", len(text), max_chars)
+        return [text]
+
+    chunks: list[str] = []
+    start = 0
+    while start < len(text):
+        # 如果剩余文本不足 max_chars,直接取剩余全部
+        if start + max_chars >= len(text):
+            chunks.append(text[start:])
+            break
+
+        # 在当前块末尾附近寻找段落边界(换行符)
+        end = start + max_chars
+        # 在 [start+max_chars//2, start+max_chars] 范围内向前找最后一个换行符
+        search_start = max(start + max_chars // 2, start)
+        boundary = text.rfind("\n", search_start, end)
+        if boundary <= start:
+            # 找不到合适换行符,在最大范围内向后找
+            boundary = text.find("\n", end)
+            if boundary == -1 or boundary >= start + int(max_chars * 1.5):
+                # 仍然找不到,直接在 max_chars 处切分
+                boundary = end
+
+        chunk = text[start:boundary].strip()
+        if chunk:
+            chunks.append(chunk)
+        start = boundary + 1  # 跳过换行符
+
+    logger.debug("文本已切分为 %d 个块", len(chunks))
+    return chunks
+
+
+def table_to_markdown(table_info) -> str:
+    """将 pdf_table_to_docx.table_parser.TableInfo 转换为 Markdown 表格格式。
+
+    用于将表格内容序列化为文本,方便传给 AI。
+
+    Args:
+        table_info: 来自 pdf_table_to_docx 的 TableInfo 对象
+
+    Returns:
+        Markdown 格式的表格字符串
+    """
+    # 输入校验
+    if table_info is None:
+        logger.warning("table_to_markdown 收到 None 输入")
+        return "(空表格)"
+    if not hasattr(table_info, "cells"):
+        logger.warning("table_to_markdown 收到的对象没有 cells 属性")
+        return "(空表格)"
+
+    rows = table_info.cells  # list[list[CellInfo]]
+    if not rows:
+        logger.debug("table_to_markdown: 空表格(无行)")
+        return "(空表格)"
+
+    lines: list[str] = []
+    for row_idx, row in enumerate(rows):
+        # 提取该行每个单元格的文本,去除首尾空白
+        cell_texts = [cell.text.strip() if hasattr(cell, "text") else str(cell).strip() for cell in row]
+        # 用 | 分隔单元格
+        lines.append("| " + " | ".join(cell_texts) + " |")
+
+        # 在第一行之后添加分隔行(表头分隔符)
+        if row_idx == 0:
+            lines.append("|" + "|".join("---" for _ in row) + "|")
+
+    return "\n".join(lines)