|
|
@@ -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`
|