跳转至

Tender 标书端到端流程概览

本文档梳理 TenderPDF 招标文件解析 → 信息提取与结构化存储 → 目录大纲生成 → 章节正文写作 → 整卷标书导出 的完整链路,以及各阶段使用到的模型和关键模块,方便后续排查问题和扩展能力。


一、整体阶段拆分

  1. 标书基础配置 & 外部资料绑定 (routes/tender/config.py 等)
  2. 上传 PDF 并解析为结构化信息 (routes/tender/parser.py + services/ingestion/*)
  3. 生成标书目录大纲(Outline)与节点编写思路 (routes/tender/generation/outline.py + services/generation_service.py)
  4. 按目录生成章节正文(单章 / 批量) (routes/tender/generation/section.py + services/generation_service.py)
  5. 汇总大纲与正文,导出整卷 Word 标书 (routes/tender/document.py + utils/document_renderer.py)
  6. 解析任务与 SSE 进度追踪 (app/routes/tasks.py 挂载于 /v1/tasks + services/task_service.py)

下面按时间顺序、结合核心模型说明每个阶段的输入/输出和关键代码位置。


二、标书基础配置 & 引用资料绑定

2.1 数据模型

  • app/models/tenders/core.pyTender
  • 关键字段:

    • id: 标书 ID
    • title / name: 标书标题与内部名称
    • status: TenderStatusPARSING / DRAFT / GENERATING / COMPLETED / FAILED,表示解析/正文任务状态。
    • step: TenderStepANALYSIS / OUTLINE / CONTENT,表示用户已到达的最远业务步骤(标书解析/目录编制/正文编制)。
    • meta_data: JSONB:存预算、项目名称、采购人等动态属性
    • task_id: UUID:关联的解析任务 IngestionTask
    • blind_rules, table_density, has_flowchart:写作/排版相关配置
    • 关联关系:contentscoring_itemssection_contentsknowledge_referencesproduct_referencesimage_references
  • app/models/tenders/content.pyTenderContent

  • overview_data: JSONB:解析得到的项目概况原始结构
  • outline: JSONB:AI 生成的目录树(OutlineNode 列表,结构见 schemas/generation.py

  • app/models/tenders/scoring_item.pyTenderScoring

  • 评分项结构化结果:category / sub_category / name / max_score / review_content / review_criteria / scoring_rule

  • app/models/tenders/references.py → 三类引用关系

  • TenderKnowledgeReference:标书 ↔ 知识库文件
  • TenderProductReference:标书 ↔ 产品库条目
  • TenderImageReference:标书 ↔ 图片资源

2.2 列表状态映射

历史记录列表的四个展示状态由 Tender.status 映射,不使用 Tender.step

列表展示 API 筛选参数 写入时机
未完成 ?status=DRAFT 解析完成,或正文操作结束但仍有叶子章节未写
已完成 ?status=COMPLETED 当前目录的全部叶子章节都有非空正文
处理中 ?status=PARSING&status=GENERATING quick-parse 正在解析,或正文生成任务正在执行
处理失败 ?status=FAILED 解析/正文生成失败、取消,或正文异步任务入队失败

GET /v1/tendersstatus 参数支持多选,因此筛选“处理中”必须同时传入 PARSINGGENERATING

2.3 配置接口

  • app/routes/tender/config.py
  • PATCH /tenders/{tender_id}update_tender_config
  • 请求体:schemas/tender.UpdateTenderConfigRequest
    • 更新字段:title, meta_data, page_count, layout_style, blind_rules
    • 以及:knowledge_reference_ids, product_reference_ids, image_reference_ids
  • 内部逻辑:
    • 对普通字段直接 setattr(tender, key, value)
    • 三类引用 ID 通过清空原有 Tender*Reference 再重建,保证幂等

2.4 外部资料与图库

  • 知识库:models/knowledge.py + services/knowledge_*
  • 产品库:models/product_library.py + services/product_library/ + app/routes/v1/product_library/(REST 前缀 /v1/product-libraries,与知识库一致:GET/POST ""GET/PATCH/DELETE /{library_id};列表分页;子资源 /{library_id}/products/{library_id}/parse/...
  • 图库:models/image_gallery.py + services/image/gallery_service.py

这些引用信息后续会在 章节写作上下文整卷导出 时统一注入。


三、上传 PDF 并解析为结构化标书信息

3.1 上传与任务初始化

  • 入口:app/routes/tender/parser.py
  • POST /tenders/{tender_id}/documents/parseparse_document
  • 核心步骤:

    1. 校验文件类型(仅支持 application/pdf)。
    2. 生成 task_id = uuid4(),将上传的 PDF 以流式方式写入 /tmp/tender_uploads/...,限制最大 50MB。
    3. 读取文件字节 file_bytes,并通过 OSSService.upload_bytes 上传到对象存储(OBJECT_STORAGE_* 配置)。
    4. 调用 task_service.init(...) 创建 IngestionTask 记录:
    5. 保存文件名、大小、对象存储 URL、用户 ID 等。
    6. Tender.task_id = task_idTender.status = PARSING,并确保存在关联的 TenderContent 记录。
    7. 使用 FastAPI BackgroundTasks 调用:
    8. ingestion_service.process_document(task_id, file_bytes, original_filename)
  • 解析历史:

  • GET /tenders/documents/parse/history & GET /v1/tasks 结合 TaskService.summarize_channels 查看每次解析的概况/评分结果。

3.2 文档解析主流程

  • 入口:app/services/ingestion/index.pyDocumentIngestionService.process_document
  • 创建 IngestionTaskRunner,核心参数:
    • document_extractor: DocumentExtractor
    • task_id, file_bytes, filename, total_pages
  • 使用 fitz.open(stream=file_bytes, filetype="pdf") 打开文档,遍历所有页:
    • await self.runner.run_all_pages(document)
  • 所有页跑完后,等待向量化任务 vector_tasks 最长 60 秒。
  • 最终调用:
    • tender_service.save_tender_results(task_id, final_overview, final_scoring, raw_overview_markdown) 将解析结果落库
    • task_service.finish(task_id, final_result=...) 标记任务完成
    • 异常则 task_service.fail(...) 并将 Tender.status 设置为 FAILED

3.3 单页处理与模型链路

app/services/ingestion/runner.pyIngestionTaskRunner

  1. 页面内容提取
  2. DocumentExtractor.extract_markdown(document, page_index)core/document_extractor.py
    • 首选:pymupdf4llm.to_markdown(...) 把指定页转换为 Markdown
    • 失败兜底:page.get_text("text")
  3. 依赖:

    • PyMuPDF (fitz)
    • pymupdf4llm[ocr,layout]
  4. Markdown 处理

  5. 当前直接使用 DocumentExtractor 输出的 Markdown,不再经过额外的 LLM 清洗。

  6. 信号检测 & LLM 信息提取

  7. 信号引擎:core/universal_nlp_base.pyuniversal_nlp_base

    • 负责判断该页是否可能包含:
    • 概况信息(项目名称、预算、编号等)→ has_overview_signal(text, page_index)
    • 需求/技术要求 → has_requirement_signal(text)
    • 评分标准 → has_scoring_signal(text)
    • 使用统一词表:
    • lexicons/common/overview_terms.txt
    • lexicons/common/requirement_terms.txt
    • lexicons/common/scoring_terms.txt
  8. 结构化提取:core/extraction/extractor.pyExtractor

    • 客户端:AsyncOpenAI(api_key=PARSER_EXTRACT_API_KEY, base_url=PARSER_EXTRACT_BASE_URL, model=PARSER_EXTRACT_MODEL)
    • 共有三个子任务,互相独立并发执行:
    • extract_overview(markdown)

      • system prompt:overview_prompt.md
      • 返回字典(项目名称、编号、预算、开标时间、地点等)。
    • extract_scoring(markdown)

      • system prompt:scoring_prompt.md
      • 返回评分项列表(或带 items 的 JSON),最终解析为统一 list 结构。
    • extract_requirements(markdown)

      • system prompt:analysis_indicators.md
      • 返回多个需求列表字段,统一通过 parse_requirements_payload 清洗为:
      • 人员要求、设备要求、技术要求、服务方案要求、服务标准等。
    • 内置重试机制:max_retries / retry_delays,对 503/429/timeout 等错误进行指数退避重试。
  9. 聚合结果 & 进度推送(SSE)

  10. aggregate_and_push(...) 中将当前页的 overview/requirements/scoring 合并到 Runner 的全局状态:
    • 使用 ingestion/utils.pymerge_payload 等方法进行去重与合并。
  11. 调用 task_service.update_progress(...) 持久化到 IngestionTask.result_data
    • 每一页的清洗后内容/来源(regex or llm)、以及分类(overview_update / scoring_update / mixed_update / page_error)。
  12. 前端通过 app/routes/tasks.py(前缀 /v1/tasks)的 SSE 接口 /v1/tasks/{task_id}/events 实时订阅解析进度。

  13. 向量化与 RAG 索引

  14. 知识库摄入通过 services/knowledge/ingestion_service.py 调用共享 vector_store
  15. services/vector_store/factory.py 根据 VECTOR_STORE_PROVIDER 创建向量存储;当前生产配置使用 Qdrant。
  16. Embedding 请求由 EMBEDDING_API_KEYEMBEDDING_BASE_URLEMBEDDING_MODEL 配置。
  17. 后续检索由 services/knowledge/search_service.py 调用同一向量存储。

  18. 解析结果落库

  19. services/tender_service.pyTenderService.save_tender_results

  20. 根据 task_id 找到对应 TenderTenderContent
    • Tender.meta_data 中填充:
    • project_name, project_code, record_code, budget_amount, deadline, opening_time, location, service_period, purchaser, agency, project_nature, procurement_scope 等。
    • TenderContent.overview_data 保存 LLM 原始概况结果。
  21. 需求部分写入 Tender.meta_datatech_requirements, scheme_requirements, service_standards 等字段。
  22. 评分项 list 经过去重后写入 TenderScoring 表。
  23. quick-parse 入队后将 Tender.status 置为 PARSING;结果落库成功后置为 DRAFT;解析失败或取消时置为 FAILED

四、生成标书目录大纲(Outline)

4.1 目录生成接口

  • 一二级目录入口:POST /tenders/{tender_id}/outline/skeleton
  • 实际委托:generation_service.generate_skeleton(...)
  • 模型配置:OUTLINE_SKELETON_MODEL / OUTLINE_SKELETON_API_KEY / OUTLINE_SKELETON_BASE_URL

  • 三四级目录入口:POST /tenders/{tender_id}/outline/children.../stream

  • 实际委托:suboutline_service.generate_outline_children_playground_raw(...)
  • 模型配置:OUTLINE_CHILDREN_MODEL / OUTLINE_CHILDREN_API_KEY / OUTLINE_CHILDREN_BASE_URL

4.2 Prompt 构建与模型

  • 一二级目录通过后台 Prompt 场景 outline_l1_l2 渲染,注入评分标准、页数等结构化上下文。
  • 三四级目录复用后台 writing_hint Prompt,按当前二级节、需求条目和已有一二级目录收窄上下文。
  • 旧的 GENERATION_* 结构化完整目录生成链路已下线。

4.3 Outline 结果与持久化

  • 一二级目录生成后写入 TenderContent.outline / outline_flat / outline_markdown
  • 三四级目录生成后按二级节合并回 TenderContent.outline,并重算 outline_flat / outline_markdown
  • 篇幅估算使用 OUTLINE_LEAF_BASE_WORDS / OUTLINE_WORDS_PER_SCORING_ITEM / OUTLINE_WORDS_PER_PAGE

4.4 Outline 的读取与编辑

  • GET /tenders/{id}/outline:返回整棵目录树给前端。
  • GET /tenders/{id}/outline/prompts:抽取每个节点的 content_hint,方便独立展示和编辑。
  • PUT /v1/tenders/{id}/outline/node/content:修改某个节点的 title / content_hint 并写回 JSON(仅 v1;旧 /tenders/... 已移除)。
  • PATCH /tenders/{id}/outline/node:调整 estimated_pages/estimated_words

五、按目录生成章节正文(单章/批量)

5.1 多维上下文组装(不调用模型)

  • 模块:services/context_assembler.pyContextAssembler
  • 入口方法:assemble(session, tender, section_id, outline)
  • 输出:SectionContext(数据类),包含:
  • section_id, title, content_hint, breadcrumb
  • scoring_text:通过 TenderScoring + 节点的 related_scoring_items 匹配出的评分说明。
  • rag_materials:通过 vector_service.search(task_id, query) 检索到的招标文件原文片段。
  • reference_text:标书绑定的知识库文件 & 产品库条目汇总文案。
  • image_urls:标书绑定的图片资源(格式为“图片资源: name (备注)”)。
  • sibling_titles:同级节点标题,用于避免内容重复。

这一层只负责聚合上下文,不直接调用任何 LLM,方便后期替换模型或添加更多维度(如历史项目案例)。

5.2 单章节生成(SSE 流式)

  • 路由:routes/tender/generation/section.py
  • POST /tenders/{id}/sections/{section_id}/generate
  • 调用:generation_service.generate_section(session, tender_id, section_id)

  • GenerationService.generate_section 主要逻辑:

  • 校验 TenderTenderContent.outline 是否存在。
  • 调用 ContextAssembler.assemble 构造 SectionContext
  • 通过 classify_section(title, content_hint) 使用 UniversalNLPBase 中的 technical_terms / business_terms 判断:
    • 技术章节 → DeepSeek-V3run_technical_writing_stream
    • 商务/服务章节 → Qwenrun_writing_stream
  • 通过 section_content 场景绑定读取数据库 Prompt,并使用 SectionContext 渲染模板变量。
  • 调用对应模型的 流式接口,逐块 yield SSE 事件:
    • step = writing / generating_content,前端边收边渲染。
  • 生成完全文本后调用 upsert_section_content(...) 写入 TenderSectionContent 表,并更新 Tender.updated_at

  • GET /tenders/{id}/sections/{section_id}:查询单章节详情。


六、整卷标书导出(Docx)

6.1 Docx 导出

  • 路由:POST /tenders/{id}/document/download/docx
  • 请求体:DocumentStyleConfig,可指定页边距和各级标题格式(字号、字体、对齐方式等)。

  • 内部流程由 generate_document_docx_stream(...) 使用 python-docx 生成 .docx 二进制流:

  • 顶级标题 → 一级 Heading
  • 各级目录节点映射到不同 Heading 级别
  • 叶子节点无正文则插入“此章节内容尚未生成”引用样式段落。

七、涉及的主要模型与第三方依赖

7.1 LLM 模型

所有模型的具体名称都来自 .env / core/config.Settings

  • 解析提取模型(Extractor)
  • PARSER_EXTRACT_MODEL
  • 用途:从 Markdown 中抽取:项目概况、需求条目、评分标准,统一写入 TenderContent / TenderScoring 等。

  • 一二级目录模型(Outline Skeleton)

  • OUTLINE_SKELETON_MODEL
  • 用途:根据评分项和标书上下文生成一二级目录骨架。

  • 三四级目录模型(Outline Children)

  • OUTLINE_CHILDREN_MODEL
  • 用途:按二级节生成三四级目录和编写提示。

  • 正文写作模型(Writing)

  • 商务/服务类章节:WRITING_MODEL(当前配置:Qwen/Qwen2.5-72B-Instruct
  • 技术类章节:DEEPSEEK_WRITING_MODEL(当前配置:deepseek-ai/DeepSeek-V3
  • 均通过 LLMFactory 提供:
    • run_writing_stream(Qwen 流式)
    • run_technical_writing_stream(DeepSeek 流式)

7.2 解析与向量化相关依赖

  • PDF 解析:PyMuPDF (fitz)pymupdf4llm
  • 文本清洗:正则 + 自定义词库
  • 结构化提取与写作:openai SDK + 兼容 SiliconFlow/OpenAI 接口
  • 向量化与检索:
  • Embedding:EMBEDDING_MODEL(默认 BAAI/bge-m3
  • 存储与查询:Cloudflare Vectorize(HTTP API)

7.3 存储与对象存储

  • 主库:PostgreSQL + sqlmodel / alembic
  • OSS:OBJECT_STORAGE_* 配置(S3 兼容端点,如阿里云 OSS;使用 aioboto3
  • 对象:
  • 源 PDF:IngestionTask.file_url 与对象 key
  • 知识库文件 / 图片:各自有 URL 并可通过引用关系挂到标书上。

八、端到端时序总结

  1. 用户创建标书(未在本文详述)并配置基础信息 / 绑定知识库、产品库、图片等。
  2. 调用 POST /tenders/{id}/documents/parse
  3. 文件上传 → 对象存储 → 创建 IngestionTask → 标记 Tender.status = PARSING → 启动后台 process_document
  4. IngestionTaskRunner 对每一页做:
  5. PDF → Markdown → 轻量/LLM 清洗 → 信号检测 → LLM 提取(概况/需求/评分)→ 聚合 & SSE 进度 → 向量化入库。
  6. 全部页面跑完:
  7. TenderService.save_tender_results 将聚合结果写入 Tender / TenderContent / TenderScoring
  8. Tender.status = DRAFT;解析失败或取消则为 FAILED
  9. 用户调用 POST /tenders/{id}/outline/skeleton
  10. 评分项 + Prompt 场景 → OUTLINE_SKELETON_MODEL 输出一二级目录 → 保存到 TenderContent.outline
  11. 页面从解析进入目录时调用 POST /tenders/{id}/workflow/enter-outline,推进 Tender.step = OUTLINE
  12. 若前端漏调用步骤接口,目录生成保存成功也会兜底将 Tender.step 推进到 OUTLINE
  13. 用户可以:
  14. 调整 content_hint / estimated_pages / estimated_words
  15. 生成编写思路与三四级目录;
  16. 查看历史记录等。
  17. 用户调用章节写作接口:
  18. 从目录页点击“编写正文”时调用 POST /tenders/{id}/workflow/enter-content,推进 Tender.step = CONTENT
  19. 单章:POST /tenders/{id}/sections/{section_id}/generate
  20. 全文:POST /tenders/{id}/generation-runs/full
  21. 过程:正文生成接口兜底推进 Tender.step = CONTENT,并将 Tender.status 设置为 GENERATING → 装配多维上下文 → 流式生成 Markdown 正文 → 写入 TenderSectionContent
  22. 仅当当前目录全部叶子章节都有非空正文时设置 Tender.status = COMPLETED;正文操作结束但尚未齐全时恢复为 DRAFT;批量或异步全文任务失败时设置为 FAILED
  23. 最后导出整卷标书:
  24. Docx:POST /tenders/{id}/document/download/docx
  25. 组合目录树 + 各章节正文 + 引用资料 / 图片列表,生成最终交付文档。