wangxi 1 semana atrás
pai
commit
7a140b1343

+ 286 - 0
API进度查询接口对接文档.md

@@ -0,0 +1,286 @@
+# Proposa AI 任务进度查询接口对接文档
+
+版本日期:2026-09-09。适用范围:当前仓库 API 实现。
+
+本文中的响应为按实现整理的示例,时间、错误场景和业务 ID 均为示意,**不是对任务 `b429d8ddce344f2fadac91195aa56d97` 的线上查询结果**。服务地址请替换为实际部署地址。
+
+## 1. 接口信息
+
+| 项目 | 说明 |
+| --- | --- |
+| 用途 | 查询投标文件生成任务的 Step1–6 步骤进度 |
+| 方法 | `GET` |
+| 路径 | `/api/v1/jobs/{request_id}/progress` |
+| 本次任务路径 | `/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97/progress` |
+| 路径参数 | 路径中的 ID 可以是提交后返回的 `request_id`,也可以是请求中的 `txbId` 值;字符串,必填 |
+| Query / Body | 无 |
+| 响应格式 | `application/json`,直接返回对象,没有 `code` / `data` 外层包装 |
+| 鉴权 | 当前应用代码未要求 token;若部署网关设置鉴权,按网关要求提供 |
+
+请求示例(将 `https://api.example.com` 替换为实际地址):
+
+```bash
+curl "https://api.example.com/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97/progress" -H "Accept: application/json"
+```
+
+**对接核心规则:`/progress` 是步骤快照,最终成功或失败必须查询 `GET /api/v1/jobs/{request_id}` 的 `status`。HTTP 200 仅表示查询成功,不表示生成成功。**
+
+### 1.1 使用 txbId 查询最新任务
+
+状态、进度、下载三个接口共用同一套 ID 解析规则。假设提交时传入 `"txbId": "TEST-TXB-001"`,可直接请求:
+
+```text
+GET /api/v1/jobs/TEST-TXB-001
+GET /api/v1/jobs/TEST-TXB-001/progress
+GET /api/v1/jobs/TEST-TXB-001/file
+```
+
+无需增加 `?txbId=` 查询参数,也不需要请求体。提交参数名仍是大小写准确的 `txbId`。
+
+- 优先精确匹配 `request_id`,找不到时再按 `txbId` 找最新任务。如果某个 `txbId` 恰好与已有 `request_id` 相同,优先返回该 `request_id` 对应任务。
+- 同一个 `txbId` 每次提交仍产生新的唯一 `request_id` 和独立文件目录;映射指向最近提交的任务,不按完成时间排序。并发请求按服务端校验后注册任务的顺序确定先后。
+- 新任务排队、处理中或处理失败时,不会回退到之前的成功任务;新任务未完成时用 `txbId` 下载仍返回 409。
+- 请求校验失败或后台提交失败(未成功接收的请求),不替换之前有效的映射。
+- 历史任务通过原有 `request_id` 查询。查询状态响应中的 `request_id`、提交响应和回调中的 ID、下载 URL 仍使用真正的任务 ID,不替换为 `txbId`。
+- ID 值区分大小写。提交时 `txbId` 首尾空白会被去除;查询应使用处理后的值。本文 URL 示例使用可直接放入路径的 ID。
+
+**如果要保证状态、进度、下载对应同一次生成:先通过 `txbId` 查询任务状态,拿到响应中的 `request_id`,后续固定使用该 ID。** 否则在两次请求之间有同一 `txbId` 的新任务提交时,最新任务可能发生切换。
+
+## 2. 进度响应字段
+
+有步骤快照时返回以下字段;无快照时仅返回 `status`、`message`,其余字段缺省。
+
+| 字段 | 类型 | 含义 |
+| --- | --- | --- |
+| `updated_at` | string | 快照最后更新时间,ISO 8601,UTC,如 `2026-09-09T02:30:00+00:00`;北京时间需加 8 小时 |
+| `current_step` | integer | 当前或最近完成的步骤编号,1–6 |
+| `total_steps` | integer | 总步骤数,当前固定为 6 |
+| `current_step_name` | string | 步骤名称 |
+| `status` | string | 有快照时为 `running` 或 `completed`;无快照时回退为任务状态,见后文 |
+| `message` | string | 展示提示语,不应作为程序判断条件 |
+| `completed_steps` | integer[] | 已执行完成的步骤编号;正在执行的步骤不包含在其中 |
+
+此接口不返回 `request_id`、`txbId`、`error`、`resultPath`、下载地址或 `progress_url`。调用方需保存提交时获得的任务 ID。
+
+| 步骤 | `current_step_name` |
+| --- | --- |
+| 1 | Step 1 文档解析及表/函/表格提取 |
+| 2 | Step 2 招标需求、评分项、废标项分析 |
+| 3 | Step 3 投书大纲生成 |
+| 4 | Step 4 章节内容生成与模板填充 |
+| 5 | Step 5 覆盖性、风险与完整性审核修复 |
+| 6 | Step 6 最终 DOCX 聚合装配 |
+
+## 3. 处理中拿到的结果
+
+### 3.1 尚未产生步骤快照
+
+HTTP 200,排队时可能返回:
+
+```json
+{
+  "status": "queued",
+  "message": "任务已排队或尚未开始"
+}
+```
+
+后台已启动但尚未写入有效快照时:
+
+```json
+{
+  "status": "processing",
+  "message": "任务已排队或尚未开始"
+}
+```
+
+调用方显示“排队中”或“处理中”并继续轮询;不要强制读取此时不存在的 `current_step`、`completed_steps`。
+
+### 3.2 正在执行某一步骤
+
+HTTP 200,例如正在执行 Step3:
+
+```json
+{
+  "updated_at": "2026-09-09T02:30:00+00:00",
+  "current_step": 3,
+  "total_steps": 6,
+  "current_step_name": "Step 3 投书大纲生成",
+  "status": "running",
+  "message": "Step 3 投书大纲生成 正在处理",
+  "completed_steps": [1, 2]
+}
+```
+
+含义:Step1、Step2 已完成,Step3 正在执行。仍需查询任务状态确认任务没有失败,原因见第 4 节。
+
+### 3.3 某一步骤刚完成,但任务仍在处理
+
+HTTP 200,例如 Step3 刚完成、Step4 尚未更新快照:
+
+```json
+{
+  "updated_at": "2026-09-09T02:35:00+00:00",
+  "current_step": 3,
+  "total_steps": 6,
+  "current_step_name": "Step 3 投书大纲生成",
+  "status": "completed",
+  "message": "Step 3 投书大纲生成 处理完成",
+  "completed_steps": [1, 2, 3]
+}
+```
+
+这里的 `completed` 仅表示 Step3 完成,**不能停止轮询或提示整个任务完成**。
+
+## 4. 处理失败拿到的结果
+
+### 4.1 已产生快照后失败:进度可能仍为 running
+
+当前实现失败时不会把步骤快照改写为 `failed`。例如 Step4 执行失败,`/progress` 仍可能返回 HTTP 200 和以下最后快照:
+
+```json
+{
+  "updated_at": "2026-09-09T02:40:00+00:00",
+  "current_step": 4,
+  "total_steps": 6,
+  "current_step_name": "Step 4 章节内容生成与模板填充",
+  "status": "running",
+  "message": "Step 4 章节内容生成与模板填充 正在处理",
+  "completed_steps": [1, 2, 3]
+}
+```
+
+此时查询任务状态接口:
+
+```bash
+curl "https://api.example.com/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97"
+```
+
+该接口 HTTP 200 响应中的关键字段如下(**仅摘录字段,不是完整响应**):
+
+```json
+{
+  "request_id": "b429d8ddce344f2fadac91195aa56d97",
+  "txbId": "TEST-TXB-001",
+  "status": "failed",
+  "error": "Step1-6 处理进程异常退出(退出码 1)",
+  "callback_delivered": true
+}
+```
+
+调用方以任务 `status=failed` 判定失败,停止轮询,展示 `error`,隐藏下载入口。失败原因可能是“处理超时”、进程异常退出或其他错误文本,不能仅匹配某个固定错误字符串。
+
+`callback_delivered=true` 只说明结果回调已按 HTTP 状态成功投递,不表示生成成功。回调失败也不改变生成结果;可查看任务的 `callback_error`。
+
+### 4.2 未产生有效快照就失败
+
+`/progress` 可返回 HTTP 200:
+
+```json
+{
+  "status": "failed",
+  "message": "任务已排队或尚未开始"
+}
+```
+
+此处 `message` 是统一兜底文案,并非真实失败原因。仍需从任务状态接口读取 `error`。
+
+### 4.3 全部步骤完成后,最终文件校验或发布失败
+
+此时 `/progress` 甚至可能仍是 Step6 的 `completed` 快照,而任务状态接口返回 `failed`。最终结果仍以任务状态为准。
+
+注意:失败回调里的 `resultPath="error: ..."` 属于回调协议;`/progress` 不返回该字段,任务状态查询也不自动构造该字段。任务查询中如有 `output_path`,它只是预定输出路径,不能据此判成功。
+
+## 5. 处理完成拿到的结果
+
+步骤全部执行完成后,`/progress` 返回 HTTP 200:
+
+```json
+{
+  "updated_at": "2026-09-09T03:20:00+00:00",
+  "current_step": 6,
+  "total_steps": 6,
+  "current_step_name": "Step 6 最终 DOCX 聚合装配",
+  "status": "completed",
+  "message": "Step 6 最终 DOCX 聚合装配 处理完成",
+  "completed_steps": [1, 2, 3, 4, 5, 6]
+}
+```
+
+再查询任务状态接口,成功时关键字段如下(**仅摘录字段,不是完整响应**;文件名及路径为虚构示例):
+
+```json
+{
+  "request_id": "b429d8ddce344f2fadac91195aa56d97",
+  "txbId": "TEST-TXB-001",
+  "status": "completed",
+  "output_path": "/srv/proposa/output/api_jobs/b429d8ddce344f2fadac91195aa56d97/示例招标文件.docx",
+  "final_review_path": "/srv/proposa/output/api_jobs/b429d8ddce344f2fadac91195aa56d97/示例招标文件.docx",
+  "final_review_url": "https://api.example.com/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97/file",
+  "callback_delivered": true,
+  "callback_error": ""
+}
+```
+
+只有任务 `status=completed` 才展示“生成完成”和下载按钮。`final_review_path` / `output_path` 是服务器路径,浏览器不能直接打开;通过下载接口获取文件:
+
+```bash
+curl "https://api.example.com/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97/file" --output "投标文件.docx"
+```
+
+下载成功:HTTP 200,响应为 DOCX 二进制,Content-Type 为 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,不是 JSON。
+
+Step6 完成后还有文件检查、发布和回调投递过程;当前实现等回调尝试结束才更新任务终态。因此短暂出现“进度 Step6 completed、任务 processing”时,应继续等待。回调最终失败仍可得到任务 `completed`,但 `callback_delivered=false`。
+
+## 6. 异常响应和任务保留
+
+| 请求 | HTTP 状态 | 含义与处理 |
+| --- | --- | --- |
+| `/progress`、任务查询、下载 | 404 | 任务不存在;核对任务 ID 和服务实例,不应当作生成失败原因 |
+| 下载 `/file` | 409 | 任务未完成,或最终文件不存在;不可按 DOCX 保存响应 |
+| 查询 | 网络异常 / 5xx | 本次查询失败,不等于任务生成失败;提示重试,避免直接把业务任务改为 failed |
+
+404 响应:
+
+```json
+{
+  "detail": "任务不存在"
+}
+```
+
+下载 409 响应:
+
+```json
+{
+  "detail": "任务尚未生成可下载文件"
+}
+```
+
+任务记录及 `txbId → 最新 request_id` 映射当前保存在 API 进程内存中,服务重启后旧 ID 或 txbId 可能返回 404,即使磁盘文件还在。多实例部署需确保查询访问持有该任务记录及映射的实例。
+
+默认清理中间文件时,会先将末次步骤快照保存在任务内存记录中,再清理中间目录。因此同一进程内,处理中间文件清理后仍可查询末次快照;该快照不会因清理自动变成成功或失败状态。
+
+## 7. 推荐对接流程
+
+1. 提交任务后保存 `request_id`,与自己的业务 `txbId` 关联;也可直接用 `txbId` 查询最新任务,再从任务状态响应取实际 `request_id`。
+2. 建议每 3–5 秒查询一次任务状态;当状态为 `queued` 或 `processing` 时,同时查询 `/progress` 更新步骤展示。这是调用建议,服务端没有强制该轮询间隔。
+3. 任务状态为 `failed`:停止轮询,展示 `error`,不提供下载。
+4. 任务状态为 `completed`:停止轮询,提供 `/file` 下载;下载仍需检查 HTTP 状态。
+5. 两个查询不是同一时刻的原子快照,发生矛盾时以任务终态优先;未知字段可以忽略,未知状态保留重试和异常提示。
+
+判定逻辑示意:
+
+```text
+job = GET /api/v1/jobs/{request_id}
+如果查询失败:处理 HTTP/网络异常
+否则如果 job.status == failed:展示 job.error,结束轮询
+否则如果 job.status == completed:展示完成及下载按钮,结束轮询
+否则:
+    progress = GET /api/v1/jobs/{request_id}/progress
+    若有步骤字段:显示当前步骤和已完成步骤
+    否则:显示排队中或处理中
+    等待后继续查询 job
+```
+
+接口没有提供百分比或预计剩余时间。可显示“已完成 2/6 步”;各步骤耗时不同,不宜将步骤比例当作实际耗时百分比。`updated_at` 仅在步骤开始或结束时更新,长时间不变化本身不能证明失败。
+
+对接验收至少覆盖:两种 ID 的三个查询接口、同一 txbId 重复提交后最新任务切换及历史任务可查、提交失败不污染映射、排队无步骤字段、步骤 running、中间步骤 completed、失败但进度仍 running、Step6 completed 但任务仍 processing、任务 completed 后下载,以及 404/409。

+ 3 - 3
scripts/test_step1.py

@@ -18,16 +18,16 @@ from _bootstrap import PROJECT_ROOT
 # ---------- 测试数据(环境变量可覆盖) ----------
 TENDER_FILE = os.environ.get(
     "PROPOSA_TENDER_FILE",
-    "test_data/171-上海群众艺术馆/上海市群众艺术馆物业管理服务采购项目招标文件.pdf",
+    "test_data/step3_error/tender.pdf",
 )
 PROCUREMENT_FILE = os.environ.get(
     "PROPOSA_PROCUREMENT_FILE",
-    "test_data/171-上海群众艺术馆/采购需求.docx",
+    "test_data/step3_error/procurement.docx",
 )
 CLARIFICATION_FILE = os.environ.get("PROPOSA_CLARIFICATION_FILE", "")
 OUTPUT_DIR = os.environ.get(
     "PROPOSA_STEP1_OUTPUT_DIR",
-    os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
+    os.environ.get("PROPOSA_WORK_DIR", "output/step3"),
 )
 STEP1_ITEMS_FILE = os.environ.get(
     "PROPOSA_STEP1_ITEMS_FILE",

+ 4 - 4
scripts/test_step2.py

@@ -15,11 +15,11 @@ from _bootstrap import PROJECT_ROOT
 # ---------- 测试数据(环境变量可覆盖) ----------
 TENDER_FILE = os.environ.get(
     "PROPOSA_TENDER_FILE",
-    "test_data/171-上海群众艺术馆/上海市群众艺术馆物业管理服务采购项目招标文件.pdf",
+    "test_data/step3_error/tender.pdf",
 )
 PROCUREMENT_FILE = os.environ.get(
     "PROPOSA_PROCUREMENT_FILE",
-    "test_data/171-上海群众艺术馆/采购需求.docx",
+    "test_data/step3_error/procurement.docx",
 )
 CLARIFICATION_FILE = os.environ.get("PROPOSA_CLARIFICATION_FILE", "")
 COMPANY_INFO_DIR = os.environ.get(
@@ -28,11 +28,11 @@ COMPANY_INFO_DIR = os.environ.get(
 )
 REFERENCE_BID = os.environ.get(
     "PROPOSA_REFERENCE_BID",
-    "test_data/171-上海群众艺术馆/参考投标文件/物业管理费项目投标文件.docx",
+    "test_data/step3_error/reference_bid.docx",
 )
 OUTPUT_DIR = os.environ.get(
     "PROPOSA_STEP2_OUTPUT_DIR",
-    os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
+    os.environ.get("PROPOSA_WORK_DIR", "output/step3"),
 )
 STEP1_ITEMS_FILE = os.environ.get(
     "PROPOSA_STEP1_ITEMS_FILE",

+ 2 - 2
scripts/test_step3.py

@@ -20,11 +20,11 @@ TEMPLATE_PATH = os.environ.get(
 )
 REFERENCE_BID = os.environ.get(
     "PROPOSA_REFERENCE_BID",
-    "test_data/171-上海群众艺术馆/参考投标文件/物业管理费项目投标文件.docx",
+    "test_data/step3_error/reference_bid.docx",
 )
 OUTPUT_DIR = os.environ.get(
     "PROPOSA_STEP3_OUTPUT_DIR",
-    os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
+    os.environ.get("PROPOSA_WORK_DIR", "output/step3"),
 )
 STEP2_INFO_FILE = os.environ.get(
     "PROPOSA_STEP2_INFO_FILE",

+ 2 - 2
scripts/test_step4.py

@@ -17,7 +17,7 @@ from _bootstrap import PROJECT_ROOT
 
 REFERENCE_BID = os.environ.get(
     "PROPOSA_REFERENCE_BID",
-    "test_data/171-上海群众艺术馆/参考投标文件/物业管理费项目投标文件.docx",
+    "test_data/step3_error/reference_bid.docx",
 )
 
 TEMPLATE_PATH = os.environ.get(
@@ -26,7 +26,7 @@ TEMPLATE_PATH = os.environ.get(
 
 OUTPUT_DIR = os.environ.get(
     "PROPOSA_STEP4_OUTPUT_DIR",
-    os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
+    os.environ.get("PROPOSA_WORK_DIR", "output/step3"),
 )
 STEP2_INFO_FILE = os.environ.get(
     "PROPOSA_STEP2_INFO_FILE",

+ 1 - 1
scripts/test_step5.py

@@ -29,7 +29,7 @@ from models import BidOutline, Chapter, ChapterType
 
 OUTPUT_DIR = os.environ.get(
     "PROPOSA_STEP5_OUTPUT_DIR",
-    os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
+    os.environ.get("PROPOSA_WORK_DIR", "output/step3"),
 )
 os.environ.setdefault("BID_LLM_CACHE_DIR", os.path.join(OUTPUT_DIR, ".llm_cache"))
 os.environ.setdefault("BID_LLM_CACHE", "0")

+ 2 - 2
scripts/test_step6.py

@@ -34,7 +34,7 @@ from _bootstrap import PROJECT_ROOT
 
 OUTPUT_DIR = os.environ.get(
     "PROPOSA_STEP6_OUTPUT_DIR",
-    os.environ.get("PROPOSA_WORK_DIR", "output/171-上海群众艺术馆"),
+    os.environ.get("PROPOSA_WORK_DIR", "output/step3"),
 )
 STEP5_CHAPTERS_DIR = os.environ.get(
     "PROPOSA_STEP5_CHAPTERS_DIR",
@@ -58,7 +58,7 @@ REFERENCE_TABLES_DIR = os.environ.get(
 )
 OUTPUT_FILE = os.environ.get(
     "PROPOSA_STEP6_OUTPUT",
-    os.path.join(OUTPUT_DIR, "171-上海群众艺术馆.docx"),
+    os.path.join(OUTPUT_DIR, "step3.docx"),
 )
 
 

+ 216 - 0
scripts/tests/test_new_scoring_chapters.py

@@ -0,0 +1,216 @@
+"""新增一级评分章跨 Step3-6 的离线回归,全部使用合成资料。"""
+
+import copy
+import importlib.util
+import os
+import sys
+import tempfile
+import unittest
+import zipfile
+import xml.etree.ElementTree as ET
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+from docx import Document
+from docx.oxml import OxmlElement
+from docx.oxml.ns import qn
+from models import BidOutline, Chapter, CompanyInfo, ScoringCriterion, TenderAnalysis
+from step3_outlining import _write_and_enforce_outline_gate
+from step3_outlining.outline_report import build_outline_report, validate_outline_gate
+from step3_outlining.scoring_structure import apply_scoring_structure, _int_to_chinese
+from step4_writing import _get_template_text_for_chapter, _save_chapter_docx, _write_single_chapter
+from step5_reviewing.policies import find_template_integrity_issues
+from step5_reviewing.reviewer import _Reviewer
+from step6_exporting.assembler import assemble_step5_document
+
+
+class NewScoringChapterTests(unittest.TestCase):
+    def test_inserted_chapter_never_reads_template_even_with_stale_section(self):
+        chapter = Chapter(id='6', title='专项类别', is_scoring_inserted=True,
+                          template_section='第六章 项目经理')
+        with patch('step4_writing.get_template_text_for_section') as section, patch(
+            'step4_writing.get_template_text_for_chapter'
+        ) as title:
+            self.assertEqual(_get_template_text_for_chapter(object(), chapter), '')
+        section.assert_not_called()
+        title.assert_not_called()
+
+    def test_shifted_chapter_fallback_uses_template_source_number(self):
+        for source in ({'template_chapter_id': '6'}, {'template_original_id': '6'}):
+            with self.subTest(source=source):
+                chapter = Chapter(id='9', title='经理章节别名', **source)
+                with patch('step4_writing.get_template_text_for_chapter',
+                           side_effect=lambda _, title: {'项目经理': '正确经理正文',
+                                                       '错误模板章': '错误正文'}.get(title, '')), patch(
+                    'step3_outlining.template_parser.get_template_chapter_titles',
+                    return_value=[('6', '项目经理'), ('9', '错误模板章')]
+                ):
+                    self.assertEqual(_get_template_text_for_chapter(
+                        SimpleNamespace(file_path='synthetic.docx'), chapter
+                    ), '正确经理正文')
+
+    def test_inserted_chapter_writing_has_no_wrong_template_base(self):
+        chapter = self.outline.chapters[5]
+        injector = Mock()
+        injector.get_scoring_context.return_value = ''
+
+        def generate(chapter, *_args):
+            chapter.children[0].supplement_content = '专项执行响应正文'
+            return 1
+
+        with patch('step4_writing.get_template_text_for_chapter',
+                   return_value='不应读取的经理正文') as lookup, patch(
+            'step4_writing._generate_direct_content_blocks', side_effect=generate
+        ):
+            result = _write_single_chapter(
+                chapter, Mock(), injector, self.analysis, CompanyInfo(),
+                object(), SimpleNamespace(enforce_word_limit=False), self.outline, {}
+            )
+        lookup.assert_not_called()
+        self.assertIn('专项执行响应正文', result.generated_content)
+        self.assertNotIn('经理正文', result.generated_content)
+        self.assertEqual(result.content_blocks[0]['block_type'], 'generated_scoring_chapter')
+
+    @staticmethod
+    def _load_stage(number):
+        scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+        spec = importlib.util.spec_from_file_location(
+            f'new_scoring_stage{number}', os.path.join(scripts_dir, f'test_step{number}.py')
+        )
+        module = importlib.util.module_from_spec(spec)
+        with patch.object(sys, 'path', [scripts_dir, *sys.path]), patch.dict(os.environ):
+            spec.loader.exec_module(module)
+        return module
+
+    def setUp(self):
+        titles = ['资格证明', '投标报价', '需求理解', '服务方案', '质量保障',
+                  '项目经理', '人员配置', '附件']
+        self.template = BidOutline(project_name='合成项目', chapters=[
+            Chapter(id=str(i), title=title, level=1, template_chapter_id=str(i),
+                    template_original_id=str(i), from_template=True)
+            for i, title in enumerate(titles, 1)
+        ])
+        self.analysis = TenderAnalysis(project_name='合成项目', scoring_criteria=[
+            ScoringCriterion(id=f'SC-{i}', category=f'专项类别{i}', name=f'专项措施{i}',
+                             description=f'说明专项措施{i}的执行流程', max_score=5)
+            for i in range(1, 4)
+        ])
+        self.outline = copy.deepcopy(self.template)
+        self.outline.template_chapters = copy.deepcopy(self.template.chapters)
+        apply_scoring_structure(self.outline, self.analysis.scoring_criteria)
+
+    def test_gate_accepts_new_categories_without_approval(self):
+        for value in ['', '其他历史类别']:
+            with self.subTest(legacy_value=value), patch.dict(
+                os.environ, {'BID_APPROVED_NEW_SCORING_CATEGORIES': value}
+            ), tempfile.TemporaryDirectory() as tmp:
+                _write_and_enforce_outline_gate(
+                    self.outline, self.analysis, os.path.join(tmp, 'report.md')
+                )
+        self.assertEqual([c.title for c in self.outline.chapters[5:9]],
+                         ['专项类别1', '专项类别2', '专项类别3', '项目经理'])
+        self.assertEqual([c.id for c in self.outline.chapters],
+                         [str(i) for i in range(1, 12)])
+
+    def test_gate_still_rejects_unauthorized_and_missing_template_chapters(self):
+        self.outline.chapters[5].is_scoring_inserted = False
+        self.outline.chapters.pop()
+        errors = validate_outline_gate(self.template, self.outline, self.analysis)
+        self.assertTrue(any('授权评分' in error for error in errors))
+        self.assertTrue(any('缺少模板章' in error for error in errors))
+
+    def test_new_chapters_keep_own_content_and_shifted_template_sources_through_export(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            template_path = os.path.join(tmp, 'template.docx')
+            doc = Document()
+            doc.sections[0].header.paragraphs[0].text = '合成项目 合成页眉'
+            doc.sections[0].footer.paragraphs[0].text = '合成页脚'
+            footer = doc.sections[0].footer.paragraphs[0]
+            begin = OxmlElement('w:fldChar')
+            begin.set(qn('w:fldCharType'), 'begin')
+            footer.add_run()._r.append(begin)
+            instruction = OxmlElement('w:instrText')
+            instruction.text = ' PAGE '
+            footer.add_run()._r.append(instruction)
+            end = OxmlElement('w:fldChar')
+            end.set(qn('w:fldCharType'), 'end')
+            footer.add_run()._r.append(end)
+            for chapter in self.template.chapters:
+                doc.add_paragraph(f'第{_int_to_chinese(int(chapter.id))}章 {chapter.title}',
+                                  style='Heading 1')
+                doc.add_paragraph(f'模板来源正文{chapter.id}')
+                if chapter.id == '6':
+                    doc.add_table(rows=1, cols=1).cell(0, 0).text = '经理原生表'
+            doc.save(template_path)
+
+            records = []
+            for chapter in self.outline.chapters:
+                if chapter.is_scoring_inserted:
+                    for node in chapter.children:
+                        node.supplement_content = f'{node.title}的专属响应正文。'
+                        node.content_blocks = [{'block_type': 'scoring_supplement',
+                                                'target_heading_id': node.id}]
+                path = _save_chapter_docx(chapter, tmp, template_path=template_path)
+                artifact = Document(path)
+                text = '\n'.join(p.text for p in artifact.paragraphs)
+                if chapter.is_scoring_inserted:
+                    self.assertNotIn('模板来源正文', text,
+                                     '新增评分章不得按最终编号误裁模板正文')
+                    self.assertEqual(len(artifact.tables), 0)
+                    self.assertIn(chapter.children[0].supplement_content, text)
+                else:
+                    self.assertIn(f'模板来源正文{chapter.template_chapter_id}', text)
+                self.assertEqual(artifact.sections[0].header.paragraphs[0].text, '合成项目 合成页眉')
+                records.append(dict(id=chapter.id, title=chapter.title,
+                                    artifact_path=path, status='complete'))
+
+            # Step5 仍按持久化的最终 ID 找到新增章子节点,授权正文不被误删。
+            stage5 = self._load_stage(5)
+            stage6 = self._load_stage(6)
+            _, payload = build_outline_report(self.template, self.outline, self.analysis)
+            chapter_texts = {
+                r['id']: '\n'.join(p.text for p in Document(r['artifact_path']).paragraphs)
+                for r in records
+            }
+            reviewed = stage5._build_outline_from_report(payload, chapter_texts)
+            reviewer = _Reviewer.__new__(_Reviewer)
+            self.assertEqual(find_template_integrity_issues(self.outline), [])
+            for entry in self.outline.evaluation_index_entries:
+                nodes = reviewer._bound_nodes(self.outline, [entry])
+                self.assertEqual(len(nodes), 1)
+                self.assertEqual(nodes[0].id, entry['final_heading_id'])
+                self.assertIn('专属响应正文', nodes[0].supplement_content)
+                self.assertIn('专属响应正文', reviewer._bound_entry_evidence(reviewed, entry))
+
+            step5_dir = os.path.join(tmp, 'step5')
+            stage5._write_step5_chapters_from_step4(records, reviewed, self.analysis, step5_dir)
+            records = stage6._load_chapter_records(step5_dir)
+
+            output_path = os.path.join(tmp, 'final.docx')
+            report = assemble_step5_document(list(reversed(records)), reviewed,
+                                            output_path, template_path=template_path)
+            audit = stage6._assert_output_structure(output_path, reviewed)
+            self.assertEqual(audit['chapters'], 11)
+            self.assertEqual(report.chapter_count, 11)
+            self.assertEqual(report.index_row_count, 3)
+            result = Document(output_path)
+            headings = [p.text for p in result.paragraphs if p.style.name == 'Heading 1']
+            self.assertEqual(headings, [
+                f'第{_int_to_chinese(int(c.id))}章 {c.title}' for c in self.outline.chapters
+            ])
+            text = '\n'.join(p.text for p in result.paragraphs)
+            for i in range(1, 9):
+                self.assertEqual(text.count(f'模板来源正文{i}'), 1)
+            self.assertEqual(sum(t.cell(0, 0).text == '经理原生表' for t in result.tables), 1)
+            with zipfile.ZipFile(output_path) as package:
+                root = ET.fromstring(package.read('word/document.xml'))
+            ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
+            bookmarks = {n.get(ns + 'name') for n in root.iter(ns + 'bookmarkStart')}
+            refs = [n.text.split()[1] for n in root.iter(ns + 'instrText')
+                    if 'PAGEREF' in (n.text or '')]
+            self.assertEqual(len(refs), 3)
+            self.assertTrue(set(refs) <= bookmarks)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 1 - 1
scripts/tests/test_step4_block_generation.py

@@ -470,7 +470,7 @@ class Step4BlockGenerationTests(unittest.TestCase):
         self.assertEqual(len(writer.llm.calls), 5)
         self.assertEqual(
             [call["max_tokens"] for call in writer.llm.calls],
-            [32768, 65536, 65536, 65536, 65536],
+            [65536, 65536, 65536, 65536, 65536],
         )
         self.assertEqual(child1.supplement_content, "服务定位正文")
         self.assertEqual(child2.supplement_content, "重点难点正文")

+ 3 - 3
src/config.py

@@ -57,9 +57,9 @@ class LLMConfig:
     generate_temperature: float = 0.4  # 生成场景:中等温度,保持一致性
     creative_temperature: float = 0.7  # 创意场景:较高温度,更灵活
 
-    analyze_max_tokens: int = 32768
+    analyze_max_tokens: int = 65536
     generate_max_tokens: int = 65536
-    review_max_tokens: int = 16384
+    review_max_tokens: int = 32768
 
 
 @dataclass
@@ -108,7 +108,7 @@ class BidAgentConfig:
         not in ("0", "false", "False")
     )
     # 大纲 LLM 优化的输出 token 上限
-    outline_refine_max_tokens: int = 8192
+    outline_refine_max_tokens: int = 16384
     # Step H 标题层级分析的 LLM 并发任务数(可环境变量 BID_HEADING_CONCURRENCY 覆盖)
     heading_analysis_concurrency: int = field(
         default_factory=lambda: _env_int("BID_HEADING_CONCURRENCY", 5)

+ 4 - 4
src/llm_client/client.py

@@ -162,7 +162,7 @@ class LLMClient:
         self,
         system_prompt: str,
         user_prompt: str,
-        max_tokens: int = 16384,
+        max_tokens: int = 32768,
         temperature: float = 0.7,
     ) -> str:
         """通用对话接口
@@ -187,7 +187,7 @@ class LLMClient:
         self,
         system_prompt: str,
         user_prompt: str,
-        max_tokens: int = 32768,
+        max_tokens: int = 65536,
         temperature: float = 0.1,
     ) -> Union[Dict[str, Any], List[Any]]:
         """强制 JSON 格式提取
@@ -291,7 +291,7 @@ class LLMClient:
         self,
         text: str,
         instruction: str = "",
-        max_tokens: int = 16384,
+        max_tokens: int = 32768,
     ) -> str:
         """文本分析
 
@@ -317,7 +317,7 @@ class LLMClient:
         self,
         system_prompt: str,
         user_prompt: str,
-        max_tokens: int = 16384,
+        max_tokens: int = 32768,
         temperature: float = 0.4,
     ) -> str:
         """长文本生成

+ 3 - 3
src/step1_parsing/document_parser.py

@@ -536,7 +536,7 @@ def _llm_name_docx_table(
         result = llm.chat(
             system_prompt=_DOCX_TABLE_NAME_PROMPT,
             user_prompt=user_prompt,
-            max_tokens=512,
+            max_tokens=1024,
             temperature=0.2,
         )
         name = result.strip().strip('"').strip("'").strip()
@@ -576,7 +576,7 @@ def _llm_name_extracted_table_item(
         result = llm.chat(
             system_prompt=_EXTRACTED_TABLE_NAME_PROMPT,
             user_prompt=user_prompt,
-            max_tokens=512,
+            max_tokens=1024,
             temperature=0.2,
         )
         name = result.strip().strip('"').strip("'").strip()
@@ -2089,7 +2089,7 @@ def _review_and_fix_content(
             result = llm.chat(
                 system_prompt="你是招标文件审核专家。只按要求的格式输出,不加额外说明。",
                 user_prompt=prompt,
-                max_tokens=16384,
+                max_tokens=32768,
                 temperature=0.1,
             )
         except Exception as e:

+ 1 - 1
src/step1_parsing/table_extractor.py

@@ -1001,7 +1001,7 @@ def _llm_enrich_one_table(
     result = llm.extract_json(
         system_prompt=_REFERENCE_TABLE_UNIT_PROMPT,
         user_prompt=user_prompt,
-        max_tokens=4096,
+        max_tokens=8192,
     )
     if not isinstance(result, dict):
         return None

+ 5 - 5
src/step2_analysis/analyzer.py

@@ -455,7 +455,7 @@ class _DeepAnalyzer:
             result = self.llm.chat(
                 system_prompt="你是采购需求结构化分析专家。",
                 user_prompt=f"{prompt}\n\n---\n{short_text}",
-                max_tokens=32768,
+                max_tokens=65536,
                 temperature=0.1,
             )
             return result.strip()
@@ -479,7 +479,7 @@ class _DeepAnalyzer:
             raw = self.llm.chat(
                 system_prompt="你是招标项目字段提取专家,只提取有原文依据的信息。",
                 user_prompt=f"{prompt}\n\n---\n{text[:18000]}",
-                max_tokens=4096,
+                max_tokens=8192,
                 temperature=0.0,
             ).strip()
             match = re.search(r"\{.*\}", raw, re.S)
@@ -515,7 +515,7 @@ class _DeepAnalyzer:
             result = self.llm.chat(
                 system_prompt="你是招标文件分析专家。请准确提取和总结招标文件中的关键要求。",
                 user_prompt=f"{prompt}\n\n---\n{short_text}",
-                max_tokens=32768,
+                max_tokens=65536,
                 temperature=0.1,
             )
             return result.strip()
@@ -544,7 +544,7 @@ class _DeepAnalyzer:
             result = self.llm.extract_json(
                 system_prompt="提取包件信息为 JSON 数组。",
                 user_prompt=f"{prompt}\n\n---\n{short_text}",
-                max_tokens=16384,
+                max_tokens=32768,
             )
             if isinstance(result, list):
                 return [
@@ -569,7 +569,7 @@ class _DeepAnalyzer:
             result = self.llm.chat(
                 system_prompt=f"请从文本中提取\"{label}\"。只输出结果,不要解释。如果找不到输出\"未知\"。",
                 user_prompt=short,
-                max_tokens=1024,
+                max_tokens=2048,
                 temperature=0.0,
             )
             val = result.strip().strip('"').strip("'")

+ 2 - 2
src/step2_analysis/format_reconciler.py

@@ -246,7 +246,7 @@ def llm_judge_format(
         result = llm.extract_json(
             system_prompt=FORMAT_JUDGE_PROMPT,
             user_prompt="\n".join(prompt_parts),
-            max_tokens=4096,
+            max_tokens=8192,
         )
         if isinstance(result, dict):
             decision = result.get("decision", "keep_template")
@@ -515,7 +515,7 @@ def _llm_select_reconciliation_table(
         result = llm.extract_json(
             system_prompt=_RECONCILIATION_TABLE_SELECT_PROMPT,
             user_prompt="\n".join(lines),
-            max_tokens=1024,
+            max_tokens=2048,
         )
         if isinstance(result, dict):
             idx = result.get("index")

+ 1 - 1
src/step2_analysis/material_matcher.py

@@ -82,7 +82,7 @@ def match_materials(
                 "输出 JSON 对象。"
             ),
             user_prompt=prompt,
-            max_tokens=16384,
+            max_tokens=32768,
         )
 
         if isinstance(result, dict):

+ 0 - 13
src/step3_outlining/outline_report.py

@@ -3,7 +3,6 @@
 from __future__ import annotations
 
 import json
-import os
 from pathlib import Path
 from typing import Iterable
 
@@ -130,11 +129,6 @@ def validate_outline_gate(
     )
 
     top_titles: dict[str, list[str]] = {}
-    approved_new_h1 = {
-        normalize_heading_text(value)
-        for value in os.getenv("BID_APPROVED_NEW_SCORING_CATEGORIES", "").split(",")
-        if value.strip()
-    }
     for chapter in enhanced_outline.chapters:
         if chapter.level != 1 or not chapter.id.isdigit():
             continue
@@ -146,13 +140,6 @@ def validate_outline_gate(
             errors.append(
                 f"一级章 {chapter.id}:{chapter.title} 既非模板章,也非授权评分新增章"
             )
-        if (
-            getattr(chapter, "is_scoring_inserted", False)
-            and normalize_heading_text(chapter.title) not in approved_new_h1
-        ):
-            errors.append(
-                f"新增一级评分章尚未人工确认: {chapter.id}:{chapter.title}"
-            )
         if set(chapter.related_criteria or []) != set(
             chapter.direct_scoring_criteria or []
         ):

+ 2 - 2
src/step3_outlining/scoring_structure.py

@@ -141,7 +141,7 @@ def _request_llm_dimension_split(
         result = llm.extract_json(
             system_prompt=_DIMENSION_SPLIT_SYSTEM_PROMPT,
             user_prompt="\n".join(prompt_lines),
-            max_tokens=16384,
+            max_tokens=32768,
             temperature=0.0,
         )
     except Exception as exc:
@@ -321,7 +321,7 @@ def _request_llm_mapping(llm, registry, paths, grouped):
         result = llm.extract_json(
             system_prompt=_MATCH_SYSTEM_PROMPT,
             user_prompt=user_prompt,
-            max_tokens=8192,
+            max_tokens=16384,
             temperature=0.0,
         )
     except Exception as exc:

+ 1 - 1
src/step3_outlining/template_parser.py

@@ -469,7 +469,7 @@ def _llm_disambiguate_section(
                 f"章节标题:{chapter_title}\n\n候选模板区域:\n"
                 + "\n".join(lines)
             ),
-            max_tokens=1024,
+            max_tokens=2048,
             temperature=0.0,
         )
         if not isinstance(result, dict):

+ 18 - 13
src/step4_writing/__init__.py

@@ -63,7 +63,7 @@ _TEMPLATE_PART_MARKERS = frozenset(
     {"商务部分", "技术部分", "附件", "附录"}
 )
 
-_BOUND_BLOCK_MAX_TOKENS_BASE = 32768
+_BOUND_BLOCK_MAX_TOKENS_BASE = 65536
 # 初始调用后再按 max_tokens 翻倍重试 4 次,仍缺节点才进入要求兜底。
 _BOUND_BLOCK_RETRY_ATTEMPTS = 4
 
@@ -470,12 +470,15 @@ def _get_template_text_for_chapter(
     template_structure,
     chapter,
 ) -> str:
-    """获取模板章节原文;优先复用 Step 3 持久化的区域名,失败时按标题/章节编号兜底
+    """获取模板章节原文;新增评分章无模板正文,模板章按来源编号兜底。
 
     商务章(如第二章"投标报价")要求严格按模板格式书写。
     若目录标题与模板标题不一致(如目录用默认"商务响应文件"而模板是
-    "投标报价"),按章节编号回退到模板标题再取一次,避免退化到 LLM 重写
+    "投标报价"),按模板来源编号回退,避免用顺延后的最终编号读取其他章
     """
+    if getattr(chapter, "is_scoring_inserted", False):
+        return ""
+
     # Step 3 已匹配并持久化的模板区域名 → 精确取文本,避免再次标题匹配
     section_name = getattr(chapter, "template_section", "") or ""
     if section_name:
@@ -489,13 +492,14 @@ def _get_template_text_for_chapter(
     try:
         from step3_outlining.template_parser import get_template_chapter_titles
         titles = get_template_chapter_titles(template_structure.file_path)
-        tmpl_title = dict(titles).get(chapter.id, "")
+        source_number = _template_chapter_number(chapter)
+        tmpl_title = dict(titles).get(source_number, "")
         if tmpl_title:
             text = get_template_text_for_chapter(template_structure, tmpl_title)
             if text:
                 logger.info(
                     f"  章节 [{chapter.id}] 标题未直接匹配模板,"
-                    f"已按章节编号回退到模板标题: {tmpl_title}"
+                    f"已按模板来源编号 [{source_number}] 回退到模板标题: {tmpl_title}"
                 )
     except Exception as e:
         logger.debug(f"按章节编号取模板文本失败: {e}")
@@ -1251,7 +1255,7 @@ def _llm_apply_requirement_alignment(
         corrected = llm.generate(
             system_prompt=_REQUIREMENT_ALIGN_PROMPT,
             user_prompt=user_prompt,
-            max_tokens=16384,
+            max_tokens=32768,
             temperature=0.2,
         )
         cleaned = clean_ai_commentary(corrected.strip())
@@ -1417,7 +1421,7 @@ def _llm_pick_attachment_material(
         result = llm.extract_json(
             system_prompt=_ATTACHMENT_MATERIAL_MATCH_PROMPT,
             user_prompt="\n".join(lines),
-            max_tokens=4096,
+            max_tokens=8192,
         )
         if isinstance(result, dict):
             name = str(result.get("material") or "").strip()
@@ -1475,7 +1479,7 @@ def _llm_generate_attachment(
         content = llm.generate(
             system_prompt=_ATTACHMENT_GENERATE_PROMPT,
             user_prompt="\n\n".join(segments),
-            max_tokens=16384,
+            max_tokens=32768,
             temperature=0.3,
         )
         cleaned = clean_ai_commentary(content.strip())
@@ -2800,7 +2804,9 @@ def _save_native_template_chapter_docx(
                 if element.tag == _w_tag("p")
                 and _docx_paragraph_level(element, style_levels) == 1
             ]
-            target_number = _template_chapter_number(chapter)
+            # 新增评分章没有模板正文来源,不能用最终章号裁取同号模板章。
+            new_scoring_chapter = chapter.is_scoring_inserted
+            target_number = "" if new_scoring_chapter else _template_chapter_number(chapter)
             start = None
             for position in h1_positions:
                 text = _docx_xml_text(children[position])
@@ -2816,7 +2822,7 @@ def _save_native_template_chapter_docx(
                     start = position
                     break
             if start is None:
-                if not _is_evaluation_index_chapter(chapter):
+                if not new_scoring_chapter and not _is_evaluation_index_chapter(chapter):
                     return False
                 start = 0
                 chapter_sect_pr = _chapter_section_properties(children, 0)
@@ -3143,9 +3149,8 @@ def _save_chapter_docx(
 ) -> str:
     """保存一个顶层章节 DOCX。
 
-    标题树严格采用 Step3 最终 Chapter 树;template_original_title 只用于
-    找回模板正文,不作为产物标题。该文件用于逐章人工检查,不参与 Step6
-    原生模板 XML 装配。
+    标题树严格采用 Step3 最终 Chapter 树;模板来源编号只用于找回原生
+    正文,新增评分章不裁取模板正文。该文件供 Step5 审核和 Step6 聚合。
     """
     import re
     from docx import Document as DocxDocument

+ 2 - 2
src/step4_writing/chapter_writer.py

@@ -31,7 +31,7 @@ MAX_GENERATE_PER_CALL = 5000
 NO_SOURCE_MARKER = "【未找到参考信息,存在内容未生成情况】"
 
 # 单次 LLM 生成的最大 token 数(章节正文,避免一次输出失控撑爆字数)
-CHAPTER_MAX_TOKENS = 8192
+CHAPTER_MAX_TOKENS = 16384
 
 # ═══════════════════════════════════════════════════════════
 #  Prompt 模板
@@ -977,7 +977,7 @@ class _ChapterWriter:
             overview = self.llm.chat(
                 system_prompt="撰写投标书章节概述,简洁专业。",
                 user_prompt=overview_prompt,
-                max_tokens=4096,
+                max_tokens=8192,
                 temperature=0.4,
             )
         except Exception:

+ 1 - 1
src/step4_writing/company_info_extractor.py

@@ -83,7 +83,7 @@ def extract_company_info(
         result = llm.extract_json(
             system_prompt="你是企业信息提取专家。请从文本中准确提取公司基本信息。",
             user_prompt=f"{prompt}\n\n---\n{intro_text[:12000]}",
-            max_tokens=16384,
+            max_tokens=32768,
         )
         if isinstance(result, dict):
             # LLM 可能返回 null(而非空字符串):str(None) 会生成 "None"

+ 1 - 1
src/step4_writing/placeholder_engine.py

@@ -783,7 +783,7 @@ def llm_resolve_ambiguous_placeholders(
         result = llm.extract_json(
             system_prompt=system,
             user_prompt="\n".join(context_parts),
-            max_tokens=8192,
+            max_tokens=16384,
         )
 
         if isinstance(result, dict):

+ 2 - 2
src/step4_writing/table_form_filler.py

@@ -270,7 +270,7 @@ def llm_fill_nonstandard_responses(
         raw = llm.generate(
             system_prompt=FILL_NONSTANDARD_PROMPT,
             user_prompt="\n".join(context_parts),
-            max_tokens=16384,
+            max_tokens=32768,
             temperature=0.3,
         )
 
@@ -321,7 +321,7 @@ def llm_verify_table_filling(
         result = llm.extract_json(
             system_prompt=VERIFY_FILLED_TABLE_PROMPT,
             user_prompt=f"表格名称: {section_name}\n\n请审核以下表格的填充质量:\n\n{table_md}",
-            max_tokens=8192,
+            max_tokens=16384,
         )
 
         if isinstance(result, dict):

+ 2 - 2
src/step4_writing/template_filler.py

@@ -42,7 +42,7 @@ def fill_qualification_table(
         result = llm.chat(
             system_prompt="你是投标书编写专家。生成专业的资格条件响应表。",
             user_prompt=prompt,
-            max_tokens=16384,
+            max_tokens=32768,
             temperature=0.3,
         )
         return result.strip()
@@ -74,7 +74,7 @@ def fill_substantive_response_table(
         result = llm.chat(
             system_prompt="你是投标书编写专家。生成专业的实质性要求响应表。",
             user_prompt=prompt,
-            max_tokens=16384,
+            max_tokens=32768,
             temperature=0.3,
         )
         return result.strip()

+ 4 - 4
src/step5_reviewing/fixer.py

@@ -454,7 +454,7 @@ class _Fixer:
                     "不得创建新标题。"
                 ),
                 user_prompt=prompt,
-                max_tokens=8192,
+                max_tokens=16384,
                 temperature=0.3,
             )
             addition = _strip_new_structure_headings(generated)
@@ -644,7 +644,7 @@ class _Fixer:
                     content = self.llm.generate(
                         system_prompt="你是投标书撰写专家。撰写正式的投标书章节。",
                         user_prompt=prompt,
-                        max_tokens=32768,
+                        max_tokens=65536,
                         temperature=0.4,
                     )
                     ch.generated_content = content.strip()
@@ -759,7 +759,7 @@ class _Fixer:
                 append_content = self.llm.generate(
                     system_prompt="撰写投标书补充内容,正面响应评分要求。",
                     user_prompt=append_prompt,
-                    max_tokens=8192,
+                    max_tokens=16384,
                     temperature=0.4,
                 )
                 # 直接追加响应内容,不残留"(补充内容 — 响应…)"这类 AI 痕迹包装
@@ -833,7 +833,7 @@ class _Fixer:
                 new_content = self.llm.generate(
                     system_prompt="扩展投标书章节内容,增加细节和深度。",
                     user_prompt=expand_prompt,
-                    max_tokens=32768,
+                    max_tokens=65536,
                     temperature=0.5,
                 )
                 return ch, new_content.strip(), None

+ 6 - 6
src/step5_reviewing/reviewer.py

@@ -185,7 +185,7 @@ class _Reviewer:
                             '输出 JSON:{"uncovered": [{"criteria_id": "ID", '
                             '"reason": "说明"}], "summary": "总结"}'
                         ),
-                        max_tokens=8192,
+                        max_tokens=16384,
                     )
                     if not isinstance(result, dict):
                         llm_failed = True
@@ -249,7 +249,7 @@ class _Reviewer:
                             f"## 本批评分小项与绑定正文({len(batch)}项)\n{payload}\n\n"
                             '请逐一检查并输出 JSON。'
                         ),
-                        max_tokens=8192,
+                        max_tokens=16384,
                     )
                     if not isinstance(result, dict):
                         llm_failed = True
@@ -524,7 +524,7 @@ class _Reviewer:
                     '输出 JSON:{"removals": [{"text": "需删除原文", "reason": "原因"}], '
                     '"fills": [{"label": "服务内容", "value": "..."}]}'
                 ),
-                max_tokens=8192,
+                max_tokens=16384,
             )
             removals = result.get("removals", []) if isinstance(result, dict) else []
             fills = result.get("fills", []) if isinstance(result, dict) else []
@@ -577,7 +577,7 @@ class _Reviewer:
                     f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n"
                     '输出严格 JSON:{"missing": [{"id": "RR-01", "reason": "说明"}]}'
                 ),
-                max_tokens=8192,
+                max_tokens=16384,
             )
             if isinstance(result, dict):
                 missing = result.get("missing", [])
@@ -648,7 +648,7 @@ class _Reviewer:
                     f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n"
                     '输出严格 JSON:{"missing": ["要点1", "要点2"], "summary": "一句话总结"}'
                 ),
-                max_tokens=8192,
+                max_tokens=16384,
             )
             if isinstance(result, dict):
                 missing = result.get("missing", [])
@@ -725,7 +725,7 @@ class _Reviewer:
                         f'输出 JSON 对象格式:{{"risks": [{{"item_id": "RI-01", "description": "风险描述"}}], "summary": "总结"}}\n'
                         f'如果没有风险,输出 {{"risks": [], "summary": "所有废标条件均已正确规避"}}'
                     ),
-                    max_tokens=4096,
+                    max_tokens=8192,
                 )
                 if isinstance(result, dict):
                     risks = result.get("risks", [])

+ 3 - 3
src/step6_exporting/docx_builder.py

@@ -5455,7 +5455,7 @@ def _insert_table_header_fields_with_llm(
         result = llm.extract_json(
             system_prompt=_TABLE_HEADER_FIELDS_PROMPT,
             user_prompt="\n".join(lines),
-            max_tokens=2048,
+            max_tokens=4096,
         )
         if isinstance(result, dict):
             keep = result.get("keep")
@@ -5658,7 +5658,7 @@ def _llm_select_best_tender_table(
         result = llm.extract_json(
             system_prompt=_TENDER_TABLE_SELECT_PROMPT,
             user_prompt="\n".join(lines),
-            max_tokens=1024,
+            max_tokens=2048,
         )
         if isinstance(result, dict):
             idx = result.get("index")
@@ -7242,7 +7242,7 @@ def _llm_pick_header_lines(
         result = llm.extract_json(
             system_prompt=_HEADER_BLOCK_JUDGE_PROMPT,
             user_prompt="\n".join(user),
-            max_tokens=1024,
+            max_tokens=2048,
         )
         if isinstance(result, dict):
             keep = result.get("keep")

+ 1 - 1
src/step6_exporting/format_applier.py

@@ -240,7 +240,7 @@ def _llm_is_header_row(first_row_texts: list):
         result = llm.extract_json(
             system_prompt=_HEADER_JUDGE_PROMPT,
             user_prompt=f"表格第一行单元格内容:\n{user}",
-            max_tokens=512,
+            max_tokens=1024,
         )
         if isinstance(result, dict):
             val = result.get("is_header")

+ 1 - 1
src/step6_exporting/format_verifier.py

@@ -456,7 +456,7 @@ def _run_llm_check(prompt: str, label: str) -> List[FormatIssue]:
         data = llm.extract_json(
             system_prompt=SYSTEM_PROMPT,
             user_prompt=prompt,
-            max_tokens=16384,
+            max_tokens=32768,
         )
         raw = json.dumps(data, ensure_ascii=False) if isinstance(data, dict) else ""
         return _parse_llm_issues(raw)

+ 2 - 2
src/step6_exporting/heading_analyzer.py

@@ -1546,7 +1546,7 @@ def _llm_judge_sibling_group(
         result = LLMClient().extract_json(
             system_prompt="你是投标书标题识别专家。只输出 JSON。",
             user_prompt=prompt,
-            max_tokens=512,
+            max_tokens=1024,
         )
         if isinstance(result, dict):
             val = result.get("is_title_group")
@@ -1607,7 +1607,7 @@ def _analyze_headings_task(
         data = llm.extract_json(
             system_prompt=system_prompt,
             user_prompt=user_prompt,
-            max_tokens=32768,  # 段落全文较长时输出易超 8192 导致截断,提到 32768
+            max_tokens=65536,  # 段落全文较长时输出易超 8192 导致截断,提到 65536
         )
     except Exception as e:
         logger.warning(f"{label} LLM 标题分析失败: {e}")

+ 1 - 1
src/step6_exporting/table_filler.py

@@ -409,7 +409,7 @@ def llm_judge_reuse_reference(
         result = llm.extract_json(
             system_prompt=REUSE_JUDGE_PROMPT,
             user_prompt="\n".join(prompt_parts),
-            max_tokens=4096,
+            max_tokens=8192,
         )
         if isinstance(result, dict):
             can_reuse = result.get("can_reuse", False)

+ 1 - 1
src/step6_exporting/title_checker.py

@@ -176,7 +176,7 @@ def llm_fix_issue(doc: DocxDocument, issue: Dict) -> int:
         result = llm.extract_json(
             system_prompt=_LLM_FIX_PROMPT,
             user_prompt=user_prompt,
-            max_tokens=2048,
+            max_tokens=4096,
         )
     except Exception as e:
         logger.warning(f"标题序号 LLM 修复失败: {e}")