task_a2.md 28 KB

A2 工作包:投标内容生成 + 文档输出 + 流程编排

任务概述

实现 bid_proposal 包的后半部分:投标内容生成(proposal_generator.py)、文档输出(proposal_writer.py)、流程编排(pipeline.py)、命令行入口(cli.py / __main__.py)。

先决条件:A1 工作包已完成,models.pyconfig.pybid_reader.pyrequirement_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

核心类

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: 内容生成失败
        """

章节规划逻辑

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

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

实现步骤

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 调用封装(带重试)

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}")

概述章节生成

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 投标文件。样式设计符合投标文件的正式格式。

核心函数

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 页)

实现步骤

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

封面页

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"), "宋体")

目录页

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)

章节内容写入

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"), "宋体")

页脚页码

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。

核心函数

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 — 命令行入口

"""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 -m bid_proposal 调用。"""
from .cli import main

main()

接口依赖(A2 → A1)

A2 工作包依赖 A1 提供的以下接口:

# ---- 从 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 的使用方式

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,包含以下测试:

# 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