Преглед изворни кода

refactor(分类器优化): 完善层级分类器并清理冗余代码

- 新增标题提取规则库,支持多级数字、中文数字、节格式等
- 新增二级标题白名单校验机制
- 优化分类合并阈值 (+1 → +3)
- 细化 section_key 为 group_key
- 新增补充验证机制(关键字扫描+表格检测)
- 添加结果去重(按code/start/end三元组)
- 更新 PDF提取器,移除旧版本
- 删除废弃的words_detect和llm_chain_client模块
WangXuMing пре 3 дана
родитељ
комит
069891a393
40 измењених фајлова са 725 додато и 2815 уклоњено
  1. 119 4
      config/模型调用指南.md
  2. 0 15
      core/base/words_detect/__init__.py
  3. 0 39
      core/base/words_detect/config/config.yaml
  4. 0 75
      core/base/words_detect/core/prompt_builder.py
  5. 0 98
      core/base/words_detect/core/reviewer.py
  6. 0 65
      core/base/words_detect/examples/example.py
  7. 0 63
      core/base/words_detect/错误识别.yaml
  8. 296 0
      core/construction_review/component/doc_worker/classification/hierarchy_classifier.py
  9. 14 14
      core/construction_review/component/doc_worker/config/StandardCategoryTable.csv
  10. 14 0
      core/construction_review/component/minimal_pipeline/pdf_extractor.py
  11. 0 481
      core/construction_review/component/minimal_pipeline/pdf_extractor3.py
  12. 30 7
      core/construction_review/component/minimal_pipeline/simple_processor.py
  13. 0 119
      core/construction_review/component/minimal_pipeline/test.py
  14. 3 0
      core/construction_review/component/minimal_pipeline/toc_builder.py
  15. 14 11
      core/construction_review/component/minimal_pipeline/toc_detector.py
  16. 61 7
      core/construction_review/component/reviewers/completeness_reviewer.py
  17. 0 400
      core/construction_review/component/reviewers/utils/llm_chain_client/README.md
  18. 0 36
      core/construction_review/component/reviewers/utils/llm_chain_client/__init__.py
  19. 0 256
      core/construction_review/component/reviewers/utils/llm_chain_client/bootstrap.py
  20. 0 1
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/__init__.py
  21. 0 4
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/chains/__init__.py
  22. 0 178
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/chains/async_chain_executor.py
  23. 0 14
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/__init__.py
  24. 0 129
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/base_client.py
  25. 0 22
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/deepseek_client.py
  26. 0 22
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/doubao_client.py
  27. 0 22
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/gemini_client.py
  28. 0 22
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/qwen_client.py
  29. 0 4
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/loaders/__init__.py
  30. 0 105
      core/construction_review/component/reviewers/utils/llm_chain_client/implementations/loaders/yaml_prompt_loader.py
  31. 0 6
      core/construction_review/component/reviewers/utils/llm_chain_client/interfaces/__init__.py
  32. 0 46
      core/construction_review/component/reviewers/utils/llm_chain_client/interfaces/chain_executor.py
  33. 0 35
      core/construction_review/component/reviewers/utils/llm_chain_client/interfaces/llm_client.py
  34. 0 62
      core/construction_review/component/reviewers/utils/llm_chain_client/interfaces/prompt_loader.py
  35. 0 68
      core/construction_review/component/reviewers/utils/llm_chain_client/main.py
  36. 0 4
      core/construction_review/component/reviewers/utils/llm_chain_client/orchestration/__init__.py
  37. 0 86
      core/construction_review/component/reviewers/utils/llm_chain_client/orchestration/prompt_chain_processor.py
  38. 0 10
      core/construction_review/component/reviewers/utils/llm_chain_client/requirements.txt
  39. 174 0
      foundation/ai/agent/generate/model_generate.py
  40. 0 285
      foundation/ai/agent/generate/model_generate.py.bak

+ 119 - 4
config/模型调用指南.md

@@ -52,7 +52,19 @@ default:
 
 ## 模型调用方式
 
-### 方式一:使用 function_name(推荐)
+### 如何选择同步/异步版本
+
+| 场景 | 推荐版本 | 说明 |
+|------|---------|------|
+| 异步函数/协程中 | `await get_model_generate_invoke()` | 标准异步调用 |
+| 同步函数/普通代码中 | `get_model_generate_invoke_sync()` | 同步阻塞调用 |
+| 已有事件循环中 | `get_model_generate_invoke_sync()` | 避免嵌套事件循环问题 |
+
+**快速判断**:
+- 如果你的代码在 `async def` 函数中 → 使用异步版本
+- 如果你的代码在普通 `def` 函数中 → 使用同步版本
+
+### 方式一:使用 function_name(推荐 - 异步版本)
 
 通过功能名称自动从 `model_setting.yaml` 加载对应的模型和 thinking 模式。
 
@@ -68,9 +80,29 @@ response = await generate_model_client.get_model_generate_invoke(
 )
 ```
 
-**适用场景**:业务功能调用,如分类、审查等。
+**适用场景**:异步上下文(如 FastAPI 请求处理器、异步任务)。
+
+### 方式一(同步):使用 function_name(推荐 - 同步版本)
+
+在同步上下文中使用,功能与异步版本完全一致。
+
+```python
+from foundation.ai.agent.generate.model_generate import generate_model_client
+
+# 同步调用(用于普通函数、同步上下文)
+response = generate_model_client.get_model_generate_invoke_sync(
+    trace_id="my_trace_id",
+    system_prompt="你是专家",
+    user_prompt="请分析...",
+    function_name="doc_classification_tertiary"  # 功能名称
+)
+```
 
-### 方式二:使用 model_name
+**适用场景**:同步上下文(如普通函数、已有事件循环环境中)。
+
+**注意**:同步版本不支持 `timeout` 参数(使用构造时的默认值)。
+
+### 方式二:使用 model_name(异步版本)
 
 直接指定模型名称,跳过 `model_setting.yaml` 配置。
 
@@ -88,6 +120,24 @@ response = await generate_model_client.get_model_generate_invoke(
 
 **适用场景**:需要临时切换模型,或测试特定模型性能。
 
+### 方式二(同步):使用 model_name(同步版本)
+
+在同步上下文中直接指定模型名称。
+
+```python
+from foundation.ai.agent.generate.model_generate import generate_model_client
+
+# 同步调用,直接指定模型
+response = generate_model_client.get_model_generate_invoke_sync(
+    trace_id="my_trace_id",
+    system_prompt="你是专家",
+    user_prompt="请分析...",
+    model_name="shutian_qwen3_5_122b"  # 直接指定模型
+)
+```
+
+**适用场景**:同步上下文中需要临时切换模型。
+
 ### 方式三:使用 model_handler
 
 通过 `model_handler` 获取模型实例,进行更底层的操作。
@@ -216,6 +266,7 @@ model_settings:
 
 2. **在代码中使用:**
 
+**异步版本:**
 ```python
 response = await generate_model_client.get_model_generate_invoke(
     trace_id="xxx",
@@ -224,12 +275,37 @@ response = await generate_model_client.get_model_generate_invoke(
 )
 ```
 
+**同步版本:**
+```python
+response = generate_model_client.get_model_generate_invoke_sync(
+    trace_id="xxx",
+    messages=messages,
+    function_name="my_new_feature"
+)
+```
+
 ## 注意事项
 
 1. **优先使用 `function_name`**:便于统一管理和调整模型配置
 2. **不要随意修改 `available_models`**:必须与 `model_handler.py` 中的模型类型名称一致
 3. **保留 `default` 配置**:作为兜底方案,防止功能未指定时出错
 4. **thinking 模式**:仅对 Qwen3.5 系列模型有效,其他模型自动忽略
+5. **同步/异步选择**:
+   - 异步版本 (`get_model_generate_invoke`):适用于 `async def` 函数
+   - 同步版本 (`get_model_generate_invoke_sync`):适用于普通 `def` 函数,避免嵌套事件循环
+6. **在已有事件循环中使用**:如果你在 Jupyter Notebook 或其他已有事件循环的环境中,使用同步版本
+
+## 同步/异步选择决策树
+
+```
+你在什么上下文中?
+├── async def 函数中
+│   └── 使用 await generate_model_client.get_model_generate_invoke(...)
+├── def 普通函数中
+│   └── 使用 generate_model_client.get_model_generate_invoke_sync(...)
+└── 不确定/已有事件循环(如 Jupyter)
+    └── 使用 generate_model_client.get_model_generate_invoke_sync(...)
+```
 
 ## 故障排查
 
@@ -249,9 +325,48 @@ response = await generate_model_client.get_model_generate_invoke(
 
 检查代码是否正确传入了 `function_name`,而不是硬编码 `model_name`。
 
+### 同步/异步版本选择错误
+
+**错误现象**:`RuntimeWarning: coroutine was never awaited`
+
+**原因**:在同步函数中使用了异步版本的调用。
+
+**解决方案**:
+```python
+# 错误:在普通函数中使用异步版本
+def my_sync_function():
+    response = await generate_model_client.get_model_generate_invoke(...)  # ❌
+
+# 正确:在普通函数中使用同步版本
+def my_sync_function():
+    response = generate_model_client.get_model_generate_invoke_sync(...)   # ✓
+```
+
+**错误现象**:`RuntimeError: This event loop is already running`
+
+**原因**:在已有事件循环的环境中(如 Jupyter Notebook)尝试运行异步代码。
+
+**解决方案**:使用同步版本 `get_model_generate_invoke_sync`。
+
+### 嵌套事件循环问题
+
+**错误现象**:`asyncio.run() cannot be called from a running event loop`
+
+**原因**:在异步函数内部尝试使用 `asyncio.run()` 运行另一个异步调用。
+
+**解决方案**:直接使用 `await` 调用异步版本,或将内部调用改为同步版本。
+
 ## 相关文件
 
 - `config/model_setting.yaml` - 模型配置文件
 - `config/model_config_loader.py` - 配置加载接口
 - `foundation/ai/models/model_handler.py` - 模型管理器
-- `foundation/ai/agent/generate/model_generate.py` - 模型调用客户端
+- `foundation/ai/agent/generate/model_generate.py` - 模型调用客户端(包含同步/异步版本)
+
+## API 快速参考
+
+| 方法 | 类型 | 使用场景 |
+|------|------|---------|
+| `await get_model_generate_invoke(...)` | 异步 | `async def` 函数中 |
+| `get_model_generate_invoke_sync(...)` | 同步 | 普通 `def` 函数中 |
+| `get_model_generate_stream(...)` | 同步生成器 | 流式输出场景 |

+ 0 - 15
core/base/words_detect/__init__.py

@@ -1,15 +0,0 @@
-"""
-文本块错别字/重复字审查模块
-
-提供基于提示词的一次性 LLM 调用能力。
-"""
-
-from .core.reviewer import WordsErrorReviewer
-from .core.prompt_builder import WordsPromptBuilder
-
-__version__ = "0.1.0"
-__all__ = [
-    "WordsErrorReviewer",
-    "WordsPromptBuilder",
-]
-

+ 0 - 39
core/base/words_detect/config/config.yaml

@@ -1,39 +0,0 @@
-# ============================================================
-# LLM 调用配置
-# ============================================================
-
-llm_api:
-  # API 类型:'openai' 使用 OpenAI 客户端库,'requests' 使用 requests 库(默认)
-  # 推荐使用 'openai' 以获得更好的兼容性和功能支持(如 ModelScope)
-  # api_type: "requests"  # 可选值: "openai" 或 "requests"(推荐使用 "openai" 以获得更好的兼容性)
-
-  # 标准 OpenAI 兼容 API 配置示例:
-  # api_type: "requests"
-  # base_url: "http://172.16.35.50:8000/v1"
-  # model: "Qwen3-4B"
-  # api_key: null
-
-  # ModelScope API 配置(当前使用):
-  # api_type: "openai"
-  # base_url: "https://api-inference.modelscope.cn/v1"
-  # api_key: "ms-8aa2f24b-2560-41c4-b3ba-f75de0249871"  # ModelScope Token
-  # model: "Qwen/Qwen3-8B"
-
-  # 路桥服务器
-  api_type: "requests"
-  base_url: "http://192.168.91.253:9002/v1"
-  model: "Qwen3-8B"
-  api_key: 'dummy'
-
-  timeout: 60  # 请求超时时间(秒)
-  stream: false  # 是否使用流式输出
-
-  # extra_body 参数(用于 ModelScope 等特殊功能)
-  # 对于非流式调用,enable_thinking 必须设置为 false
-  extra_body:
-    enable_thinking: false
-  # 如需启用 thinking 功能(仅流式调用时可用):
-  # extra_body:
-  #   enable_thinking: true
-  #   thinking_budget: 4096
-

+ 0 - 75
core/base/words_detect/core/prompt_builder.py

@@ -1,75 +0,0 @@
-"""
-错误识别提示词构建器
-用于将输入文本与模板组合为完整提示词。
-"""
-
-from pathlib import Path
-from typing import Optional
-import yaml
-
-
-class WordsPromptBuilder:
-    """根据错误识别模板构建提示词"""
-
-    def __init__(self, template_path: Optional[str] = None):
-        """
-        初始化提示词构建器
-
-        Args:
-            template_path: 模板文件路径,默认使用 words_detect/错误识别.yaml
-        """
-        if template_path is None:
-            template_path = Path(__file__).parent.parent / "错误识别.yaml"
-
-        with open(template_path, "r", encoding="utf-8") as f:
-            self.template = yaml.safe_load(f)
-
-    def build_prompt(self, content: str) -> str:
-        """
-        构建完整提示词
-
-        Args:
-            content: 待检查的文本块
-
-        Returns:
-            拼装后的提示词文本
-        """
-        content = content.strip()
-
-        # 1) 错误类别定义
-        prompt_parts = []
-        standards = self.template.get("classification_standards", {})
-        if standards:
-            prompt_parts.append("【错误类别定义】")
-            for name, desc in standards.items():
-                prompt_parts.append(f"{name}: {desc}")
-            prompt_parts.append("")
-
-        # 2) 待检查文本
-        file_toc_tpl = self.template.get("file_toc", "")
-        if "<content>" in file_toc_tpl:
-            file_toc = file_toc_tpl.replace("<content>", content)
-        else:
-            file_toc = f"{file_toc_tpl.strip()}\n{content}"
-        prompt_parts.append(file_toc.strip())
-        prompt_parts.append("")
-
-        # 3) 检查标准
-        review_standard = self.template.get("review_standard", "")
-        if review_standard:
-            prompt_parts.append(review_standard.strip())
-            prompt_parts.append("")
-
-        # 4) 检查要求
-        review_requirements = self.template.get("review_requirements", "")
-        if review_requirements:
-            prompt_parts.append(review_requirements.strip())
-            prompt_parts.append("")
-
-        # 5) 输出格式
-        output_format = self.template.get("output_format", "")
-        if output_format:
-            prompt_parts.append(output_format.strip())
-
-        return "\n".join(prompt_parts)
-

+ 0 - 98
core/base/words_detect/core/reviewer.py

@@ -1,98 +0,0 @@
-"""
-错误识别审查器
-直接使用提示词与文本块调用一次 LLM API。
-"""
-
-import ast
-import json
-import re
-from pathlib import Path
-from typing import Any, Dict, Optional
-
-from detect.core.llm_client import LLMClient
-from .prompt_builder import WordsPromptBuilder
-
-
-class WordsErrorReviewer:
-    """错别字/重复字审查器(单次 LLM 调用)"""
-
-    def __init__(
-        self,
-        template_path: Optional[str] = None,
-        config_path: Optional[str] = None,
-    ):
-        """
-        初始化审查器
-
-        Args:
-            template_path: 提示词模板路径,默认使用 words_detect/错误识别.yaml
-            config_path: LLM 配置路径,默认使用 words_detect/config/config.yaml
-        """
-        if template_path is None:
-            template_path = Path(__file__).parent.parent / "错误识别.yaml"
-        if config_path is None:
-            config_path = Path(__file__).parent.parent / "config" / "config.yaml"
-
-        self.prompt_builder = WordsPromptBuilder(template_path)
-        self.llm_client = LLMClient(config_path)
-
-    def review(self, content: str) -> Dict[str, Any]:
-        """
-        执行文本错误审查(单次 API 调用)
-
-        Args:
-            content: 待检查文本块
-
-        Returns:
-            {
-              "prompt": 构建的提示词,
-              "result": LLM 原始返回文本,
-              "parsed_result": 解析得到的 JSON(若解析失败则为 None)
-            }
-        """
-        prompt = self.prompt_builder.build_prompt(content)
-        result_text = self.llm_client.review(prompt)
-        parsed = self._extract_json_from_text(result_text)
-
-        return {
-            "prompt": prompt,
-            "result": result_text,
-            "parsed_result": parsed,
-        }
-
-    def _extract_json_from_text(self, text: str) -> Optional[Any]:
-        """
-        从 LLM 返回文本中提取 JSON 对象
-        """
-        if not text:
-            return None
-
-        candidates = []
-
-        # 1) 直接尝试整体解析
-        stripped = text.strip()
-        candidates.append(stripped)
-
-        # 2) ```json ... ``` 代码块
-        fence_pattern = r"```(?:json)?\s*(\{[\s\S]*?\})\s*```"
-        match = re.search(fence_pattern, text, re.DOTALL)
-        if match:
-            candidates.append(match.group(1))
-
-        # 3) 最外层花括号或方括号截取(支持列表/对象)
-        container_pattern = r"(\{[\s\S]*\}|\[[\s\S]*\])"
-        match = re.search(container_pattern, text)
-        if match:
-            candidates.append(match.group(1))
-
-        for candidate in candidates:
-            try:
-                return json.loads(candidate)
-            except json.JSONDecodeError:
-                # 兼容 Python 字面量格式 (列表/元组)
-                try:
-                    return ast.literal_eval(candidate)
-                except Exception:
-                    continue
-        return None
-

+ 0 - 65
core/base/words_detect/examples/example.py

@@ -1,65 +0,0 @@
-"""
-使用示例:错别字/重复字审查
-"""
-
-import json
-from words_detect import WordsErrorReviewer
-
-
-example_text = """
-2、项目主要管理人员环保职责
-  表10 项目主要管理人员环水保职责一览表
-
-  序号
-  职务
-  主要环保职责
-  备注
-  1
-  项目经理/项目书记
-  项目环保工作的第一责任人,对项目环保工作负总责。
-  3
-  总工程师
-  对项目环保科技创新和技术管理工作负直接领导责任;编制施工组织设计、专项方案时应包含环保技术内容,对方案环保措施进行优化。
-  4
-  副经理
-  对分管工区环保工作负直接领导责任,阻值分管工区开展环保教育培训,解决分管范围内存在的有关环保问题,制定预防和整改措施。
-  5
-  安全总监
-  对项目环保工作负监督管理责任;统筹邪教、监督管理项目环保工作,建立健全项目环保管理制度和体系;组织开展项目环保管理监督、检查工作。
-  6
-  工程管理部处长长
-  组织生产活动同时应统筹考虑防治污染的设施,防治污染的设施应与主体工程同时设计、同时施工、同时投入使用;编制专项施工方案时,应系统考虑污染防治措施,努力实现废水、废渣、废气、噪声等依法合规排放。
-  7
-  安全环保处长
-  认真贯彻执行国家环保方针政策、法律法规,参与拟定环保监督管理规章制度;监督、检查项目各部门、工区依法合规开展环保工作,提出整改意见并监督落实。
-  8
-  物资设备处长
-  负责项目生态环境保护管理工作,贯彻执行国家环保方针政策、法律法规,项目决策部署和工作安排;制定、监督实施项目环保监督管理规章制度、标准、工作计划;负责项目环保监督数据的统计分析和报送工作。
-  3、环水保风险控制要点
-  四川公路桥梁建设集团有限公司镇广C4 项目经理部
-  JQ220t-40m 架桥机安装及拆除专项施工方案第63 页
-"""
-
-
-def main():
-    reviewer = WordsErrorReviewer()
-    result = reviewer.review(example_text)
-
-    print("=" * 60)
-    print("审查提示词:")
-    print("=" * 60)
-    print(result["prompt"])
-
-    print("\n" + "=" * 60)
-    print("模型原始返回:")
-    print("=" * 60)
-    print(result["result"])
-
-    if result["parsed_result"] is not None:
-        print("\n解析后的 JSON:")
-        print(json.dumps(result["parsed_result"], ensure_ascii=False, indent=2))
-
-
-if __name__ == "__main__":
-    main()
-

+ 0 - 63
core/base/words_detect/错误识别.yaml

@@ -1,63 +0,0 @@
-# ============================================================
-# 错误识别提示词模板
-# ============================================================
-
-# 分类标准映射
-# 说明:定义各错误类别的判定标准
-classification_standards:
-  错别字: "实体、动词、名词等概念字词的使用不符合句子语境的错误。"
-  重复字词: "组词时多字(如“工工程简介”多 1 个“工”字),或某词语在一句话中无语义、语法依据重复出现的错误。"
-
-# 组件1: 待检查文本
-# 说明:需要进行错误识别的完整文本内容(运行时将 <content> 替换为实际文本块)
-file_toc: |-
-  【待检查文本】
-  <content>
-
-# 组件2: 检查标准
-# 说明:两类错误的具体识别要求
-review_standard: |-
-  【检查标准】
-  1. 错别字:识别语义、语境、搭配错误导致的错词、错字。
-  2. 重复字词:识别多余的字、重复出现且无语义依据的字、词或词组。
-
-# 组件3: 检查要求
-# 说明:明确如何执行检查和判定
-review_requirements: |-
-  【检查任务要求】
-  
-  ## 检查原则
-  1. 精准识别:只标注确凿的错别字或重复字词,避免过度推测。
-  2. 分类清晰:两类错误分别归入对应字段,不得混淆。
-  3. 去重原则:同一错误实体的同一错误仅标注 1 次。
-  4. 无误返回:若未发现错误,返回空 JSON 对象。
-  
-  ## 检查细致程度
-  - 逐句检查:逐句审查语义与用词是否匹配。
-  - 语境判断:结合上下文判断是否存在错词或多余字词。
-  - 重复确认:确认重复字词是否确为冗余,而非强调或并列。
-  
-  ## 判定标准
-  - 错别字:字词与语境不符或使用错误。
-  - 重复字词:多余字、词或词组导致语句冗余。
-  
-  ## 特别注意
-  - 忽略转义字符带来的问题。
-  - 若无任何错误,返回空 JSON 对象 {}。
-
-# 组件4: 输出格式
-# 说明:规范检查结果的返回格式
-output_format: |-
-  【输出格式规范】
-  
-  ## 格式要求
-  返回列表,每个元素为四元组:
-  [
-    ("错误实体A", "错误类型A", "错误问题A", "正确示例A"),
-    ("错误实体B", "错误类型B", "错误问题B", "正确示例B"),
-  ]
-  
-  ## 注意事项
-  - 错误类型仅可为“错别字”或“重复字词”。
-  - 若无错误,返回空列表 []。
-  /no_think

+ 296 - 0
core/construction_review/component/doc_worker/classification/hierarchy_classifier.py

@@ -22,6 +22,242 @@ from ..config.provider import default_config_provider
 from ..utils.prompt_loader import PromptLoader
 
 
+
+# 如果某二级标题在CSV中不存在,但在此白名单中,则不作为非标准项告警
+STANDARD_SECONDARY_TITLES: Dict[str, List[str]] = {
+    "basis": ["法律法规", "标准规范", "文件制度", "编制原则", "编制范围"],
+    "overview": ["设计概况", "工程地质与水文气象", "周边环境", "施工平面及立面布置", "施工要求和技术保证条件", "风险辨识与分级", "参建各方责任主体单位"],
+    "plan": ["施工进度计划", "施工材料计划", "施工设备计划", "劳动力计划", "安全生产费用使用计划"],
+    "technology": ["主要施工方法概述", "技术参数", "工艺流程", "施工准备", "施工方法及操作要求", "检查要求"],
+    "safety": ["安全保证体系", "组织保证措施", "技术保证措施", "监测监控措施", "应急处置措施"],
+    "quality": ["质量保证体系", "质量目标", "工程创优规划", "质量控制程序与具体措施"],
+    "environment": ["环境保证体系", "环境保护组织机构", "环境保护及文明施工措施"],
+    "personnel": ["施工管理人员", "专职安全生产管理人员", "其他作业人员"],
+    "acceptance": ["验收标准", "验收程序", "验收内容", "验收时间", "验收人员"],
+    "other": ["计算书", "相关施工图纸", "附图附表", "编制及审核人员情况"],  # 兼容单数形式
+}
+
+
+def _normalize_heading_for_extract(text: str) -> str:
+    """归一化标题:统一标点符号,去除空格"""
+    if not text:
+        return ""
+    normalized = text.strip()
+    normalized = normalized.replace("【", "[").replace("】", "]")
+    normalized = normalized.replace("(", "(").replace(")", ")")
+    normalized = normalized.replace(".", ".").replace("。", ".")
+    normalized = normalized.replace("[", "[").replace("]", "]")
+    normalized = re.sub(r"\s+", "", normalized)
+    return normalized
+
+
+async def extract_title_core(title: str) -> str:
+    """
+    使用规则库提取标题核心内容(去除序号)。
+    整合了 pdf_extractor.py 中的有效规则。
+
+    Args:
+        title: 原始标题(如 "1.1 项目总体概况"、"一、编制依据")
+
+    Returns:
+        str: 标题核心内容
+    """
+    if not title:
+        return ""
+
+    # 先归一化(统一标点)
+    normalized = _normalize_heading_for_extract(title)
+
+    # 规则1: 多级数字格式 1.1、1.1.1(来自pdf_extractor _clean_section_title)
+    multi_num_match = re.match(r"^(\d+\.\d+)(?!\.\d)\.?(.*)$", normalized)
+    if multi_num_match:
+        return multi_num_match.group(2).strip() or title
+
+    # 规则2: 数字列表 1、1. 1)(1)(来自pdf_extractor _clean_section_title)
+    single_num_match = re.match(r"^(\d{1,2})(?:[、\.\)\]\]])\.?(.*)$", normalized)
+    if single_num_match:
+        return single_num_match.group(2).strip() or title
+
+    # 规则3: 中文数字 一、一.(一)(一)(来自RULE_LIB Rule_4/5)
+    cn_num_match = re.match(r"^([一二三四五六七八九十百零两]+)[、\.\s\)\]\]]+(.*)$", normalized)
+    if cn_num_match:
+        return cn_num_match.group(2).strip() or title
+
+    # 规则4: 第X节格式(来自pdf_extractor _clean_section_title)
+    section_match = re.match(r"^(第[一二三四五六七八九十\d]+节)[\s、::\.\-]*(.*)$", normalized)
+    if section_match:
+        return section_match.group(2).strip() or title
+
+    # 规则5: 【1】粗体括号(来自RULE_LIB Rule_7)
+    bracket_match = re.match(r"^[\[【](\d+)[\]】](.*)$", normalized)
+    if bracket_match:
+        return bracket_match.group(2).strip() or title
+
+    # 规则6: 纯数字开头(兜底)
+    pure_num_match = re.match(r"^\d{1,2}(?:[\..。、])?(.*)$", normalized)
+    if pure_num_match:
+        return pure_num_match.group(1).strip() or title
+
+    # 无法匹配,使用模型兜底
+    return await _extract_title_core_with_model(title)
+
+
+async def _extract_title_core_with_model(title: str) -> str:
+    """
+    使用模型去除标题序号(兜底方案)。
+
+    Args:
+        title: 原始标题
+
+    Returns:
+        str: 去除序号后的标题核心内容
+    """
+    if not title:
+        return ""
+
+    system_prompt = """你是一个文档标题处理助手。
+
+【任务】
+去除标题中的序号前缀,只保留核心内容。
+
+【示例】
+输入: "1.1 项目总体概况" -> 输出: "项目总体概况"
+输入: "7.1 验收标准" -> 输出: "验收标准"
+输入: "一、编制依据" -> 输出: "编制依据"
+输入: "(1)计算书" -> 输出: "计算书"
+输入: "【1】相关施工图纸" -> 输出: "相关施工图纸"
+输入: "5.3 夏季施工措施" -> 输出: "夏季施工措施"
+
+【要求】
+1. 只返回去除序号后的核心标题,不要任何解释
+2. 如果无法判断,直接返回原始标题
+3. 不要添加引号或其他标点"""
+
+    try:
+        response = await generate_model_client.get_model_generate_invoke(
+            trace_id="title_extract",
+            system_prompt=system_prompt,
+            user_prompt=f"请去除以下标题的序号前缀,只返回核心内容:{title}",
+            function_name="doc_classification_secondary",
+        )
+        core = response.strip().strip('"').strip("'").strip()
+        logger.debug(f"[模型提取标题] '{title}' -> '{core}'")
+        return core if core else title
+    except Exception as e:
+        logger.warning(f"[模型提取标题] 失败: {e}, 返回原始标题")
+        return title.strip()
+
+
+async def is_secondary_in_whitelist_async(first_code: str, second_name: str) -> bool:
+    """
+    检查二级标题是否在标准目录白名单中。
+
+    Args:
+        first_code: 一级分类代码
+        second_name: 二级分类名称(可能包含"一、"、"1."、"1.1"等前缀)
+
+    Returns:
+        bool: 如果在白名单中返回 True
+    """
+    if not first_code or not second_name:
+        return False
+
+    whitelist = STANDARD_SECONDARY_TITLES.get(first_code, [])
+    if not whitelist:
+        return False
+
+    # 使用异步标题提取(支持正则+模型兜底)
+    core_title = await extract_title_core(second_name)
+
+    # 与白名单进行模糊匹配
+    for item in whitelist:
+        item_clean = item.strip()
+        # 完全匹配或包含关系
+        if core_title == item_clean:
+            return True
+        if item_clean in core_title or core_title in item_clean:
+            return True
+
+    return False
+
+
+# 同步版本(仅使用正则,不使用模型兜底,用于同步上下文)
+def is_secondary_in_whitelist(first_code: str, second_name: str) -> bool:
+    """
+    同步版本:检查二级标题是否在标准目录白名单中。
+    仅使用正则提取,不使用模型兜底(避免在同步上下文中调用异步模型)。
+
+    Args:
+        first_code: 一级分类代码
+        second_name: 二级分类名称
+
+    Returns:
+        bool: 如果在白名单中返回 True
+    """
+    if not first_code or not second_name:
+        return False
+
+    whitelist = STANDARD_SECONDARY_TITLES.get(first_code, [])
+    if not whitelist:
+        return False
+
+    # 使用同步标题提取(仅用正则)
+    core_title = _extract_title_core_sync(second_name)
+
+    # 与白名单进行模糊匹配
+    for item in whitelist:
+        item_clean = item.strip()
+        if core_title == item_clean:
+            return True
+        if item_clean in core_title or core_title in item_clean:
+            return True
+
+    return False
+
+
+def _extract_title_core_sync(title: str) -> str:
+    """
+    同步版本:使用正则提取标题核心内容(不使用模型兜底)。
+    """
+    if not title:
+        return ""
+
+    normalized = _normalize_heading_for_extract(title)
+
+    # 规则1: 多级数字格式 1.1
+    multi_num_match = re.match(r"^(\d+\.\d+)(?!\.\d)\.?(.*)$", normalized)
+    if multi_num_match:
+        return multi_num_match.group(2).strip() or title
+
+    # 规则2: 数字列表 1、1.
+    single_num_match = re.match(r"^(\d{1,2})(?:[、\.\)\]\]])\.?(.*)$", normalized)
+    if single_num_match:
+        return single_num_match.group(2).strip() or title
+
+    # 规则3: 中文数字 一、一.
+    cn_num_match = re.match(r"^([一二三四五六七八九十百零两]+)[、\.\s\)\]\]]+(.*)$", normalized)
+    if cn_num_match:
+        return cn_num_match.group(2).strip() or title
+
+    # 规则4: 第X节格式
+    section_match = re.match(r"^(第[一二三四五六七八九十\d]+节)[\s、::\.\-]*(.*)$", normalized)
+    if section_match:
+        return section_match.group(2).strip() or title
+
+    # 规则5: 【1】粗体括号
+    bracket_match = re.match(r"^[\[【](\d+)[\]】](.*)$", normalized)
+    if bracket_match:
+        return bracket_match.group(2).strip() or title
+
+    # 规则6: 纯数字开头
+    pure_num_match = re.match(r"^\d{1,2}(?:[\..。、])?(.*)$", normalized)
+    if pure_num_match:
+        return pure_num_match.group(1).strip() or title
+
+    # 无法匹配,返回原始标题
+    return title.strip()
+
+
 def _extract_json(text: str) -> Optional[Dict[str, Any]]:
     """从字符串中提取第一个有效 JSON 对象"""
     if not text or not text.strip():
@@ -634,6 +870,66 @@ class HierarchyClassifier(IHierarchyClassifier):
 
         return final_result
 
+    async def get_classification_alerts(
+        self,
+        primary_result: Optional[Dict[str, Any]] = None,
+        secondary_result: Optional[Dict[str, Any]] = None
+    ) -> Dict[str, Any]:
+        """
+        获取分类告警信息(异步版本)。
+
+        识别被分到非标准项(non_standard)的一二级分类,
+        排除因解析异常或调用失败导致的非标准项。
+        """
+        l1_alerts = []
+        l2_alerts = []
+
+        # 检查一级分类非标准项
+        if primary_result and "items" in primary_result:
+            for item in primary_result.get("items", []):
+                category_code = item.get("category_code", "")
+                title = item.get("title", "")
+
+                if category_code == "non_standard":
+                    l1_alerts.append({
+                        "title": title,
+                        "alert": "该一级章节标题格式不标准,请检查文档标题格式是否符合规范"
+                    })
+
+        # 检查二级分类非标准项
+        if secondary_result and "items" in secondary_result:
+            for first_item in secondary_result.get("items", []):
+                first_category = first_item.get("first_category", "")
+                first_category_code = first_item.get("first_category_code", "")
+                original_title = first_item.get("original_title", "")
+                classifications = first_item.get("classifications", [])
+
+                for cls in classifications:
+                    category_code = cls.get("category_code", "")
+                    title = cls.get("title", "")
+                    error = cls.get("error", "")
+
+                    if category_code == "non_standard" and not error:
+                        # 白名单检查(异步版本,支持模型兜底)
+                        is_whitelist = await is_secondary_in_whitelist_async(first_category_code, title)
+                        if is_whitelist:
+                            logger.debug(f"[白名单过滤] 二级标题 '{title}' (code={first_category_code}) 在标准目录白名单中,跳过告警")
+                            continue
+
+                        chapter_name = original_title or first_category
+                        location = f"{chapter_name}>{title}" if chapter_name else title
+                        l2_alerts.append({
+                            "title": location,
+                            "alert": "该二级小节标题格式不标准,请检查文档标题格式是否符合规范"
+                        })
+
+        return {
+            "l1_system_alerts_lists": l1_alerts,
+            "l2_system_alerts_lists": l2_alerts,
+            "l1_exist_issue": len(l1_alerts) > 0,
+            "l2_exist_issue": len(l2_alerts) > 0
+        }
+
     def classify_secondary(
         self,
         primary_result: Optional[Dict[str, Any]] = None

+ 14 - 14
core/construction_review/component/doc_worker/config/StandardCategoryTable.csv

@@ -38,9 +38,9 @@ first_seq,first_code,first_name,second_seq,second_code,second_name,second_focus,
 4. 【金额防伪校验(彩蛋任务)】:请在提取数据的同时,简单核对各单项金额相加的总和,与文本填写的“合计总额”在数量级上是否发生严重背离(如少写/多写一个零)。",安全费用类别;安全生产费用;安全投入;安全经费;财资〔2022〕136号,
 3,plan,施工计划,5,SafetyCost,安全生产费用使用计划,名称类、金额类、货币数值类、货币单位类、不能将项目总的安全生产费用列入,2,SecurityFeeName,安全费用名称,安全费用名称具体(如“施工现场临时用电系统改造”“应急救援器材采购”)、避免模糊表述;,安全费用名称;安全防护费;应急救援费;临时用电改造;安全器材采购,
 3,plan,施工计划,5,SafetyCost,安全生产费用使用计划,名称类、金额类、货币数值类、货币单位类、不能将项目总的安全生产费用列入,3,SingleInvestmentAmount,单项投入金额,单项投入金额即每项费用的具体数值(如“临时防护栏杆采购:5万元”)、确保费用可量化;,单项金额;费用金额;万元;单项投入;单项安全费,
-3,plan,施工计划,5,SafetyCost,安全生产费用使用计划,名称类、金额类、货币数值类、货币单位类、不能将项目总的安全生产费用列入,4,TotalSafetyProductionExpenses,安全生产费用总额,根据工程规模、风险等级计算、确保足额投入,安全费用总额;总金额;安全投入合计;安全费总计,
-4,technology,施工工艺技术,1,MethodsOverview,主要施工方法概述,工艺名称类、施工专业词汇类、规格类、数值类、数值单位类,1,ConstructionTechnologySelection,施工工艺选择,主要施工方法概述应简要说明采取的主要施工工艺和施工方法,或为见附表,见附录,见详情类似表述视为符合,工艺选择;施工工艺;核心工艺;施工方法选择;工法,"当内容仅含""详见附表""、""详见另册""""见附表""""见另册""、""专详见另册""等通用索引说明时,视为本分类已有依据。"
-4,technology,施工工艺技术,1,MethodsOverview,主要施工方法概述,工艺名称类、施工专业词汇类、规格类、数值类、数值单位类,2,MainConstructionMethods,主要施工方法,"主要施工方法概述应简要说明采取的主要施工工艺和施工方法,或为见附表,见附录,见详情类似表述视为符合",材料类型;钢筋;混凝土;防水材料;钢材;主要材料;材料规格;型号;HRB400;C30;规格型号;材料尺寸,"当内容仅含""详见附表""、""详见另册""""见附表""""见另册""、""专详见另册""等通用索引说明时,视为本分类已有依据。"
+3,plan,施工计划,5,SafetyCost,安全生产费用使用计划,名称类、金额类、货币数值类、货币单位类、不能将项目总的安全生产费用列入,4,TotalSafetyProductionExpenses,安全生产费用总额,根据工程规模、风险等级计算、确保足额投入,或含安全生产费用计划表 视为符合,"安全生产费用总额,安全费用总额;总金额;安全投入合计;安全费总计",
+4,technology,施工工艺技术,1,MethodsOverview,主要施工方法概述,工艺名称类、施工专业词汇类、规格类、数值类、数值单位类,1,ConstructionTechnologySelection,施工工艺选择,主要施工方法概述应简要说明采取的主要施工工艺和施工方法,或为见附表,见附录,见详情类似表述视为符合,工艺选择;施工工艺;核心工艺;施工方法选择;工法,当内容仅含"详见附表"、"详见另册"、"见附表"、"见另册"、"专详见另册"等通用索引说明时,视为本分类已有依据。
+4,technology,施工工艺技术,1,MethodsOverview,主要施工方法概述,工艺名称类、施工专业词汇类、规格类、数值类、数值单位类,2,MainConstructionMethods,主要施工方法,"主要施工方法概述应简要说明采取的主要施工工艺和施工方法,或为见附表,见附录,见详情类似表述视为符合",材料类型;钢筋;混凝土;防水材料;钢材;主要材料;材料规格;型号;HRB400;C30;规格型号;材料尺寸,当内容仅含"详见附表"、"详见另册"、"见附表"、"见另册"、"专详见另册"等通用索引说明时,视为本分类已有依据。
 4,technology,施工工艺技术,2,TechParams,技术参数,工程材料类名词、规格类、数值类、数值单位类、时间日期类、重量单位类,1,MaterialType,"材料类型规格,主要设备名称",本参数主要包含使用材料的类型、规格或本参数主要设备的名称、型号、出厂时间、性能参数、自重等。,材料类型;钢筋;混凝土;防水材料;钢材;主要材料,
 4,technology,施工工艺技术,3,PrepWork,施工准备,名称类、数值类、规格类、数值单位类、岗位名称类、时间日期类、工程设备类,1,MeasurementAndStakeout,测量放样,需明确测量的基准点、控制网设置、是施工定位的关键;,测量放样;控制网;轴线;基准点;放线;测量基准;坐标,
 4,technology,施工工艺技术,3,PrepWork,施工准备,名称类、数值类、规格类、数值单位类、岗位名称类、时间日期类、工程设备类,2,TemporaryWaterAndElectricityConsumption,临时水电用量,需计算施工期间的用水、用电量、,临时用水量;临时用电量;用水量;用电量;水电用量,
@@ -54,11 +54,11 @@ first_seq,first_code,first_name,second_seq,second_code,second_name,second_focus,
 五号。",施工工序;主要工序;工序流程;施工顺序;工艺步骤,
 4,technology,施工工艺技术,5,Operations,施工方法及操作要求,施工流程名称类、数值类、数值单位类,1,ConstructionProcessOperations,施工工序描述操作,需详细描述各工序的操作步骤、是操作指导的核心;,操作步骤;操作流程;施工步骤;操作方法;操作要求,
 4,technology,施工工艺技术,5,Operations,施工方法及操作要求,施工流程名称类、数值类、数值单位类,2,ConstructionPoints,施工要点,需明确工序的关键要求、是质量控制的关键;,施工要点;关键要求;质量关键;工艺要点;控制要点,
-4,technology,施工工艺技术,5,Operations,施工方法及操作要求,施工流程名称类、数值类、数值单位类,3,FAQPrevention,常见问题及预防,"需列出工序的常见问题及预防措施、是风险防控的重点 ",常见问题;质量通病;预防措施;防治措施;常见缺陷;预防对策,
+4,technology,施工工艺技术,5,Operations,施工方法及操作要求,施工流程名称类、数值类、数值单位类,3,FAQPrevention,常见问题及预防,需列出工序的常见问题及预防措施、是风险防控的重点 ,常见问题;质量通病;预防措施;防治措施;常见缺陷;预防对策,
 4,technology,施工工艺技术,5,Operations,施工方法及操作要求,施工流程名称类、数值类、数值单位类,4,ProblemSolvingMeasures,问题处理措施,需明确问题的解决方法、是问题解决的指南;,问题处理;处理措施;整改措施;修复方法;缺陷处理,
 4,technology,施工工艺技术,6,Inspection,检查要求,工序检查内容、工序检查标准,1,ProcessInspectionContent,工序检查内容,【施工工序检查维度】:文本必须涵盖该方案主要施工步骤(如安装、浇筑、张拉等)的过程检查内容。,工序检查;检查内容;检查项目;工序检验;检查清单,
 4,technology,施工工艺技术,6,Inspection,检查要求,工序检查内容、工序检查标准,2,ProcessInspectionStandards,工序检查标准,【量化标准有效性(红线)】:针对上述检查内容,文本必须提供具体的“检查标准”。特征表现为明确的量化允许偏差(如±Xmm)、强度指标(如100%)、或明确引用的国家/行业验收规范条款编号。,检查标准;验收标准;允许偏差;检查合格;偏差限值,
-5,safety,安全保证措施,1,SafetySystem,安全保证体系,流程体系类名词、标准文书类、标标准编号编码数字类,1,SafetyProductionAssuranceSystemFrameworkDiagram,安全生产保证体系框图,含安全生产保证体系框图关键字,及类似表述,视为符合,类似出现这种;见附表,详情,详见附表,专详见另册及类似描述视为符合! 这种表述视为符合,安全保证体系;安全体系框图;安全管理体系框图;安全组织体系,"当内容仅含""详见附表""、""详见另册""""见附表""""见另册""、""专详见另册""等通用索引说明时,视为本分类已有依据。"
+5,safety,安全保证措施,1,SafetySystem,安全保证体系,流程体系类名词、标准文书类、标标准编号编码数字类,1,SafetyProductionAssuranceSystemFrameworkDiagram,安全生产保证体系框图,含安全生产保证体系框图关键字,及类似表述,视为符合,类似出现这种;见附表,详情,详见附表,专详见另册及类似描述视为符合! 这种表述视为符合,安全保证体系;安全体系框图;安全管理体系框图;安全组织体系,当内容仅含"详见附表"、"详见另册"、"见附表"、"见另册"、"专详见另册"等通用索引说明时,视为本分类已有依据。
 5,safety,安全保证措施,2,Organization,组织保证措施,名词类、人名类、岗位名称类、制度名词类,1,SafetymanagementOrganization,安全管理组织机构,基于项目经理为组长的安全工作领导小组、关注岗位组织架构名称类、部门名称类、关系结构类名词;,安全管理组织;安全领导小组;安全管理机构;安全管理组织机构,
 5,safety,安全保证措施,2,Organization,组织保证措施,名词类、人名类、岗位名称类、制度名词类,2,PersonnelSafetyResponsibilities,人员安全职责,关注岗位名称类、人名类、责任制度名词类、岗位职责名词类、安全制度名词类;,安全职责;人员安全责任;岗位安全;安全责任制,
 5,safety,安全保证措施,3,TechMeasures,技术保证措施,施工专业名词类、工序名称类 、施工设备名称类、施工材料名称类、施工场地名称类、岗位名称类,1,OverallSecurityMeasures,总体安全措施,"总体安全措施按主要工序的安全保证措施进行梳理和说明,
@@ -68,8 +68,8 @@ first_seq,first_code,first_name,second_seq,second_code,second_name,second_focus,
 5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,1,MonitoringOrganization,监测组织机构,监测监控的责任主体、需明确监测人员的资质(如注册安全工程师、监测技术员)及职责(如数据采集、分析、报告)、确保监测工作的专业性;,监测机构;监测人员;监测责任;监测组织机构;监测负责人,
 5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,2,MonitoringRange,监测范围,需覆盖施工区域内的所有风险点(如深基坑周边、高支模体系、临时用电线路)、避免遗漏;,监测范围;监测区域;监测覆盖范围;监测对象,
 5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,3,MonitoringItems,监测项目,需明确监测的具体内容(如深基坑的水平位移、高支模的立杆轴力、临时用电的电压电流)、是监测的核心;,监测项目;监测内容;水平位移;沉降监测;监测指标,
-5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,4,MonitoringPointSettings,监测点设置,需根据风险点的分布确定(如深基坑每10米设置一个位移监测点)、需符合《建筑基坑支护技术规程》(JGJ 120-2012)等行业标准;,监测点;测点布置;监测布点;测点位置,
-5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,5,MonitoringInstrumentsAndEquipment,监测仪器设备,需明确仪器的名称(如全站仪、测斜仪、应力传感器)、型号(如徕卡TS60全站仪)及精度(如0.5秒级)、确保数据的准确性;,监测仪器;全站仪;测斜仪;应力传感器;监测设备;仪器型号,
+5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,4,MonitoringPointSettings,监测点设置,需根据风险点的分布确定(如深基坑每10米设置一个位移监测点);,监测点;测点布置;监测布点;测点位置,
+5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,5,MonitoringInstrumentsAndEquipment,监测仪器设备,需明确仪器的名称(如全站仪、测斜仪、应力传感器)、型号及精度(如0.5秒级)、确保数据的准确性;,监测仪器;全站仪;测斜仪;应力传感器;监测设备;仪器型号,
 5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,6,MonitoringMethods,监测方法,需明确数据采集的方式(如人工读数、自动采集)、及数据处理方法(如统计分析、趋势预测)、是监测的关键环节;,监测方法;数据采集;人工读数;自动采集;数据处理,
 5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,7,MonitoringFrequency,监测频率,需明确监测频率、(如深基坑每天一次、高支模每周两次)。,监测频率;监测周期;每天一次;每周两次;监测时间间隔,
 5,safety,安全保证措施,4,Monitoring,监测监控措施,组织机构名称类、施工区域名称类、监测专业名词类、施工场地名称类、设备名称类、数值类、数值单位类、岗位名称类,8,WarningValuesAndControlValues,预警值及控制值,需根据设计文件及行业标准确定(如深基坑水平位移预警值为30mm、控制值为50mm)、是判断风险的重要依据;,预警值;控制值;报警值;监测阈值;预警指标;预警控制,
@@ -80,7 +80,7 @@ first_seq,first_code,first_name,second_seq,second_code,second_name,second_focus,
 5,safety,安全保证措施,5,Emergency,应急处置措施,事故名称类、救援器材类、机构名称类、数字类、数值单位类。,4,TrafficmanagementAndMedicalRescue,交通疏导与医疗救援,应以表格的形式明确施工工点附近的医疗救援机构名称、联系电话、距离等、并附应急救援线路图;,医疗救援;交通疏导;救援线路;医院联系;急救电话;应急救援路线,
 5,safety,安全保证措施,5,Emergency,应急处置措施,事故名称类、救援器材类、机构名称类、数字类、数值单位类。,5,PostDisposal,后期处置,包括善后处理、调查与评估、恢复生产等三个方面、事故后的恢复工作、需明确善后处理(如伤亡人员家属安抚、财产损失统计)、事故调查(如原因分析、责任认定评估)及整改措施(如完善安全制度、加强培训)、避免事故重复发生;,后期处置;善后处理;事故调查;恢复生产;善后工作;事故评估,
 6,quality,质量保证措施,1,QualitySystem,质量保证体系,组织机构名称类、岗位名称类、岗位职责词汇类。,1,QualitymanagementOrganization,人员职责,基于项目经理为组长的工作领导小组、小组中包括项目经理、项目总工、质量总监、工程部门、质检部门、专业分包单位(协作队伍)项目负责人和项目技术负责人等、需明确层级(如公司级、项目级、班组级)及组成部门(如质量部、工程部、技术部)、形成“横向到边、纵向到底”的管理网络;,质量管理组织;质量领导小组;质检人员;质量总监;质量体系组织,
-6,quality,质量保证措施,1,QualitySystem,质量保证体系,组织机构名称类、岗位名称类、岗位职责词汇类。,2,PersonnelResponsibilities,质量保证体系框图,含质量保证体系框图关键字,及类似表述,视为符合,类似出现这种;见附表,详情,详见附表,专详见另册及类似描述视为符合! 这种表述视为符合,质量职责;质量责任制;岗位质量责任;质量保证体系框图,"当内容仅含""详见附表""、""详见另册""""见附表""""见另册""、""专详见另册""等通用索引说明时,视为本分类已有依据。"
+6,quality,质量保证措施,1,QualitySystem,质量保证体系,组织机构名称类、岗位名称类、岗位职责词汇类。,2,PersonnelResponsibilities,质量保证体系框图,含质量保证体系框图关键字,及类似表述,视为符合,类似出现这种;见附表,详情,详见附表,专详见另册及类似描述视为符合! 这种表述视为符合,质量职责;质量责任制;岗位质量责任;质量保证体系框图,当内容仅含"详见附表"、"详见另册"、"见附表"、"见另册"、"专详见另册"等通用索引说明时,视为本分类已有依据。
 6,quality,质量保证措施,2,QualityGoals,质量目标,目标标准词汇类、合同条款类、具体工程名称类、量化数值类、数值单位类。,1,DecompositionOfQualityObjectives,质量目标分解,根据施工合同和业主要求填写、需将总目标拆解为分部(基础、主体、装饰)、分项工程的具体目标(如“主体结构混凝土强度合格率100%”)、是目标落地的关键;,质量目标分解;分项质量;质量指标;质量目标分项,
 6,quality,质量保证措施,3,Excellence,工程创优规划,工程创优总体计划、技术准备(BIM/新技术应用)、过程控制(关键工序精品打造)、细部处理(节点优化)、精品工程创建、新技术推广(四新技术)、申报资料编制、工程资料归档、创优考核机制,1,EngineeringDataArchiving,工程创优规划要求,"1、广泛开展QC小组及创优质样板工程活动,通过广泛的论证、分析和研究,确保工程质量得到有效控制。 
 2、在施工前,组织有关人员认真学习新技术、新工艺、新材料、新设备、新测试方法的技术要点,并认真进行技术交底,确保在施工中正确应用,提高工程质量。
@@ -88,7 +88,7 @@ first_seq,first_code,first_name,second_seq,second_code,second_name,second_focus,
 6,quality,质量保证措施,4,QualityControl,质量控制程序与具体措施,原材料检查验收(三证一检)、实体工程质量验收(分项/分部工程验收)、质量通病防治(墙面空鼓/屋面渗漏)、季节性施工质量控制(冬期混凝土保温/雨期防水)、工序质量控制点、质量检查程序(自检/互检/专检)、质量问题整改(闭环管理),1,PhysicalProjectQualityAcceptance,实体工程质量验收,需按分项(如“钢筋绑扎”)、分部工程(如“基础工程”)进行验收、符合规范要求;,实体验收;分项验收;分部验收;实体工程验收;工程质量验收,
 6,quality,质量保证措施,4,QualityControl,质量控制程序与具体措施,原材料检查验收(三证一检)、实体工程质量验收(分项/分部工程验收)、质量通病防治(墙面空鼓/屋面渗漏)、季节性施工质量控制(冬期混凝土保温/雨期防水)、工序质量控制点、质量检查程序(自检/互检/专检)、质量问题整改(闭环管理),2,PreventionAndControlOfCommonQualityDefectsInProcesses,工序质量通病防治,需针对常见问题(如“墙面空鼓”“屋面渗漏”)制定专项措施(如“抹灰前基层凿毛”“防水附加层施工”)、减少质量缺陷;,质量通病;空鼓;渗漏;裂缝;蜂窝麻面;防治措施;通病防治,
 6,quality,质量保证措施,4,QualityControl,质量控制程序与具体措施,原材料检查验收(三证一检)、实体工程质量验收(分项/分部工程验收)、质量通病防治(墙面空鼓/屋面渗漏)、季节性施工质量控制(冬期混凝土保温/雨期防水)、工序质量控制点、质量检查程序(自检/互检/专检)、质量问题整改(闭环管理),3,SeasonalConstructionQualityAssuranceMeasures,季节性施工质量保证措施,需针对冬期(混凝土保温)、雨期(防水加强)、高温(混凝土保湿)制定专项措施、确保施工质量;,季节性施工;冬期施工;雨期施工;高温施工;夏季施工;冬季混凝土,
-7,environment,环境保证措施,1,EnvSystem,环境保证体系,环境保证体系框图、公司标准体系引用,1,BlockDiagramOfEnvironmentalAssuranceSystem,环境保证体系框图,含环境保证体系框图关键字,及类似表述,视为符合,类似出现这种;见附表,详情,详见附表,专详见另册及类似描述视为符合! 这种表述视为符合,环境保证体系;环境管理体系框图;环境保证体系框图,"当内容仅含""详见附表""、""详见另册""""见附表""""见另册""、""专详见另册""等通用索引说明时,视为本分类已有依据。"
+7,environment,环境保证措施,1,EnvSystem,环境保证体系,环境保证体系框图、公司标准体系引用,1,BlockDiagramOfEnvironmentalAssuranceSystem,环境保证体系框图,含环境保证体系框图关键字,及类似表述,视为符合,类似出现这种;见附表,详情,详见附表,专详见另册及类似描述视为符合! 这种表述视为符合,环境保证体系;环境管理体系框图;环境保证体系框图,当内容仅含"详见附表"、"详见另册"、"见附表"、"见另册"、"专详见另册"等通用索引说明时,视为本分类已有依据。
 7,environment,环境保证措施,2,EnvOrg,环境保护组织机构,环境保护组织架构、管理人员姓名、管理人员职务、管理人员职责、环境管理岗位责任、责任考核机制、环境管理职责分工、环境管理人员资质、环境管理沟通机制,1,EnvironmentalAssuranceSystemFramework,环境保护组织架构,"环境保护组织机构包含管理人员姓名、职务、职责。环境管理组织机构基于项
 目经理为组长的工作领导小组,小组中包括项目经理、项目副经理、项目总工、工
 程部门、质检部门、安全环保部门、专业分包单位(协作队伍)项目负责人和项目
@@ -114,11 +114,11 @@ first_seq,first_code,first_name,second_seq,second_code,second_name,second_focus,
 9,acceptance,验收要求,1,Content,验收内容,安全生产条件验收(安全防护设施验收、临时用电验收)、资源配置验收(人员配置验收、设备配置验收)、施工工艺验收(模板安装工艺验收、混凝土浇筑工艺验收)、机械设备验收(塔式起重机验收、混凝土泵车验收)、临时支撑结构验收(脚手架验收、满堂支架验收)、人员操作平台验收(高空作业平台验收、操作脚手架验收)、安全防护设施验收(安全网验收、防护栏杆验收),3,ConstructionProcessAcceptance,施工工艺验收,验收表格中要明确项目的具体验收标准、验收标准应尽量量化到具体参数或标准、需明确工艺环节(如“模板安装工艺验收”包括“模板垂直度验收”“模板拼接缝验收”)、强调工艺的标准化;,施工工艺验收;模板安装验收;工艺验收;施工技术验收,
 9,acceptance,验收要求,1,Content,验收内容,安全生产条件验收(安全防护设施验收、临时用电验收)、资源配置验收(人员配置验收、设备配置验收)、施工工艺验收(模板安装工艺验收、混凝土浇筑工艺验收)、机械设备验收(塔式起重机验收、混凝土泵车验收)、临时支撑结构验收(脚手架验收、满堂支架验收)、人员操作平台验收(高空作业平台验收、操作脚手架验收)、安全防护设施验收(安全网验收、防护栏杆验收),4,AcceptanceOfMechanicalEquipment,机械设备验收,验收表格中要明确项目的具体验收标准、验收标准应尽量量化到具体参数或标准、需指向具体设备(如“塔式起重机验收”包括“设备型号验收”“安全装置验收”)、确保设备符合施工要求。,机械设备验收;设备验收;起重机验收;机械验收,
 9,acceptance,验收要求,1,Content,验收内容,安全生产条件验收(安全防护设施验收、临时用电验收)、资源配置验收(人员配置验收、设备配置验收)、施工工艺验收(模板安装工艺验收、混凝土浇筑工艺验收)、机械设备验收(塔式起重机验收、混凝土泵车验收)、临时支撑结构验收(脚手架验收、满堂支架验收)、人员操作平台验收(高空作业平台验收、操作脚手架验收)、安全防护设施验收(安全网验收、防护栏杆验收),5,SafetyProtectionFacilities,安全防护措施验收,验收表格中要明确项目的具体验收标准、验收标准应尽量量化到具体参数或标准、需结合场景需求(如建筑施工中的基坑临边防护、电梯井防护门)、功能定位(如预防事故的防护栏杆、减少事故影响的安全网)、技术要求(如材质、构造、固定方式)。其核心逻辑是“隔离危险、承接冲击、提醒注意”,安全防护验收;防护设施验收;安全网验收;防护栏杆验收,
-9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),1,AcceptancePersonnelOfTheConstructionUnit,建设单位验收人员,需明确具体角色(如“建设单位项目负责人”)、避免“建设单位人员”等泛化表述;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,建设单位验收人员;业主验收;建设单位项目负责人;甲方验收,
-9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),2,DesignUnitAcceptancePersonnel,设计单位验收人员,需明确验收人员姓名、关联专业(如“设计单位专业工程师”)、体现设计的专业性;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,设计单位验收;设计单位人员;设计师验收;设计验收,
-9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),3,ConstructionUnitAcceptancePersonnel,施工单位验收人员,需明确验收人员姓名、指向管理岗位(如“施工单位项目经理”“施工单位技术负责人”)、强调施工单位的主体责任;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,施工单位验收;施工方验收;施工单位项目经理;施工验收人员,
-9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),4,InspectionPersonnelOfTheSupervisionUnit,监理单位验收人员,需明确验收人员姓名、监理角色(如“总监理工程师”“专业监理工程师”)、体现监理的监督职责;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,监理单位验收;总监理工程师;监理人员;监理验收,
-9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),5,MonitoringUnitAcceptancePersonnel,监测单位验收人员,需明确验收人员姓名、关联监测内容(如“监测项目负责人”“监测技术员”)、确保监测数据的准确性;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,监测单位验收;监测人员;监测项目负责人;监测验收,
+9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),1,AcceptancePersonnelOfTheConstructionUnit,建设单位验收人员,需明确建设单位验收人员,具体角色(如“建设单位项目负责人”)、避免“建设单位人员”等泛化表述;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,建设单位验收人员;业主验收;建设单位项目负责人;甲方验收,
+9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),2,DesignUnitAcceptancePersonnel,设计单位验收人员,需明确设计单位验收人员姓名、关联专业(如“设计单位专业工程师”)、体现设计的专业性;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,设计单位验收;设计单位人员;设计师验收;设计验收,
+9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),3,ConstructionUnitAcceptancePersonnel,施工单位验收人员,需明确施工单位验收人员姓名、指向管理岗位(如“施工单位项目经理”“施工单位技术负责人”)、强调施工单位的主体责任;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,施工单位验收;施工方验收;施工单位项目经理;施工验收人员,
+9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),4,InspectionPersonnelOfTheSupervisionUnit,监理单位验收人员,需明确监理单位验收人员姓名、监理角色(如“总监理工程师”“专业监理工程师”)、体现监理的监督职责;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,监理单位验收;总监理工程师;监理人员;监理验收,
+9,acceptance,验收要求,2,Personnel,验收人员,建设单位验收人员(如建设单位项目负责人、建设单位技术负责人)、设计单位验收人员(如设计单位项目负责人、设计单位专业工程师)、施工单位验收人员(如施工单位项目经理、施工单位技术负责人)、监理单位验收人员(如总监理工程师、专业监理工程师)、监测单位验收人员(如监测项目负责人、监测技术员),5,MonitoringUnitAcceptancePersonnel,监测单位验收人员,需明确监测单位验收人员姓名、关联监测内容(如“监测项目负责人”“监测技术员”)、确保监测数据的准确性;由施工作业班组在施工过程中自行对照方案自检、施工完成后由方案编制负责人、项目经理、项目副经理、项目技术负责人、安全环保处、工程处、机料处、合同处、专业分包单位(协作队伍)项目负责人和项目技术负责人等部门人员参加方案验收。,监测单位验收;监测人员;监测项目负责人;监测验收,
 10,other,其它资料,1,Team,编制及审核人员情况,专项施工方案验收条件一览表、编制人员信息、复核人员信息、审核人员信息、审批人员信息、姓名、职务、职称,2,PreparePersonnelInformation,编制人员信息,需包含“姓名”“职务”“职称”(如“张三 技术员 助理工程师”)、确保编制人员具备专业能力;,编制人员;编制人信息;方案编制者;编制人,
 10,other,其它资料,1,Team,编制及审核人员情况,专项施工方案验收条件一览表、编制人员信息、复核人员信息、审核人员信息、审批人员信息、姓名、职务、职称,3,ReviewerInformation,审核人员信息,需包含“姓名”“职务”“职称”(如“李四 项目技术负责人 工程师”)、确保审核流程的严谨性;,审核人员;复核人员;审核信息;审核人;复核人,
 10,other,其它资料,1,Team,编制及审核人员情况,专项施工方案验收条件一览表、编制人员信息、复核人员信息、审核人员信息、审批人员信息、姓名、职务、职称,4,ApprovalPersonnelInformation,审批人员信息,需包含“姓名”“职务”“职称”(如“王五 项目经理 高级工程师”)、确保方案符合项目整体要求,审批人员;批准人;审批信息;审批签字;项目经理审批,

+ 14 - 0
core/construction_review/component/minimal_pipeline/pdf_extractor.py

@@ -1149,6 +1149,20 @@ class PdfStructureExtractor:
         if matched_chapter_count == 0 and matched_section_count == 0:
             return chapters
 
+        # 兜底逻辑:如果匹配率过低,直接使用正文原始结构
+        total_chapters = len(chapters)
+        if total_chapters > 0 and matched_chapter_count / total_chapters < 0.5:
+            logger.warning(f"[结构对齐] 目录与正文匹配率过低({matched_chapter_count}/{total_chapters}),使用正文原始结构")
+            return chapters
+
+        # 兜底逻辑:补充目录中缺失但正文存在的章节
+        rebuilt_keys = {self._normalize_heading_key(k) for k in rebuilt.keys()}
+        for chapter_title, sections in chapters.items():
+            chapter_key = self._normalize_heading_key(chapter_title)
+            if chapter_key not in rebuilt_keys:
+                rebuilt[chapter_title] = sections
+                logger.info(f"[结构对齐] 目录缺失章节'{chapter_title}',从正文提取补充")
+
         return rebuilt or chapters
 
     @staticmethod

+ 0 - 481
core/construction_review/component/minimal_pipeline/pdf_extractor3.py

@@ -1,481 +0,0 @@
-"""
-PDF 结构提取器 - 同步并发 OCR 版本
-
-基于 splitter_pdf 逻辑,直接提取章节结构并记录页码。
-支持 OCR 增强:检测表格区域并使用 ThreadPoolExecutor 5并发 OCR,其他文本保持 PyMuPDF 提取。
-输出格式兼容后续分类与组装流程。
-"""
-
-import re
-from typing import Dict, Any, List, Optional, Tuple
-
-import fitz
-
-from foundation.observability.logger.loggering import review_logger as logger
-
-from .ocr_processor import OcrProcessor, TableRegion, OcrResult
-
-# 尝试导入 RapidLayout
-try:
-    from rapid_layout import RapidLayout
-    RAPID_LAYOUT_AVAILABLE = True
-except ImportError:
-    RAPID_LAYOUT_AVAILABLE = False
-    RapidLayout = None
-
-
-class PdfStructureExtractor:
-    """PDF 章节结构提取器(支持 OCR 异步并发)"""
-
-    CHAPTER_PATTERN = re.compile(r'^第[一二三四五六七八九十百]+章\s*.*')
-    SECTION_PATTERN = re.compile(r'^[一二三四五六七八九十百]+、\s*.*')
-    TOC_PATTERN = re.compile(r"\.{3,}|…{2,}")
-
-    def __init__(
-        self,
-        clip_top: float = 60,
-        clip_bottom: float = 60,
-        use_ocr: bool = False,
-        ocr_api_url: str = "http://183.220.37.46:25429/v1/chat/completions",
-        ocr_timeout: int = 600,
-        ocr_api_key: str = "",
-        detect_toc: bool = True,
-        toc_model_path: str = "config/yolo/best.pt",
-    ):
-        self.clip_top = clip_top
-        self.clip_bottom = clip_bottom
-        self.use_ocr = use_ocr and RAPID_LAYOUT_AVAILABLE
-
-        # 初始化 OCR 处理器
-        self._ocr_processor = OcrProcessor(
-            ocr_api_url=ocr_api_url,
-            ocr_timeout=ocr_timeout,
-            ocr_api_key=ocr_api_key,
-        ) if self.use_ocr else None
-
-        # 目录检测配置
-        self.detect_toc = detect_toc
-        self.toc_model_path = toc_model_path
-        self._toc_extractor = None
-
-        if use_ocr and not RAPID_LAYOUT_AVAILABLE:
-            logger.warning("RapidLayout 未安装,OCR 功能不可用")
-
-    def extract(self, file_content: bytes, progress_callback=None) -> Dict[str, Any]:
-        """
-        从 PDF 字节流提取章节结构。
-
-        Args:
-            file_content: PDF 文件字节流
-            progress_callback: 进度回调函数,接收 (stage, current, message) 参数
-
-        Returns:
-            {
-                "chapters": {
-                    "第一章 xxx": {
-                        "章节标题": {"content": "...", "page_start": 1, "page_end": 1},
-                        "一、xxx": {"content": "...", "page_start": 2, "page_end": 3},
-                    }
-                },
-                "total_pages": N,
-                "catalog": {  # 目录结构(YOLO检测+OCR提取)
-                    "chapters": [...],
-                    "total_chapters": N
-                }
-            }
-        """
-        result = {"chapters": {}, "total_pages": 0, "catalog": None}
-
-        # === 阶段0: 目录页检测与提取(如果启用)===
-        if self.detect_toc:
-            try:
-                catalog = self._extract_catalog(file_content, progress_callback)
-                if catalog:
-                    result["catalog"] = catalog
-                    logger.info(f"[PDF提取] 目录提取完成: {catalog.get('total_chapters', 0)} 章")
-            except Exception as e:
-                logger.warning(f"[PDF提取] 目录提取失败: {e}")
-
-        # === 阶段1-3: 文档结构提取 ===
-        doc = fitz.open(stream=file_content)
-        try:
-            structure = self._extract_from_doc(doc, progress_callback)
-            result["chapters"] = structure.get("chapters", {})
-            result["total_pages"] = len(doc)
-            return result
-        finally:
-            doc.close()
-
-    def _extract_catalog(self, file_content: bytes, progress_callback=None) -> Optional[Dict[str, Any]]:
-        """
-        提取目录结构(YOLO检测 + OCR识别)
-
-        Returns:
-            {"chapters": [...], "total_chapters": N} 或 None
-        """
-        # 延迟导入避免循环依赖(YOLO依赖必须存在,否则报错)
-        from .toc_detector import TOCCatalogExtractor
-
-        if self._toc_extractor is None:
-            # 使用 OCR 处理器的配置(如果已初始化)
-            ocr_config = {}
-            if self._ocr_processor:
-                ocr_config = {
-                    "ocr_api_url": self._ocr_processor.ocr_api_url,
-                    "ocr_api_key": self._ocr_processor.ocr_api_key,
-                    "ocr_timeout": self._ocr_processor.ocr_timeout,
-                }
-            self._toc_extractor = TOCCatalogExtractor(
-                model_path=self.toc_model_path,
-                **ocr_config
-            )
-
-        return self._toc_extractor.detect_and_extract(file_content, progress_callback)
-
-    def _extract_from_doc(self, doc: fitz.Document, progress_callback=None) -> Dict[str, Any]:
-        """
-        提取文档结构(支持 OCR 异步并发)- 带坐标的精准回填方案。
-
-        流程:
-        1. 提取带坐标的文本块
-        2. 章节标题匹配 + 块归属划分
-        3. 扫描表格区域并 OCR
-        4. 根据表格坐标,将其作为新的块插入到对应小节
-        5. 将每个小节的块列表按顺序拼接成纯文本输出
-        """
-
-        def _emit_progress(stage: str, current: int, message: str):
-            """发送进度回调"""
-            if progress_callback:
-                try:
-                    progress_callback(stage, current, message)
-                except Exception:
-                    pass
-
-        total_pages = len(doc)
-
-        # ==================== 阶段1: 提取带坐标的文本块并归属到章节/小节====================
-        logger.info("[阶段1] 提取带坐标的文本块并归属章节...")
-
-        # 数据结构: {(chapter_name, section_name): [blocks_with_position]}
-        chapter_blocks: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
-        current_chapter = "未分类前言"
-        current_section = "默认部分"
-        in_body = False
-
-        for page_num in range(total_pages):
-            page = doc.load_page(page_num)
-            rect = page.rect
-            clip_box = fitz.Rect(0, self.clip_top, rect.width, rect.height - self.clip_bottom)
-
-            # 获取带坐标的文本块
-            blocks = self._extract_text_blocks_with_position(page, clip_box)
-
-            for block in blocks:
-                line = block["text"]
-
-                # 跳过空行和页眉页脚
-                if not line.strip():
-                    continue
-                if self._is_header_footer(line):
-                    continue
-
-                # 跳过目录阶段
-                if not in_body:
-                    if self.CHAPTER_PATTERN.match(line) and not self.TOC_PATTERN.search(line):
-                        in_body = True
-                    else:
-                        continue
-
-                # 跳过残余目录格式
-                if self.TOC_PATTERN.search(line):
-                    continue
-
-                # 匹配章标题
-                if self.CHAPTER_PATTERN.match(line):
-                    current_chapter = self._clean_chapter_title(line)
-                    current_section = "章节标题"
-                    key = (current_chapter, current_section)
-                    if key not in chapter_blocks:
-                        chapter_blocks[key] = []
-                    chapter_blocks[key].append(block)
-                    continue
-
-                # 匹配节标题
-                if self.SECTION_PATTERN.match(line):
-                    current_section = line
-                    key = (current_chapter, current_section)
-                    if key not in chapter_blocks:
-                        chapter_blocks[key] = []
-                    chapter_blocks[key].append(block)
-                    continue
-
-                # 普通内容块
-                key = (current_chapter, current_section)
-                if key not in chapter_blocks:
-                    chapter_blocks[key] = []
-                chapter_blocks[key].append(block)
-
-        logger.info(f"[阶段1] 章节结构提取完成,共 {len({k[0] for k in chapter_blocks})} 个章节")
-
-        # ==================== 阶段2: 收集表格区域并OCR(如果启用OCR)====================
-        table_regions: List[TableRegion] = []
-        ocr_results: List[OcrResult] = []
-
-        if self.use_ocr and self._ocr_processor:
-            logger.info("[阶段2] 扫描表格区域...")
-            for page_num in range(total_pages):
-                page = doc.load_page(page_num)
-                rect = page.rect
-                clip_box = fitz.Rect(0, self.clip_top, rect.width, rect.height - self.clip_bottom)
-                regions = self._ocr_processor.detect_table_regions(page, page_num + 1, clip_box)
-                for bbox, score in regions:
-                    table_regions.append(TableRegion(
-                        page_num=page_num + 1,
-                        page=page,
-                        bbox=bbox,
-                        score=score
-                    ))
-                # 每5页推送进度
-                if (page_num + 1) % 5 == 0 or page_num == total_pages - 1:
-                    progress = int((page_num + 1) / total_pages * 30)
-                    _emit_progress("版面分析", progress, f"扫描页面 {page_num + 1}/{total_pages}")
-
-            logger.info(f"[阶段2] 发现 {len(table_regions)} 个表格区域")
-
-            # 执行OCR
-            if table_regions:
-                _emit_progress("版面分析", 35, f"发现 {len(table_regions)} 个表格,开始OCR识别...")
-                ocr_results = self._ocr_processor.process_ocr_concurrent(
-                    table_regions,
-                    progress_callback=lambda completed, total: _emit_progress(
-                        "版面分析", 35 + int(completed / total * 15), f"OCR识别中 {completed}/{total}"
-                    )
-                )
-                success_count = sum(1 for r in ocr_results if r.success)
-                logger.info(f"[阶段2] OCR完成 {success_count}/{len(table_regions)}")
-                _emit_progress("版面分析", 50, f"OCR识别完成 {success_count}/{len(table_regions)}")
-
-        # ==================== 阶段3: 将OCR结果作为新块插入到对应章节====================
-        if ocr_results:
-            logger.info("[阶段3] 将OCR结果回填到对应章节...")
-            self._insert_ocr_blocks_into_chapters(chapter_blocks, ocr_results)
-
-        # ==================== 阶段4: 生成最终输出(块列表转纯文本)====================
-        logger.info("[阶段4] 生成最终文本输出...")
-        result: Dict[str, Any] = {"chapters": {}}
-
-        for (chap_name, sec_name), blocks in chapter_blocks.items():
-            if chap_name not in result["chapters"]:
-                result["chapters"][chap_name] = {}
-
-            # 按页码和Y坐标排序块
-            blocks.sort(key=lambda b: (b["page"], b["bbox"][1]))
-
-            # 拼接文本
-            lines = []
-            page_start = blocks[0]["page"] if blocks else 1
-            page_end = blocks[-1]["page"] if blocks else 1
-
-            for block in blocks:
-                if block.get("type") == "table":
-                    lines.append(f"\n[表格OCR识别结果]:\n{block['text']}\n[/表格]\n")
-                else:
-                    lines.append(block["text"])
-
-            result["chapters"][chap_name][sec_name] = {
-                "content": "\n".join(lines),
-                "page_start": page_start,
-                "page_end": page_end,
-            }
-
-        logger.info(f"[PdfExtractor] 提取完成,共 {len(result['chapters'])} 个章节")
-        return result
-
-    def _extract_text_blocks_with_position(
-        self,
-        page: fitz.Page,
-        clip_box: fitz.Rect
-    ) -> List[Dict[str, Any]]:
-        """
-        提取带坐标的文本块列表。
-
-        使用 page.get_text("dict") 获取每个文本块的精确边界框和文本内容。
-        """
-        blocks = []
-        page_dict = page.get_text("dict", clip=clip_box)
-
-        for block in page_dict.get("blocks", []):
-            if block.get("type") == 0:  # 文本块
-                bbox = block["bbox"]
-                y_center = (bbox[1] + bbox[3]) / 2
-
-                # 拼接块内所有文本
-                text_lines = []
-                for line in block.get("lines", []):
-                    line_text = ""
-                    for span in line.get("spans", []):
-                        line_text += span.get("text", "")
-                    if line_text.strip():
-                        text_lines.append(line_text)
-
-                if text_lines:
-                    blocks.append({
-                        "text": "\n".join(text_lines),
-                        "page": page.number + 1,
-                        "bbox": bbox,
-                        "y_center": y_center,
-                        "type": "text"
-                    })
-
-        # 按阅读顺序排序(Y坐标为主,X坐标为辅)
-        blocks.sort(key=lambda b: (b["page"], b["bbox"][1], b["bbox"][0]))
-        return blocks
-
-    def _insert_ocr_blocks_into_chapters(
-        self,
-        chapter_blocks: Dict[Tuple[str, str], List[Dict[str, Any]]],
-        ocr_results: List[OcrResult]
-    ) -> None:
-        """
-        将OCR结果作为新的块插入到对应章节。
-
-        策略:
-        1. 找到表格Y坐标所在的页面
-        2. 在该页面的所有小节中,找到表格Y坐标介于哪两个文本块之间
-        3. 将OCR块插入到正确位置
-        """
-        # 按页码分组OCR结果
-        ocr_by_page: Dict[int, List[OcrResult]] = {}
-        for result in ocr_results:
-            if result.success:
-                if result.page_num not in ocr_by_page:
-                    ocr_by_page[result.page_num] = []
-                ocr_by_page[result.page_num].append(result)
-
-        # 处理每个包含表格的页面
-        for page_num, ocr_list in ocr_by_page.items():
-            # 找到该页面涉及的所有小节
-            page_sections = []
-            for (chap_name, sec_name), blocks in chapter_blocks.items():
-                # 检查该小节是否包含该页面的块
-                page_blocks = [b for b in blocks if b["page"] == page_num]
-                if page_blocks:
-                    page_sections.append({
-                        "chapter": chap_name,
-                        "section": sec_name,
-                        "blocks": page_blocks,
-                        "all_blocks": blocks,  # 引用原列表用于插入
-                    })
-
-            if not page_sections:
-                logger.warning(f"[OCR回填] 第{page_num}页没有匹配到任何小节")
-                continue
-
-            # 处理每个OCR结果
-            for ocr_result in sorted(ocr_list, key=lambda r: r.bbox[1]):
-                table_y_top = ocr_result.bbox[1]
-                table_y_bottom = ocr_result.bbox[3]
-                ocr_text = ocr_result.text
-
-                # 构造表格块
-                table_block = {
-                    "text": ocr_text,
-                    "page": page_num,
-                    "bbox": ocr_result.bbox,
-                    "y_center": (table_y_top + table_y_bottom) / 2,
-                    "type": "table"
-                }
-
-                # 找到目标小节
-                target_section = None
-                insert_index = -1
-
-                for ps in page_sections:
-                    # 获取该小节在该页面的所有块,按Y坐标排序
-                    page_blocks = sorted(ps["blocks"], key=lambda b: b["bbox"][1])
-
-                    if not page_blocks:
-                        continue
-
-                    # 找到表格应该插入的位置
-                    # 策略:表格上边界位于哪个块之后
-                    found = False
-                    for i, block in enumerate(page_blocks):
-                        block_y_bottom = block["bbox"][3]
-                        if i < len(page_blocks) - 1:
-                            next_y_top = page_blocks[i + 1]["bbox"][1]
-                        else:
-                            next_y_top = float('inf')
-
-                        # 如果表格位于当前块之后,且在下一块之前
-                        if block_y_bottom <= table_y_top < next_y_top:
-                            # 找到在原列表中的位置
-                            try:
-                                insert_index = ps["all_blocks"].index(block) + 1
-                                target_section = ps
-                                found = True
-                                break
-                            except ValueError:
-                                continue
-
-                    # 如果表格在所有块之前
-                    if not found and table_y_top < page_blocks[0]["bbox"][1]:
-                        try:
-                            insert_index = ps["all_blocks"].index(page_blocks[0])
-                            target_section = ps
-                            found = True
-                        except ValueError:
-                            continue
-
-                    # 如果表格在所有块之后
-                    if not found and table_y_bottom > page_blocks[-1]["bbox"][3]:
-                        try:
-                            insert_index = ps["all_blocks"].index(page_blocks[-1]) + 1
-                            target_section = ps
-                            found = True
-                        except ValueError:
-                            continue
-
-                    if found:
-                        break
-
-                # 执行插入
-                if target_section and insert_index >= 0:
-                    target_section["all_blocks"].insert(insert_index, table_block)
-                    logger.debug(
-                        f"[OCR回填] 第{page_num}页表格(Y={table_y_top:.0f}) -> "
-                        f"{target_section['chapter']}/{target_section['section']} 位置{insert_index}"
-                    )
-                else:
-                    # 兜底:追加到该页面第一个小节末尾
-                    if page_sections:
-                        ps = page_sections[0]
-                        ps["all_blocks"].append(table_block)
-                        logger.warning(
-                            f"[OCR回填] 第{page_num}页表格无法精确定位,追加到 {ps['chapter']}/{ps['section']}"
-                        )
-
-    @staticmethod
-    def _is_header_footer(line: str) -> bool:
-        return (
-            "四川路桥建设集团股份有限公司" in line
-            or "T梁运输及安装专项施工方案" in line
-            or line.isdigit()
-        )
-
-    @staticmethod
-    def _clean_chapter_title(line: str) -> str:
-        chapter_match = re.search(r"第[一二三四五六七八九十百]+章", line)
-        if not chapter_match:
-            return line.strip()
-
-        prefix = chapter_match.group(0)
-        remaining = line[chapter_match.end() :].strip()
-        remaining = re.sub(r"^[\.\s]+", "", remaining)
-        remaining = re.sub(r"\s+\d+\s*$", "", remaining)
-        remaining = re.sub(r"[\._\-]{3,}[^\u4e00-\u9fa5a-zA-Z0-9]*", "", remaining)
-
-        if remaining:
-            return f"{prefix} {remaining}"
-        return prefix

+ 30 - 7
core/construction_review/component/minimal_pipeline/simple_processor.py

@@ -25,8 +25,6 @@ from ..doc_worker.classification.hierarchy_classifier import HierarchyClassifier
 from ..doc_worker.classification.chunk_classifier import ChunkClassifier
 from ..doc_worker.models import (
     UnifiedDocumentStructure,
-    PrimaryClassification,
-    SecondaryClassification,
     TertiaryClassification,
     TertiaryItem,
     Outline,
@@ -76,7 +74,14 @@ class SimpleDocumentProcessor:
         )
 
         if not chunks:
-            empty_result = self._build_empty_unified(file_name, structure.get("total_pages", 0))
+            # 提取失败时也要保留 quality_check
+            quality_check = structure.get("chapters", {}).get("quality_check", {})
+            raw_metadata = {"quality_check": quality_check} if quality_check else {}
+            empty_result = self._build_empty_unified(
+                file_name,
+                structure.get("total_pages", 0),
+                raw_metadata=raw_metadata
+            )
             empty_result.catalog = catalog
             return empty_result
 
@@ -152,6 +157,18 @@ class SimpleDocumentProcessor:
         # 3. 二级分类
         secondary_result = await self.hierarchy_classifier.classify_secondary_async(primary_result)
         logger.info(f"[SimpleProcessor] 二级分类完成: {secondary_result.get('total_count', 0)} 项")
+
+        # 3.5 生成分类告警并合并到 quality_check
+        classification_alerts = await self.hierarchy_classifier.get_classification_alerts(
+            primary_result, secondary_result
+        )
+        if "chapters" in structure and "quality_check" in structure["chapters"]:
+            structure["chapters"]["quality_check"].update(classification_alerts)
+            l1_count = len(classification_alerts.get("l1_system_alerts_lists", []))
+            l2_count = len(classification_alerts.get("l2_system_alerts_lists", []))
+            if l1_count > 0 or l2_count > 0:
+                logger.info(f"[SimpleProcessor] 分类告警: {l1_count} 个一级非标准项, {l2_count} 个二级非标准项")
+
         await self._emit_progress(progress_callback, "文档分类", 40, "二级分类完成")
 
         # 4. 组装 chunks
@@ -346,12 +363,18 @@ class SimpleDocumentProcessor:
 
         unified.tertiary_classifications = tertiary_list
 
-    def _build_empty_unified(self, document_name: str, total_pages: int) -> UnifiedDocumentStructure:
+    def _build_empty_unified(
+        self,
+        document_name: str,
+        total_pages: int,
+        raw_metadata: Optional[Dict[str, Any]] = None
+    ) -> UnifiedDocumentStructure:
         return UnifiedDocumentStructure(
             document_id=str(uuid.uuid4()),
             document_name=document_name,
             total_pages=total_pages,
             secondary_classifications=[],
+            raw_metadata=raw_metadata or {},
         )
 
     async def _classify_catalog(self, catalog: Dict[str, Any]) -> Dict[str, Any]:
@@ -478,10 +501,10 @@ class SimpleDocumentProcessor:
             l1_threshold: 一级章节提取率阈值
             l2_threshold: 二级小节提取率阈值
         """
-        chapters = structure.get("chapters", {})
         # 确保 chapters 存在(即使为空),以便添加 quality_check
-        if "chapters" not in structure:
-            structure["chapters"] = chapters
+        if "chapters" not in structure or structure["chapters"] is None:
+            structure["chapters"] = {}
+        chapters = structure["chapters"]
 
         # 统计一级章节数量
         l1_count = len(chapters)

+ 0 - 119
core/construction_review/component/minimal_pipeline/test.py

@@ -1,119 +0,0 @@
-import fitz  # PyMuPDF
-import re
-import json
-import os
-from datetime import datetime
-
-def extract_and_split_construction_plan(pdf_path):
-    # 打开PDF文件
-    doc = fitz.open(pdf_path)
-    
-    # 编译正则表达式
-    chapter_pattern = re.compile(r'^第[一二三四五六七八九十百]+章\s*.*')
-    section_pattern = re.compile(r'^[一二三四五六七八九十百]+、\s*.*')
-    # 用于识别目录的特征:连续的三个以上小数点或省略号
-    toc_pattern = re.compile(r'\.{3,}|…{2,}') 
-    
-    structured_data = {}
-    current_chapter = "未分类前言"
-    current_section = "默认部分"
-    
-    in_body = False  # 状态机:标记是否已经跳过目录,正式进入正文
-    
-    for page_num in range(len(doc)):
-        page = doc.load_page(page_num)
-        
-        # 1. 清理页眉页脚:利用 clip 裁剪页面提取区域
-        # 默认A4纸高度约842磅,裁剪掉顶部和底部各60磅的区域(可根据实际PDF微调)
-        rect = page.rect
-        clip_box = fitz.Rect(0, 60, rect.width, rect.height - 60)
-        
-        # 仅提取裁剪框内的纯文本
-        text = page.get_text("text", clip=clip_box)
-        lines = text.split('\n')
-        
-        for line in lines:
-            line = line.strip()
-            # 跳过空行
-            if not line:
-                continue
-            
-            # 双保险:过滤掉可能因排版偏移漏掉的页眉页脚特征词或孤立的页码
-            if "四川路桥建设集团股份有限公司" in line or "T梁运输及安装专项施工方案" in line or line.isdigit():
-                continue
-            
-            # 2. 删除目录逻辑:判断是否正式进入正文
-            if not in_body:
-                if chapter_pattern.match(line) and not toc_pattern.search(line):
-                    in_body = True
-                else:
-                    continue  # 还在目录页,直接跳过
-            
-            # 进入正文后的防干扰处理:跳过残余目录格式
-            if toc_pattern.search(line):
-                continue
-            
-            # 匹配到一级标题
-            if chapter_pattern.match(line):
-                current_chapter = line
-                current_section = "章节前言" 
-                if current_chapter not in structured_data:
-                    structured_data[current_chapter] = {current_section: []}
-                continue
-            
-            # 匹配到二级标题
-            if section_pattern.match(line):
-                current_section = line
-                if current_chapter not in structured_data:
-                    structured_data[current_chapter] = {}
-                if current_section not in structured_data[current_chapter]:
-                    structured_data[current_chapter][current_section] = []
-                continue
-            
-            # 容错处理:确保基础字典结构存在
-            if current_chapter not in structured_data:
-                structured_data[current_chapter] = {current_section: []}
-            if current_section not in structured_data[current_chapter]:
-                structured_data[current_chapter][current_section] = []
-                
-            # 3. 将正文内容累加到对应的层级下
-            structured_data[current_chapter][current_section].append(line)
-    
-    # 将列表拼接成完整的文本块
-    for chap in structured_data:
-        for sec in structured_data[chap]:
-            structured_data[chap][sec] = '\n'.join(structured_data[chap][sec])
-            
-    return structured_data
-
-if __name__ == "__main__":
-    # 获取用户输入的路径
-    user_input = input("请输入需要提取的PDF文件路径(支持直接拖入文件或粘贴路径):")
-    
-    # 清理路径两端可能存在的引号和空格(应对“复制文件地址”或拖拽文件带来的双引号)
-    pdf_file_path = user_input.strip('\'" ')
-    
-    # 检查文件是否存在
-    if not os.path.exists(pdf_file_path):
-        print(f"\n[错误] 找不到文件,请检查路径是否正确:{pdf_file_path}")
-    else:
-        print("\n开始提取施工方案,请稍候...")
-        try:
-            result_data = extract_and_split_construction_plan(pdf_file_path)
-            
-            # 4. 保存为本地JSON,名称为:文件名+当前时间(到秒)
-            base_name = os.path.splitext(os.path.basename(pdf_file_path))[0]
-            current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
-            
-            # 将输出文件保存在与原PDF相同的目录下
-            output_dir = os.path.dirname(pdf_file_path)
-            output_filename = os.path.join(output_dir, f"{base_name}_{current_time}.json")
-            
-            with open(output_filename, 'w', encoding='utf-8') as json_file:
-                json.dump(result_data, json_file, ensure_ascii=False, indent=4)
-                
-            print(f"\n[成功] 提取完成!")
-            print(f"结构化数据已保存至: {output_filename}")
-            
-        except Exception as e:
-            print(f"\n[失败] 提取过程中发生错误: {e}")

+ 3 - 0
core/construction_review/component/minimal_pipeline/toc_builder.py

@@ -20,6 +20,9 @@ def build_toc_items_from_structure(structure: Dict[str, Any]) -> List[Dict[str,
     """
     toc_items: List[Dict[str, Any]] = []
     for chapter_title, sections in structure.get("chapters", {}).items():
+        # 跳过 quality_check 等非章节数据
+        if chapter_title == "quality_check":
+            continue
         # 安全获取 page_start,默认值为 1
         page_starts = [s.get("page_start", 1) for s in sections.values() if isinstance(s, dict)]
         page_start = min(page_starts) if page_starts else 1

+ 14 - 11
core/construction_review/component/minimal_pipeline/toc_detector.py

@@ -445,9 +445,13 @@ class TOCCatalogExtractor:
                 else:
                     idx = chinese_nums.get(chapter_num, len(chapters) + 1)
 
+                # 从原始行提取完整标题(保留原文格式)
+                # 移除行尾页码,保留章节号+标题的原文形式
+                original_title = re.sub(r'[\.\s]*(\d+)\s*$', '', line).strip()
+
                 current_chapter = {
                     "index": idx,
-                    "title": f"第{idx}章 {title}",
+                    "title": original_title,
                     "page": page,
                     "original": line,
                     "subsections": []
@@ -469,7 +473,7 @@ class TOCCatalogExtractor:
                     section_cn = section_num
 
                 current_chapter["subsections"].append({
-                    "title": f"{section_cn}、{title}",
+                    "title": title,
                     "page": page,
                     "level": 2,
                     "original": line
@@ -488,19 +492,19 @@ class TOCCatalogExtractor:
                                                '验收要求']):
                     chapters.append(current_chapter)
                     idx = len(chapters) + 1
+                    # 保留原标题,只移除页码
+                    original_title = re.sub(r'[\.\s]*(\d+)\s*$', '', line).strip()
                     current_chapter = {
                         "index": idx,
-                        "title": f"第{idx}章 {title}",
+                        "title": original_title,
                         "page": page,
                         "original": line,
                         "subsections": []
                     }
                 else:
-                    # 作为节,自动编号
-                    section_idx = len(current_chapter["subsections"]) + 1
-                    section_cn = self._number_to_chinese(section_idx)
+                    # 作为节,保留原标题
                     current_chapter["subsections"].append({
-                        "title": f"{section_cn}、{title}",
+                        "title": title,
                         "page": page,
                         "level": 2,
                         "original": line
@@ -566,18 +570,17 @@ class TOCCatalogExtractor:
                 section_idx = 0  # 重置节计数
                 chapters.append({
                     "index": idx,
-                    "title": f"第{idx}章 {title}",
+                    "title": title,
                     "page": page,
                     "original": line,
                     "subsections": []
                 })
             else:
-                # 作为上一章的节,使用标准格式 一、二、三
+                # 作为上一章的节,保留原标题
                 if chapters:
                     section_idx += 1
-                    section_cn = self._number_to_chinese(section_idx)
                     chapters[-1]["subsections"].append({
-                        "title": f"{section_cn}、{title}",
+                        "title": title,
                         "page": page,
                         "level": 2,
                         "original": line

+ 61 - 7
core/construction_review/component/reviewers/completeness_reviewer.py

@@ -18,6 +18,7 @@ from pathlib import Path
 import json
 
 from foundation.observability.logger.loggering import review_logger as logger
+from ..doc_worker.classification.hierarchy_classifier import is_secondary_in_whitelist, is_secondary_in_whitelist_async
 
 
 @dataclass
@@ -257,7 +258,7 @@ class LightweightCompletenessChecker:
         # 构建问题上下文
         if level == "一级":
             context = f"""
-【问题类型】一级章节缺失
+【问题类型】一级章节缺失或标题不符合规范
 【缺失章节】{first_name} ({first_code})
 【问题描述】文档中缺少'{first_name}'整个章节,这是专项施工方案中必须包含的一级章节。"""
             # 获取该一级下的所有二级和三级信息作为参考
@@ -282,7 +283,7 @@ class LightweightCompletenessChecker:
 
         elif level == "二级":
             context = f"""
-【问题类型】二级章节缺失
+【问题类型】二级小节缺失或标题不符合规范
 【所属一级】{first_name} ({first_code})
 【缺失章节】{second_name} ({second_code})
 【问题描述】'{first_name}'下缺少'{second_name}'二级章节。"""
@@ -478,7 +479,8 @@ JSON输出:"""
         overall_status = self._calc_overall_status(tertiary_result)
 
         # 7. 生成分级建议
-        actual_first = {cat1 for cat1, _ in actual_secondary}
+        # 一级分类独立提取(不依赖二级,避免二级被过滤时误报一级缺失)
+        actual_first = self._extract_first_from_chunks(chunks)
         recommendations = await self._generate_recommendations(
             tertiary_result, catalogue_result, outline_result,
             actual_first, actual_secondary, actual_tertiary,
@@ -494,8 +496,38 @@ JSON输出:"""
             recommendations=recommendations
         )
     
+    def _is_valid_first_code(self, code: str) -> bool:
+        """检查一级分类代码是否有效(在标准分类中)"""
+        if not code:
+            return False
+        # 排除已知的非章节键
+        if code in ("quality_check", "catalog", "metadata"):
+            return False
+        # 检查是否在标准一级分类中
+        return code in self.spec_loader.first_names
+
+    def _extract_first_from_chunks(self, chunks: List[Dict]) -> Set[str]:
+        """
+        从chunks独立提取实际存在的一级分类(不依赖二级)。
+
+        解决场景:当一级存在但所有二级被过滤(如标记为non_standard)时,
+        避免误报"一级章节缺失"。
+        """
+        actual_first = set()
+        for chunk in chunks:
+            # 支持 metadata 嵌套格式和直接字段格式
+            metadata = chunk.get("metadata", {})
+            cat1 = (metadata.get("chapter_classification") or
+                    chunk.get("chapter_classification") or
+                    chunk.get("first_code"))
+            # 归一化并验证
+            cat1 = self._normalize_chapter_code(cat1)
+            if self._is_valid_first_code(cat1):
+                actual_first.add(cat1)
+        return actual_first
+
     def _extract_secondary_from_chunks(self, chunks: List[Dict]) -> Set[Tuple[str, str]]:
-        """从chunks提取实际存在的二级分类(支持 metadata 嵌套格式)"""
+        """从chunks提取实际存在的二级分类(支持 metadata 嵌套格式),跳过非标准项"""
         actual = set()
         for chunk in chunks:
             # 支持 metadata 嵌套格式和直接字段格式
@@ -506,13 +538,19 @@ JSON输出:"""
             cat2 = (metadata.get("secondary_category_code") or
                     chunk.get("secondary_category_code") or
                     chunk.get("second_code"))
+            # 跳过非标准项
+            if cat2 == "non_standard":
+                continue
+            # 跳过无效的一级分类代码
+            if not self._is_valid_first_code(cat1):
+                continue
             if cat1 and cat2:
                 actual.add((cat1, cat2))
         return actual
 
     def _extract_tertiary_from_chunks(self, chunks: List[Dict]) -> Set[Tuple[str, str, str]]:
         """
-        从chunks提取实际存在的三级分类
+        从chunks提取实际存在的三级分类,跳过非标准项。
 
         支持三种数据格式:
         1. 传统格式:每个 chunk 有 tertiary_category_code 字段(单分类)
@@ -531,6 +569,14 @@ JSON输出:"""
                     chunk.get("secondary_category_code") or
                     chunk.get("second_code"))
 
+            # 跳过非标准项
+            if cat2 == "non_standard":
+                continue
+
+            # 跳过无效的一级分类代码
+            if not self._is_valid_first_code(cat1):
+                continue
+
             if not cat1 or not cat2:
                 continue
 
@@ -874,6 +920,9 @@ JSON输出:"""
             section_label = (metadata.get("section_label") or
                              chunk.get("section_label") or
                              "")
+            # 跳过无效的一级分类代码
+            if not self._is_valid_first_code(cat1):
+                continue
             if cat1 and cat2 and section_label:
                 label_map[(cat1, cat2)] = section_label
         return label_map
@@ -961,7 +1010,7 @@ JSON输出:"""
             # ── 一级缺失 ──────────────────────────────────────────────
             if first_code not in actual_first:
                 # issue_point 和 reason 使用简单拼接
-                issue_point = f"【一级章节缺失】'{first_name}'整个章节不存在"
+                issue_point = f"【一级章节缺失或标题不规范】'{first_name}'整个章节不存在"
                 reason = f"依据交通运输部《公路水运危险性较大工程专项施工方案编制审查规程》(JT/T 1495—2024)规定,文档必须包含'{first_name}'一级章节,当前正文中未发现该章节任何内容"
 
                 # 尝试使用LLM生成 suggestion
@@ -1000,11 +1049,16 @@ JSON输出:"""
 
                 # ── 二级缺失 ──────────────────────────────────────────
                 if (cat1, cat2) not in actual_secondary:
+                    # 白名单检查:如果在标准目录白名单中,则跳过告警(带模型兜底)
+                    if await is_secondary_in_whitelist_async(cat1, second_name):
+                        logger.debug(f"[白名单过滤] 二级章节 '{second_name}' 在标准目录白名单中,跳过缺失告警")
+                        continue
+
                     # 获取实际一级章节名
                     actual_first_name = self._get_actual_first_name(label_map, cat1)
 
                     # issue_point 和 reason 使用简单拼接
-                    issue_point = f"【二级章节缺失】{actual_first_name} > '{second_name}'整个章节不存在"
+                    issue_point = f"【二级小节缺失或标题不规范】{actual_first_name} > '{second_name}'整个章节不存在"
                     reason = f"依据交通运输部《公路水运危险性较大工程专项施工方案编制审查规程》(JT/T 1495—2024)规定,'{actual_first_name}'下应包含'{second_name}'二级章节,当前正文中未发现该章节内容"
 
                     # 尝试使用LLM生成 suggestion

+ 0 - 400
core/construction_review/component/reviewers/utils/llm_chain_client/README.md

@@ -1,400 +0,0 @@
-# LLM 链式客户端 (LLM Chain Client)
-
-一个通用的异步 LLM 提示链执行框架,支持多模型 API 调用、提示词模板渲染和链式任务编排。该模块设计为可独立复用的组件,可在任何需要调用大模型进行链式处理的 Python 项目中使用。
-
-## 功能特性
-
-- ✅ **多模型支持**:内置 Qwen、Gemini、DeepSeek、Doubao 等模型客户端
-- ✅ **异步执行**:基于 `asyncio` 和 `aiohttp` 的高并发 API 调用
-- ✅ **提示链编排**:支持多步骤链式任务,步骤间可传递数据
-- ✅ **模板渲染**:基于 Jinja2 的提示词模板引擎
-- ✅ **配置驱动**:提示词和链配置通过 YAML 文件管理
-- ✅ **完全解耦**:接口驱动设计,易于扩展和测试
-- ✅ **独立可用**:无父项目依赖,可独立安装使用
-
-## 架构设计
-
-```
-llm_chain_client/
-├── interfaces/          # 接口层 - 定义组件契约
-│   ├── llm_client.py    # LLM 客户端接口
-│   ├── prompt_loader.py # 提示词加载器接口
-│   └── chain_executor.py # 链执行器接口
-├── implementations/     # 实现层 - 具体实现
-│   ├── clients/        # 各模型客户端实现
-│   │   ├── base_client.py
-│   │   ├── qwen_client.py
-│   │   ├── gemini_client.py
-│   │   ├── deepseek_client.py
-│   │   └── doubao_client.py
-│   ├── loaders/        # 提示词加载器实现
-│   │   └── yaml_prompt_loader.py
-│   └── chains/         # 链执行器实现
-│       └── async_chain_executor.py
-├── orchestration/      # 编排层 - 业务流程编排
-│   └── prompt_chain_processor.py
-├── bootstrap.py         # 初始化层 - 依赖注入容器
-├── main.py            # 入口文件
-├── requirements.txt    # 依赖列表
-└── README.md          # 本文档
-```
-
-### 分层说明
-
-| 层级 | 职责 | 说明 |
-|-----|------|------|
-| **接口层** | 定义契约 | LLMClient、PromptLoader、ChainExecutor 抽象接口 |
-| **实现层** | 具体实现 | 各模型客户端、提示词加载器、链执行器的具体实现 |
-| **编排层** | 流程编排 | PromptChainProcessor 组装组件,定义业务流程 |
-| **初始化层** | 依赖注入 | Bootstrap 容器,统一创建和配置组件 |
-
-## 安装依赖
-
-```bash
-pip install -r requirements.txt
-```
-
-依赖项:
-- `aiohttp>=3.9.0` - 异步 HTTP 客户端
-- `pyyaml>=6.0` - YAML 配置解析
-- `jinja2>=3.1.0` - 模板引擎
-
-## 快速开始
-
-### 1. 配置 LLM API
-
-创建配置文件 `config/llm_api.yaml`:
-
-```yaml
-# 模型类型:qwen/gemini/deepseek/doubao
-MODEL_TYPE: qwen
-
-# 通用配置
-keywords:
-  timeout: 30
-  max_retries: 2
-  request_payload:
-    temperature: 0.3
-    max_tokens: 1024
-
-# 各模型配置
-qwen:
-  server_url: "https://dashscope.aliyuncs.com/compatible-mode/v1"
-  model_id: "qwen-plus"
-  api_key: "your-api-key"
-
-gemini:
-  server_url: "https://generativelanguage.googleapis.com/v1beta"
-  model_id: "gemini-pro"
-  api_key: "your-api-key"
-
-deepseek:
-  server_url: "https://api.deepseek.com"
-  model_id: "deepseek-chat"
-  api_key: "your-api-key"
-
-doubao:
-  server_url: "https://ark.cn-beijing.volces.com/api/v3"
-  model_id: "ep-xxxxx"
-  api_key: "your-api-key"
-```
-
-### 2. 创建提示词
-
-在 `config/prompts/` 目录下创建提示词 YAML 文件:
-
-**step1_extract.yaml**
-```yaml
-name: step1_extract
-description: 提取关键信息
-system: 你是一个信息提取助手,擅长从文本中提取关键信息。
-user_template: |
-  请从以下文本中提取关键信息:
-  
-  文本内容:{{ content }}
-  
-  请以 JSON 格式返回结果。
-output_parser:
-  type: json
-```
-
-### 3. 创建提示链配置
-
-**chain_config.yaml**
-```yaml
-chain_name: 信息提取链
-description: 从文本中提取并分析信息
-steps:
-  - name: extract
-    prompt_file: step1_extract.yaml
-    output_key: extracted_data
-  - name: analyze
-    prompt_file: step2_analyze.yaml
-    input_from: extracted_data
-    output_key: analysis_result
-```
-
-### 4. 使用代码
-
-```python
-import asyncio
-import sys
-from pathlib import Path
-
-# 添加模块路径
-sys.path.insert(0, "path/to/llm_chain_client")
-
-from llm_chain_client.bootstrap import Bootstrap
-
-async def main():
-    # 创建处理器
-    processor = Bootstrap.create_processor(
-        model_type="qwen",              # 可选:指定模型类型
-        prompts_dir="config/prompts",   # 提示词目录
-        config_path="config/llm_api.yaml", # API配置文件
-        temperature=0.5,                # 可选:覆盖默认温度
-        max_tokens=2048                 # 可选:覆盖默认token数
-    )
-    
-    # 执行提示链
-    result = await processor.process(
-        chain_config_path="config/prompts/chain_config.yaml",
-        input_data={
-            "content": "这是待处理的文本内容..."
-        }
-    )
-    
-    # 获取结果
-    print("最终结果:", result["final_result"])
-    print("各步骤结果:")
-    for step in result["steps"]:
-        print(f"  {step['name']}: {step['result']}")
-
-asyncio.run(main())
-```
-
-## 配置说明
-
-### LLM API 配置
-
-配置文件位置可通过 `config_path` 参数指定,默认为 `config/llm_api.yaml`。
-
-| 配置项 | 说明 | 默认值 |
-|-------|------|--------|
-| `MODEL_TYPE` | 默认模型类型 | `qwen` |
-| `keywords.timeout` | 请求超时时间(秒) | `30` |
-| `keywords.max_retries` | 最大重试次数 | `2` |
-| `keywords.request_payload.temperature` | 默认温度参数 | `0.3` |
-| `keywords.request_payload.max_tokens` | 默认最大token数 | `1024` |
-
-### 提示词配置
-
-提示词文件使用 YAML 格式,支持以下字段:
-
-| 字段 | 说明 | 必填 |
-|-----|------|------|
-| `name` | 提示词名称 | 否 |
-| `description` | 提示词描述 | 否 |
-| `system` | 系统提示词 | 否 |
-| `user_template` | 用户提示词模板(支持 Jinja2 语法) | 是 |
-| `output_parser` | 输出解析器配置 | 否 |
-
-输出解析器类型:
-- `text`:返回原始文本(默认)
-- `json`:尝试解析为 JSON 格式
-
-### 提示链配置
-
-提示链配置文件定义步骤序列:
-
-| 字段 | 说明 | 必填 |
-|-----|------|------|
-| `chain_name` | 链名称 | 否 |
-| `description` | 链描述 | 否 |
-| `steps` | 步骤列表 | 是 |
-
-步骤配置:
-| 字段 | 说明 | 必填 |
-|-----|------|------|
-| `name` | 步骤名称 | 否 |
-| `prompt_file` | 提示词文件名 | 是 |
-| `output_key` | 输出结果的键名 | 否 |
-| `input_from` | 从上一步获取输入的键名 | 否 |
-
-## 支持的模型
-
-| 模型 | 标识符 | 说明 |
-|-----|--------|------|
-| 通义千问 | `qwen` | 阿里云大模型 |
-| Gemini | `gemini` | Google 大模型 |
-| DeepSeek | `deepseek` | DeepSeek 大模型 |
-| 豆包 | `doubao` | 字节跳动大模型 |
-
-获取支持的模型列表:
-```python
-from llm_chain_client.bootstrap import Bootstrap
-
-models = Bootstrap.get_supported_models()
-print(models)  # ['qwen', 'gemini', 'deepseek', 'doubao']
-```
-
-## API 文档
-
-### Bootstrap 类
-
-依赖注入容器,用于创建处理器。
-
-#### `create_processor()`
-
-创建提示链处理器实例。
-
-```python
-processor = Bootstrap.create_processor(
-    model_type: str = None,
-    prompts_dir: str = "config/prompts",
-    config_path: str = "config/llm_api.yaml",
-    temperature: float = None,
-    max_tokens: int = None
-) -> PromptChainProcessor
-```
-
-**参数:**
-- `model_type` - 模型类型,为 None 时从配置文件读取
-- `prompts_dir` - 提示词目录路径
-- `config_path` - API 配置文件路径
-- `temperature` - 温度参数(可选,覆盖默认值)
-- `max_tokens` - 最大 token 数(可选,覆盖默认值)
-
-#### `get_supported_models()`
-
-获取支持的模型类型列表。
-
-```python
-models: list[str] = Bootstrap.get_supported_models()
-```
-
-### PromptChainProcessor 类
-
-提示链处理流程编排类。
-
-#### `process()`
-
-执行完整的提示链处理流程。
-
-```python
-result = await processor.process(
-    chain_config_path: str,
-    input_data: Dict[str, Any],
-    temperature: float = None,
-    max_tokens: int = None
-) -> Dict[str, Any]
-```
-
-**参数:**
-- `chain_config_path` - 提示链配置文件路径
-- `input_data` - 输入数据字典
-- `temperature` - 温度参数(可选)
-- `max_tokens` - 最大 token 数(可选)
-
-**返回值:**
-```python
-{
-    "final_result": {...},      # 最终结果
-    "steps": [                  # 各步骤结果
-        {
-            "name": "step1",
-            "prompt_file": "step1.yaml",
-            "result": {...},
-            "raw_content": "...",
-            "usage": {...}
-        },
-        ...
-    ],
-    "context": {...}            # 完整上下文
-}
-```
-
-#### `get_model_info()`
-
-获取当前模型信息。
-
-```python
-info = processor.get_model_info()
-# {"model_id": "qwen-plus", "server_url": "https://..."}
-```
-
-## 独立使用说明
-
-本模块设计为完全独立可用,可在任何 Python 项目中复用。
-
-### 方式一:直接复制
-
-将 `llm_chain_client/` 目录复制到你的项目中:
-
-```python
-import sys
-from pathlib import Path
-
-# 添加模块路径
-sys.path.insert(0, str(Path(__file__).parent / "llm_chain_client"))
-
-from llm_chain_client.bootstrap import Bootstrap
-```
-
-### 方式二:作为包安装
-
-创建 `setup.py` 后安装:
-
-```bash
-pip install -e path/to/llm_chain_client
-```
-
-然后在代码中直接导入:
-
-```python
-from llm_chain_client.bootstrap import Bootstrap
-```
-
-## 扩展开发
-
-### 添加新的 LLM 客户端
-
-1. 在 `implementations/clients/` 下创建新文件
-2. 继承 `BaseLLMClient` 类
-3. 实现必要的配置解析逻辑
-4. 在 `bootstrap.py` 的 `_CLIENT_MAP` 中注册
-
-示例:
-
-```python
-from implementations.clients.base_client import BaseLLMClient
-
-class CustomClient(BaseLLMClient):
-    def __init__(self, config: Dict[str, Any]):
-        super().__init__(
-            server_url=config["server_url"],
-            model_id=config["model_id"],
-            api_key=config["api_key"]
-        )
-```
-
-### 添加新的提示词加载器
-
-1. 在 `implementations/loaders/` 下创建新文件
-2. 继承 `PromptLoader` 接口
-3. 实现抽象方法
-
-## 注意事项
-
-1. **API 密钥安全**:不要将包含真实 API 密钥的配置文件提交到版本控制系统
-2. **超时设置**:根据网络情况调整 `timeout` 参数
-3. **重试机制**:默认重试 2 次,可根据需要调整
-4. **Token 限制**:注意各模型的 token 限制,合理设置 `max_tokens`
-
-## 许可证
-
-本项目为内部组件,仅供项目内部使用。
-
-## 联系方式
-
-如有问题或建议,请联系项目维护者。

+ 0 - 36
core/construction_review/component/reviewers/utils/llm_chain_client/__init__.py

@@ -1,36 +0,0 @@
-"""
-LLM 提示链系统 - 异步调用大模型API进行提示链任务
-
-架构分层说明:
-
-1. 接口层 (interfaces/)
-   - 定义组件契约:LLMClient、PromptLoader、ChainExecutor
-   - 实现解耦和可扩展性
-
-2. 实现层 (implementations/)
-   - clients/: 各模型API客户端实现(Qwen、Gemini、DeepSeek、Doubao)
-   - loaders/: 提示词加载器实现(YAML格式)
-   - chains/: 提示链执行器实现
-   - 按功能分类,内聚存放
-
-3. 编排层 (orchestration/)
-   - PromptChainProcessor: 组装组件,定义业务流程
-   - 通过接口进行依赖注入
-
-4. 初始化层 (bootstrap.py)
-   - Bootstrap 容器
-   - 统一的依赖注入和实例化
-   - 工厂方法创建不同配置的处理器
-
-5. 入口层 (main.py)
-   - 应用启动点
-   - 通过 Bootstrap 创建处理器并执行
-
-核心优势:
-  ✓ 完全解耦:组件通过接口交互
-  ✓ 易于扩展:新增实现无需修改现有代码
-  ✓ 易于测试:可轻松替换为 Mock 实现
-  ✓ 职责清晰:各层职责明确,便于维护
-  ✓ 异步支持:支持高并发API调用
-  ✓ 配置驱动:提示词和链配置通过YAML管理
-"""

+ 0 - 256
core/construction_review/component/reviewers/utils/llm_chain_client/bootstrap.py

@@ -1,256 +0,0 @@
-"""初始化层 - 依赖注入和启动"""
-from typing import Dict, Any
-
-from .interfaces.llm_client import LLMClient
-from .interfaces.prompt_loader import PromptLoader
-from .interfaces.chain_executor import ChainExecutor
-
-from .implementations.clients import (
-    QwenClient,
-    GeminiClient,
-    DeepSeekClient,
-    DoubaoClient
-)
-from .implementations.loaders import YamlPromptLoader
-from .implementations.chains import AsyncChainExecutor
-from .orchestration import PromptChainProcessor
-from foundation.infrastructure.config.config import config_handler
-
-
-class Bootstrap:
-    """启动和依赖注入容器"""
-
-    # 模型客户端映射
-    _CLIENT_MAP = {
-        "qwen": QwenClient,
-        "gemini": GeminiClient,
-        "deepseek": DeepSeekClient,
-        "doubao": DoubaoClient
-    }
-
-    @staticmethod
-    def _load_llm_config(model_type: str = None) -> Dict[str, Any]:
-        """
-        加载大模型API配置(从 config.ini)
-
-        Args:
-            model_type: 模型类型,如果为None则从配置文件读取默认值
-
-        Returns:
-            配置字典
-        """
-        # 获取模型类型(优先从 model_setting.yaml 读取默认配置)
-        if model_type is None:
-            try:
-                from foundation.ai.models.model_config_loader import get_model_for_function
-                model_type = get_model_for_function("default")
-                if model_type:
-                    logger.debug(f"LLMChainClient 从 model_setting.yaml 读取默认模型: {model_type}")
-                else:
-                    model_type = config_handler.get("model", "MODEL_TYPE", "qwen3_5_35b_a3b")
-            except Exception as e:
-                logger.debug(f"LLMChainClient 从 model_setting.yaml 读取失败: {e},回退到 config.ini")
-                model_type = config_handler.get("model", "MODEL_TYPE", "qwen3_5_35b_a3b")
-
-        model_type = model_type.lower()
-
-        # 构建 DashScope 风格的配置(兼容现有客户端)
-        if model_type.startswith("qwen"):
-            server_url = config_handler.get(model_type, "DASHSCOPE_SERVER_URL", "")
-            model_id = config_handler.get(model_type, "DASHSCOPE_MODEL_ID", "")
-            api_key = config_handler.get(model_type, "DASHSCOPE_API_KEY", "")
-
-            # 如果没有 DashScope 配置,尝试读取 QWEN_SERVER_URL 等旧格式
-            if not server_url:
-                server_url = config_handler.get(model_type, f"{model_type.upper()}_SERVER_URL", "")
-                model_id = config_handler.get(model_type, f"{model_type.upper()}_MODEL_ID", "")
-                api_key = config_handler.get(model_type, f"{model_type.upper()}_API_KEY", "")
-
-            config = {
-                "QWEN_SERVER_URL": server_url,
-                "QWEN_MODEL_ID": model_id,
-                "QWEN_API_KEY": api_key,
-            }
-        elif model_type == "gemini":
-            config = {
-                "GEMINI_SERVER_URL": config_handler.get("gemini", "GEMINI_SERVER_URL", ""),
-                "GEMINI_MODEL_ID": config_handler.get("gemini", "GEMINI_MODEL_ID", ""),
-                "GEMINI_API_KEY": config_handler.get("gemini", "GEMINI_API_KEY", ""),
-            }
-        elif model_type == "deepseek":
-            config = {
-                "DEEPSEEK_SERVER_URL": config_handler.get("deepseek", "DEEPSEEK_SERVER_URL", ""),
-                "DEEPSEEK_MODEL_ID": config_handler.get("deepseek", "DEEPSEEK_MODEL_ID", ""),
-                "DEEPSEEK_API_KEY": config_handler.get("deepseek", "DEEPSEEK_API_KEY", ""),
-            }
-        elif model_type == "doubao":
-            config = {
-                "DOUBAO_SERVER_URL": config_handler.get("doubao", "DOUBAO_SERVER_URL", ""),
-                "DOUBAO_MODEL_ID": config_handler.get("doubao", "DOUBAO_MODEL_ID", ""),
-                "DOUBAO_API_KEY": config_handler.get("doubao", "DOUBAO_API_KEY", ""),
-            }
-        elif model_type.startswith("shutian"):
-            # 蜀天模型系列
-            server_url = config_handler.get("shutian", "SHUTIAN_35B_SERVER_URL", "")
-            model_id = config_handler.get("shutian", "SHUTIAN_35B_MODEL_ID", "")
-            api_key = config_handler.get("shutian", "SHUTIAN_35B_API_KEY", "")
-
-            # 根据具体模型类型选择不同端口
-            if "122b" in model_type:
-                server_url = config_handler.get("shutian", "SHUTIAN_122B_SERVER_URL", server_url)
-                model_id = config_handler.get("shutian", "SHUTIAN_122B_MODEL_ID", model_id)
-            elif "8b" in model_type and "35b" not in model_type:
-                server_url = config_handler.get("shutian", "SHUTIAN_8B_SERVER_URL", server_url)
-                model_id = config_handler.get("shutian", "SHUTIAN_8B_MODEL_ID", model_id)
-
-            config = {
-                "QWEN_SERVER_URL": server_url,
-                "QWEN_MODEL_ID": model_id,
-                "QWEN_API_KEY": api_key,
-            }
-        else:
-            raise ValueError(f"不支持的模型类型: {model_type}")
-
-        # 添加通用配置
-        config["timeout"] = int(config_handler.get("llm_keywords", "TIMEOUT", "60"))
-        config["max_retries"] = int(config_handler.get("llm_keywords", "MAX_RETRIES", "2"))
-
-        return config
-
-    @staticmethod
-    def _create_llm_client(model_type: str, config: Dict[str, Any] = None) -> LLMClient:
-        """
-        创建大模型客户端
-
-        Args:
-            model_type: 模型类型(qwen/gemini/deepseek/doubao)
-            config: 配置字典(可选,如果不提供则从 config.ini 加载)
-
-        Returns:
-            大模型客户端实例
-        """
-        model_type_lower = model_type.lower()
-
-        # 将 qwen3_5_xx 和 shutian 类型映射为 qwen 客户端(都是OpenAI兼容API)
-        client_type = model_type_lower
-        if model_type_lower.startswith("qwen") or model_type_lower.startswith("lq_qwen") or model_type_lower.startswith("shutian"):
-            client_type = "qwen"
-
-        if client_type not in Bootstrap._CLIENT_MAP:
-            raise ValueError(
-                f"不支持的模型类型: {model_type},"
-                f"支持的类型: {', '.join(Bootstrap._CLIENT_MAP.keys())}"
-            )
-
-        # 如果没有传入配置,从 config.ini 加载
-        if config is None:
-            config = Bootstrap._load_llm_config(model_type_lower)
-
-        # 创建客户端
-        client_class = Bootstrap._CLIENT_MAP[client_type]
-        return client_class(config)
-
-    @staticmethod
-    def _create_prompt_loader(
-        prompts_dir: str = "config/prompts"
-    ) -> PromptLoader:
-        """
-        创建提示词加载器
-
-        Args:
-            prompts_dir: 提示词目录路径
-
-        Returns:
-            提示词加载器实例
-        """
-        return YamlPromptLoader(prompts_dir)
-
-    @staticmethod
-    def _create_chain_executor(
-        llm_client: LLMClient,
-        prompt_loader: PromptLoader,
-        temperature: float = None,
-        max_tokens: int = None
-    ) -> ChainExecutor:
-        """
-        创建提示链执行器
-
-        Args:
-            llm_client: 大模型客户端
-            prompt_loader: 提示词加载器
-            temperature: 温度参数(可选)
-            max_tokens: 最大token数(可选)
-
-        Returns:
-            提示链执行器实例
-        """
-        # 从 config.ini 读取默认值
-        default_temperature = temperature or float(config_handler.get("llm_keywords", "TEMPERATURE", "0.3"))
-        default_max_tokens = max_tokens or int(config_handler.get("llm_keywords", "MAX_TOKENS", "1024"))
-
-        return AsyncChainExecutor(
-            llm_client=llm_client,
-            prompt_loader=prompt_loader,
-            default_temperature=default_temperature,
-            default_max_tokens=default_max_tokens
-        )
-
-    @staticmethod
-    def create_processor(
-        model_type: str = None,
-        prompts_dir: str = "config/prompts",
-        temperature: float = None,
-        max_tokens: int = None
-    ) -> PromptChainProcessor:
-        """
-        创建提示链处理器
-
-        Args:
-            model_type: 模型类型(qwen/gemini/deepseek/doubao),
-                       如果为None则从配置文件读取
-            prompts_dir: 提示词目录路径
-            temperature: 温度参数(可选)
-            max_tokens: 最大token数(可选)
-
-        Returns:
-            提示链处理器实例
-        """
-        # 确定模型类型(优先从 model_setting.yaml 读取默认配置)
-        if model_type is None:
-            try:
-                from foundation.ai.models.model_config_loader import get_model_for_function
-                model_type = get_model_for_function("default")
-                if model_type:
-                    logger.debug(f"PromptChainProcessor 从 model_setting.yaml 读取默认模型: {model_type}")
-                else:
-                    model_type = config_handler.get("model", "MODEL_TYPE", "qwen3_5_35b_a3b")
-            except Exception as e:
-                logger.debug(f"PromptChainProcessor 从 model_setting.yaml 读取失败: {e},回退到 config.ini")
-                model_type = config_handler.get("model", "MODEL_TYPE", "qwen3_5_35b_a3b")
-
-        # 创建组件
-        llm_client = Bootstrap._create_llm_client(model_type)
-        prompt_loader = Bootstrap._create_prompt_loader(prompts_dir)
-        chain_executor = Bootstrap._create_chain_executor(
-            llm_client,
-            prompt_loader,
-            temperature,
-            max_tokens
-        )
-
-        # 创建处理器
-        return PromptChainProcessor(
-            llm_client=llm_client,
-            prompt_loader=prompt_loader,
-            chain_executor=chain_executor
-        )
-
-    @staticmethod
-    def get_supported_models() -> list[str]:
-        """
-        获取支持的模型类型列表
-
-        Returns:
-            模型类型列表
-        """
-        return list(Bootstrap._CLIENT_MAP.keys())

+ 0 - 1
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/__init__.py

@@ -1 +0,0 @@
-"""实现层 - 具体实现"""

+ 0 - 4
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/chains/__init__.py

@@ -1,4 +0,0 @@
-"""实现层 - 提示链执行器"""
-from .async_chain_executor import AsyncChainExecutor
-
-__all__ = ["AsyncChainExecutor"]

+ 0 - 178
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/chains/async_chain_executor.py

@@ -1,178 +0,0 @@
-"""实现层 - 异步提示链执行器"""
-import json
-import logging
-from typing import Dict, Any, List
-from ...interfaces.llm_client import LLMClient
-from ...interfaces.prompt_loader import PromptLoader
-from ...interfaces.chain_executor import ChainExecutor
-
-
-logger = logging.getLogger(__name__)
-
-
-class AsyncChainExecutor(ChainExecutor):
-    """异步提示链执行器"""
-
-    def __init__(
-        self,
-        llm_client: LLMClient,
-        prompt_loader: PromptLoader,
-        default_temperature: float = 0.3,
-        default_max_tokens: int = 1024
-    ):
-        """
-        初始化执行器
-
-        Args:
-            llm_client: 大模型API客户端
-            prompt_loader: 提示词加载器
-            default_temperature: 默认温度参数
-            default_max_tokens: 默认最大token数
-        """
-        self.llm_client = llm_client
-        self.prompt_loader = prompt_loader
-        self.default_temperature = default_temperature
-        self.default_max_tokens = default_max_tokens
-
-    async def execute_chain(
-        self,
-        chain_steps: List[Dict[str, Any]],
-        initial_input: Dict[str, Any]
-    ) -> Dict[str, Any]:
-        """
-        执行提示链
-
-        Args:
-            chain_steps: 提示链步骤列表
-            initial_input: 初始输入变量
-
-        Returns:
-            最终结果和中间结果
-        """
-        # 存储所有步骤的结果
-        step_results = []
-        context = initial_input.copy()
-
-        for step in chain_steps:
-            step_name = step.get("name", "unknown")
-            prompt_file = step.get("prompt_file", "")
-            output_key = step.get("output_key", "")
-            input_from = step.get("input_from", None)
-
-            logger.info(f"执行步骤: {step_name}")
-
-            # 加载提示词
-            prompt_data = self.prompt_loader.load_prompt(prompt_file)
-
-            # 准备变量
-            variables = context.copy()
-
-            # 如果指定了从上一步获取输入
-            if input_from and input_from in context:
-                variables["input"] = context[input_from]
-
-            # 渲染用户提示词
-            user_prompt = self.prompt_loader.render_template(
-                prompt_data["user_template"],
-                variables
-            )
-
-            # 构建消息列表
-            messages = []
-            if prompt_data["system"]:
-                messages.append({"role": "system", "content": prompt_data["system"]})
-            messages.append({"role": "user", "content": user_prompt})
-            # # 【推荐位置】在这里添加保存代码
-            # with open('prompts.txt', 'w', encoding='utf-8') as f:
-            #     contents = [msg["content"] for msg in messages]
-            #     f.write('\n'.join(contents))
-
-            # 调用API
-            response = await self.llm_client.chat_completion(
-                messages,
-                temperature=self.default_temperature,
-                max_tokens=self.default_max_tokens
-            )
-
-            # 解析响应
-            content = response.get("content", "")
-            parsed_result = self._parse_response(content, prompt_data.get("output_parser", {}))
-
-            # 存储结果
-            step_result = {
-                "name": step_name,
-                "prompt_file": prompt_file,
-                "result": parsed_result,
-                "raw_content": content,
-                "usage": response.get("usage", {})
-            }
-            step_results.append(step_result)
-
-            # 更新上下文
-            if output_key:
-                context[output_key] = parsed_result
-
-        # 返回最终结果
-        final_result = step_results[-1]["result"] if step_results else {}
-
-        return {
-            "final_result": final_result,
-            "steps": step_results,
-            "context": context
-        }
-
-    def _parse_response(
-        self,
-        content: str,
-        output_parser: Dict[str, Any]
-    ) -> Any:
-        """
-        解析API响应
-
-        Args:
-            content: API返回的内容
-            output_parser: 输出解析器配置
-
-        Returns:
-            解析后的结果
-        """
-        parser_type = output_parser.get("type", "text")
-
-        if parser_type == "json":
-            try:
-                # 尝试提取JSON(可能包含在代码块中)
-                json_content = self._extract_json(content)
-                return json.loads(json_content)
-            except json.JSONDecodeError as e:
-                logger.warning(f"JSON解析失败: {e}, 返回原始内容")
-                return content
-        else:
-            return content
-
-    def _extract_json(self, content: str) -> str:
-        """
-        从内容中提取JSON
-
-        Args:
-            content: 可能包含JSON的文本
-
-        Returns:
-            提取的JSON字符串
-        """
-        # 尝试找到JSON代码块
-        import re
-
-        # 匹配 ```json ... ``` 格式
-        json_pattern = r'```json\s*(.*?)\s*```'
-        match = re.search(json_pattern, content, re.DOTALL)
-        if match:
-            return match.group(1)
-
-        # 匹配 ``` ... ``` 格式
-        code_pattern = r'```\s*(.*?)\s*```'
-        match = re.search(code_pattern, content, re.DOTALL)
-        if match:
-            return match.group(1)
-
-        # 如果没有找到代码块,尝试匹配整个内容
-        return content.strip()

+ 0 - 14
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/__init__.py

@@ -1,14 +0,0 @@
-"""实现层 - 各模型API客户端"""
-from .base_client import BaseLLMClient
-from .qwen_client import QwenClient
-from .gemini_client import GeminiClient
-from .deepseek_client import DeepSeekClient
-from .doubao_client import DoubaoClient
-
-__all__ = [
-    "BaseLLMClient",
-    "QwenClient",
-    "GeminiClient",
-    "DeepSeekClient",
-    "DoubaoClient"
-]

+ 0 - 129
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/base_client.py

@@ -1,129 +0,0 @@
-"""实现层 - 大模型API客户端基类"""
-import aiohttp
-from typing import Dict, Any
-from ...interfaces.llm_client import LLMClient
-
-
-class BaseLLMClient(LLMClient):
-    """大模型API客户端基类"""
-
-    def __init__(
-        self,
-        server_url: str,
-        model_id: str,
-        api_key: str,
-        timeout: int = 30,
-        max_retries: int = 2
-    ):
-        """
-        初始化客户端
-
-        Args:
-            server_url: 服务器URL
-            model_id: 模型ID
-            api_key: API密钥
-            timeout: 超时时间(秒)
-            max_retries: 最大重试次数
-        """
-        self.server_url = server_url.rstrip("/")
-        self.model_id = model_id
-        self.api_key = api_key
-        self.timeout = timeout
-        self.max_retries = max_retries
-
-    def get_model_id(self) -> str:
-        """获取模型ID"""
-        return self.model_id
-
-    def get_server_url(self) -> str:
-        """获取服务器URL"""
-        return self.server_url
-
-    def _get_headers(self) -> Dict[str, str]:
-        """获取请求头"""
-        return {
-            "Content-Type": "application/json",
-            "Authorization": f"Bearer {self.api_key}"
-        }
-
-    def _build_request_body(
-        self,
-        messages: list[Dict[str, str]],
-        **kwargs
-    ) -> Dict[str, Any]:
-        """
-        构建请求体
-
-        Args:
-            messages: 消息列表
-            **kwargs: 额外参数
-
-        Returns:
-            请求体字典
-        """
-        body = {
-            "model": self.model_id,
-            "messages": messages
-        }
-
-        # 添加可选参数
-        if "temperature" in kwargs:
-            body["temperature"] = kwargs["temperature"]
-        if "max_tokens" in kwargs:
-            body["max_tokens"] = kwargs["max_tokens"]
-        if "stream" in kwargs:
-            body["stream"] = kwargs["stream"]
-
-        return body
-
-    async def chat_completion(
-        self,
-        messages: list[Dict[str, str]],
-        **kwargs
-    ) -> Dict[str, Any]:
-        """
-        异步调用聊天补全API
-
-        Args:
-            messages: 消息列表
-            **kwargs: 额外参数
-
-        Returns:
-            API响应结果
-        """
-        url = f"{self.server_url}/chat/completions"
-        headers = self._get_headers()
-        body = self._build_request_body(messages, **kwargs)
-
-        timeout = aiohttp.ClientTimeout(total=self.timeout)
-
-        for attempt in range(self.max_retries + 1):
-            try:
-                async with aiohttp.ClientSession(timeout=timeout) as session:
-                    async with session.post(url, json=body, headers=headers) as response:
-                        response.raise_for_status()
-                        data = await response.json()
-
-                        # 解析响应
-                        if "choices" in data and len(data["choices"]) > 0:
-                            content = data["choices"][0].get("message", {}).get("content", "")
-                            return {
-                                "content": content,
-                                "usage": data.get("usage", {}),
-                                "model": data.get("model", self.model_id),
-                                "raw_response": data
-                            }
-                        else:
-                            return {
-                                "content": "",
-                                "usage": {},
-                                "model": self.model_id,
-                                "raw_response": data
-                            }
-
-            except aiohttp.ClientError as e:
-                if attempt == self.max_retries:
-                    raise RuntimeError(f"API调用失败(重试{self.max_retries}次后): {e}")
-                continue
-            except Exception as e:
-                raise RuntimeError(f"API调用异常: {e}")

+ 0 - 22
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/deepseek_client.py

@@ -1,22 +0,0 @@
-"""实现层 - DeepSeek模型客户端"""
-from typing import Dict, Any
-from .base_client import BaseLLMClient
-
-
-class DeepSeekClient(BaseLLMClient):
-    """DeepSeek模型客户端"""
-
-    def __init__(self, config: Dict[str, Any]):
-        """
-        初始化DeepSeek客户端
-
-        Args:
-            config: 配置字典,包含 DEEPSEEK_SERVER_URL, DEEPSEEK_MODEL_ID, DEEPSEEK_API_KEY
-        """
-        super().__init__(
-            server_url=config.get("DEEPSEEK_SERVER_URL", ""),
-            model_id=config.get("DEEPSEEK_MODEL_ID", ""),
-            api_key=config.get("DEEPSEEK_API_KEY", ""),
-            timeout=config.get("timeout", 30),
-            max_retries=config.get("max_retries", 2)
-        )

+ 0 - 22
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/doubao_client.py

@@ -1,22 +0,0 @@
-"""实现层 - Doubao模型客户端"""
-from typing import Dict, Any
-from .base_client import BaseLLMClient
-
-
-class DoubaoClient(BaseLLMClient):
-    """Doubao模型客户端"""
-
-    def __init__(self, config: Dict[str, Any]):
-        """
-        初始化Doubao客户端
-
-        Args:
-            config: 配置字典,包含 DOUBAO_SERVER_URL, DOUBAO_MODEL_ID, DOUBAO_API_KEY
-        """
-        super().__init__(
-            server_url=config.get("DOUBAO_SERVER_URL", ""),
-            model_id=config.get("DOUBAO_MODEL_ID", ""),
-            api_key=config.get("DOUBAO_API_KEY", ""),
-            timeout=config.get("timeout", 30),
-            max_retries=config.get("max_retries", 2)
-        )

+ 0 - 22
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/gemini_client.py

@@ -1,22 +0,0 @@
-"""实现层 - Gemini模型客户端"""
-from typing import Dict, Any
-from .base_client import BaseLLMClient
-
-
-class GeminiClient(BaseLLMClient):
-    """Gemini模型客户端"""
-
-    def __init__(self, config: Dict[str, Any]):
-        """
-        初始化Gemini客户端
-
-        Args:
-            config: 配置字典,包含 GEMINI_SERVER_URL, GEMINI_MODEL_ID, GEMINI_API_KEY
-        """
-        super().__init__(
-            server_url=config.get("GEMINI_SERVER_URL", ""),
-            model_id=config.get("GEMINI_MODEL_ID", ""),
-            api_key=config.get("GEMINI_API_KEY", ""),
-            timeout=config.get("timeout", 30),
-            max_retries=config.get("max_retries", 2)
-        )

+ 0 - 22
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/clients/qwen_client.py

@@ -1,22 +0,0 @@
-"""实现层 - Qwen模型客户端"""
-from typing import Dict, Any
-from .base_client import BaseLLMClient
-
-
-class QwenClient(BaseLLMClient):
-    """Qwen模型客户端"""
-
-    def __init__(self, config: Dict[str, Any]):
-        """
-        初始化Qwen客户端
-
-        Args:
-            config: 配置字典,包含 QWEN_SERVER_URL, QWEN_MODEL_ID, QWEN_API_KEY
-        """
-        super().__init__(
-            server_url=config.get("QWEN_SERVER_URL", ""),
-            model_id=config.get("QWEN_MODEL_ID", ""),
-            api_key=config.get("QWEN_API_KEY", ""),
-            timeout=config.get("timeout", 30),
-            max_retries=config.get("max_retries", 2)
-        )

+ 0 - 4
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/loaders/__init__.py

@@ -1,4 +0,0 @@
-"""实现层 - 提示词加载器"""
-from .yaml_prompt_loader import YamlPromptLoader
-
-__all__ = ["YamlPromptLoader"]

+ 0 - 105
core/construction_review/component/reviewers/utils/llm_chain_client/implementations/loaders/yaml_prompt_loader.py

@@ -1,105 +0,0 @@
-"""实现层 - YAML提示词加载器"""
-import yaml
-import os
-from pathlib import Path
-from typing import Dict, Any
-from jinja2 import Template
-from ...interfaces.prompt_loader import PromptLoader
-
-
-class YamlPromptLoader(PromptLoader):
-    """YAML提示词加载器"""
-
-    def __init__(self, prompts_dir: str = "config/prompts"):
-        """
-        初始化加载器
-
-        Args:
-            prompts_dir: 提示词目录路径
-        """
-        self.prompts_dir = Path(prompts_dir)
-        self._prompt_cache: Dict[str, Dict[str, str]] = {}
-
-    def load_prompt(self, prompt_name: str) -> Dict[str, str]:
-        """
-        加载提示词
-
-        Args:
-            prompt_name: 提示词名称(对应YAML文件名,不含扩展名)
-
-        Returns:
-            包含 system 和 user_template 的字典
-        """
-        # 检查缓存
-        if prompt_name in self._prompt_cache:
-            return self._prompt_cache[prompt_name]
-
-        # 构建文件路径
-        prompt_file = self.prompts_dir / f"{prompt_name}.yaml"
-
-        if not prompt_file.exists():
-            raise FileNotFoundError(f"提示词文件不存在: {prompt_file}")
-
-        # 加载YAML文件
-        with open(prompt_file, "r", encoding="utf-8") as f:
-            data = yaml.safe_load(f)
-
-        # 解析提示词
-        result = {
-            "name": data.get("name", prompt_name),
-            "description": data.get("description", ""),
-            "system": data.get("system", ""),
-            "user_template": data.get("user_template", ""),
-            "output_parser": data.get("output_parser", {})
-        }
-
-        # 缓存结果
-        self._prompt_cache[prompt_name] = result
-
-        return result
-
-    def render_template(
-        self,
-        template: str,
-        variables: Dict[str, Any]
-    ) -> str:
-        """
-        渲染提示词模板
-
-        Args:
-            template: 模板字符串,支持 {{ variable }} 语法
-            variables: 变量字典
-
-        Returns:
-            渲染后的字符串
-        """
-        jinja_template = Template(template)
-        return jinja_template.render(**variables)
-
-    def load_chain_config(self, config_path: str) -> Dict[str, Any]:
-        """
-        加载提示链配置
-
-        Args:
-            config_path: 配置文件路径
-
-        Returns:
-            提示链配置
-        """
-        config_file = Path(config_path)
-
-        if not config_file.exists():
-            raise FileNotFoundError(f"提示链配置文件不存在: {config_file}")
-
-        with open(config_file, "r", encoding="utf-8") as f:
-            data = yaml.safe_load(f)
-
-        return {
-            "chain_name": data.get("chain_name", ""),
-            "description": data.get("description", ""),
-            "steps": data.get("steps", [])
-        }
-
-    def clear_cache(self):
-        """清除缓存"""
-        self._prompt_cache.clear()

+ 0 - 6
core/construction_review/component/reviewers/utils/llm_chain_client/interfaces/__init__.py

@@ -1,6 +0,0 @@
-"""接口层 - 定义组件契约"""
-from .llm_client import LLMClient
-from .prompt_loader import PromptLoader
-from .chain_executor import ChainExecutor
-
-__all__ = ["LLMClient", "PromptLoader", "ChainExecutor"]

+ 0 - 46
core/construction_review/component/reviewers/utils/llm_chain_client/interfaces/chain_executor.py

@@ -1,46 +0,0 @@
-"""接口层 - 提示链执行接口"""
-from abc import ABC, abstractmethod
-from typing import Dict, Any, List
-
-
-class ChainExecutor(ABC):
-    """提示链执行接口"""
-
-    @abstractmethod
-    async def execute_chain(
-        self,
-        chain_steps: List[Dict[str, Any]],
-        initial_input: Dict[str, Any]
-    ) -> Dict[str, Any]:
-        """
-        执行提示链
-
-        Args:
-            chain_steps: 提示链步骤列表
-                [
-                    {
-                        "name": "step1_extract",
-                        "prompt_file": "step1_extract.yaml",
-                        "output_key": "extracted_data"
-                    },
-                    {
-                        "name": "step2_analyze",
-                        "prompt_file": "step2_analyze.yaml",
-                        "input_from": "extracted_data",
-                        "output_key": "analysis_result"
-                    }
-                ]
-            initial_input: 初始输入变量
-                {"content": "待处理文本...", "other_var": "value"}
-
-        Returns:
-            最终结果和中间结果
-            {
-                "final_result": {...},
-                "steps": [
-                    {"name": "step1_extract", "result": {...}},
-                    {"name": "step2_analyze", "result": {...}}
-                ]
-            }
-        """
-        pass

+ 0 - 35
core/construction_review/component/reviewers/utils/llm_chain_client/interfaces/llm_client.py

@@ -1,35 +0,0 @@
-"""接口层 - 大模型API调用接口"""
-from abc import ABC, abstractmethod
-from typing import Dict, Any
-
-
-class LLMClient(ABC):
-    """大模型API调用接口"""
-
-    @abstractmethod
-    async def chat_completion(
-        self,
-        messages: list[Dict[str, str]],
-        **kwargs
-    ) -> Dict[str, Any]:
-        """
-        异步调用聊天补全API
-
-        Args:
-            messages: 消息列表,格式为 [{"role": "user", "content": "..."}]
-            **kwargs: 额外参数(temperature, max_tokens等)
-
-        Returns:
-            API响应结果,包含 content, usage 等字段
-        """
-        pass
-
-    @abstractmethod
-    def get_model_id(self) -> str:
-        """获取模型ID"""
-        pass
-
-    @abstractmethod
-    def get_server_url(self) -> str:
-        """获取服务器URL"""
-        pass

+ 0 - 62
core/construction_review/component/reviewers/utils/llm_chain_client/interfaces/prompt_loader.py

@@ -1,62 +0,0 @@
-"""接口层 - 提示词加载接口"""
-from abc import ABC, abstractmethod
-from typing import Dict, Any
-
-
-class PromptLoader(ABC):
-    """提示词加载接口"""
-
-    @abstractmethod
-    def load_prompt(self, prompt_name: str) -> Dict[str, str]:
-        """
-        加载提示词
-
-        Args:
-            prompt_name: 提示词名称(对应YAML文件名,不含扩展名)
-
-        Returns:
-            包含 system 和 user_template 的字典
-            {
-                "system": "系统提示词",
-                "user_template": "用户提示词模板",
-                "description": "提示词描述",
-                "name": "提示词名称"
-            }
-        """
-        pass
-
-    @abstractmethod
-    def render_template(
-        self,
-        template: str,
-        variables: Dict[str, Any]
-    ) -> str:
-        """
-        渲染提示词模板
-
-        Args:
-            template: 模板字符串,支持 {{ variable }} 语法
-            variables: 变量字典
-
-        Returns:
-            渲染后的字符串
-        """
-        pass
-
-    @abstractmethod
-    def load_chain_config(self, config_path: str) -> Dict[str, Any]:
-        """
-        加载提示链配置
-
-        Args:
-            config_path: 配置文件路径
-
-        Returns:
-            提示链配置,包含 steps 列表
-            {
-                "chain_name": "链名称",
-                "description": "描述",
-                "steps": [...]
-            }
-        """
-        pass

+ 0 - 68
core/construction_review/component/reviewers/utils/llm_chain_client/main.py

@@ -1,68 +0,0 @@
-"""入口文件 - 应用启动"""
-import asyncio
-import logging
-from .bootstrap import Bootstrap
-
-
-# 配置日志
-logging.basicConfig(
-    level=logging.INFO,
-    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
-)
-logger = logging.getLogger(__name__)
-
-
-async def main():
-    """主程序"""
-
-    # 打印支持的模型类型
-    supported_models = Bootstrap.get_supported_models()
-    logger.info(f"支持的模型类型: {', '.join(supported_models)}")
-
-    # 创建处理器(使用配置文件中的默认模型)
-    processor = Bootstrap.create_processor()
-
-    # 打印模型信息
-    model_info = processor.get_model_info()
-    logger.info(f"当前模型: {model_info['model_id']}")
-    logger.info(f"服务器地址: {model_info['server_url']}")
-
-    # 示例:执行提示链
-    # 注意:需要先创建相应的配置文件和提示词文件
-    try:
-        result = await processor.process(
-            chain_config_path="config/prompts/chain_config.yaml",
-            input_data={
-                "content": "这是一段待处理的文本内容..."
-            }
-        )
-
-        logger.info("处理完成")
-        logger.info(f"最终结果: {result['final_result']}")
-
-        # 打印各步骤结果
-        for step in result['steps']:
-            logger.info(f"步骤 {step['name']}: {step['result']}")
-
-    except FileNotFoundError as e:
-        logger.warning(f"配置文件未找到: {e}")
-        logger.info("请先创建 config/prompts/ 目录和相应的配置文件")
-    except Exception as e:
-        logger.error(f"处理失败: {e}")
-
-
-async def example_custom_model():
-    """使用自定义模型的示例"""
-    # 创建使用特定模型的处理器
-    processor = Bootstrap.create_processor(
-        model_type="qwen",  # 可选: qwen, gemini, deepseek, doubao
-        temperature=0.5,      # 可选: 覆盖默认温度
-        max_tokens=2048      # 可选: 覆盖默认最大token数
-    )
-
-    logger.info(f"使用模型: {processor.get_model_info()['model_id']}")
-
-
-if __name__ == "__main__":
-    # 运行主程序
-    asyncio.run(main())

+ 0 - 4
core/construction_review/component/reviewers/utils/llm_chain_client/orchestration/__init__.py

@@ -1,4 +0,0 @@
-"""编排层 - 业务流程编排"""
-from .prompt_chain_processor import PromptChainProcessor
-
-__all__ = ["PromptChainProcessor"]

+ 0 - 86
core/construction_review/component/reviewers/utils/llm_chain_client/orchestration/prompt_chain_processor.py

@@ -1,86 +0,0 @@
-"""编排层 - 提示链处理流程编排"""
-import json
-import logging
-from typing import Dict, Any
-from ..interfaces.llm_client import LLMClient
-from ..interfaces.prompt_loader import PromptLoader
-from ..interfaces.chain_executor import ChainExecutor
-
-
-logger = logging.getLogger(__name__)
-
-
-class PromptChainProcessor:
-    """提示链处理流程编排类"""
-
-    def __init__(
-        self,
-        llm_client: LLMClient,
-        prompt_loader: PromptLoader,
-        chain_executor: ChainExecutor
-    ):
-        """
-        初始化处理器
-
-        Args:
-            llm_client: 大模型API客户端
-            prompt_loader: 提示词加载器
-            chain_executor: 提示链执行器
-        """
-        self.llm_client = llm_client
-        self.prompt_loader = prompt_loader
-        self.chain_executor = chain_executor
-
-    async def process(
-        self,
-        chain_config_path: str,
-        input_data: Dict[str, Any],
-        temperature: float = None,
-        max_tokens: int = None
-    ) -> Dict[str, Any]:
-        """
-        执行完整的提示链处理流程
-
-        Args:
-            chain_config_path: 提示链配置文件路径
-            input_data: 输入数据
-            temperature: 温度参数(可选,覆盖默认值)
-            max_tokens: 最大token数(可选,覆盖默认值)
-
-        Returns:
-            处理结果
-        """
-        logger.info(f"开始处理提示链: {chain_config_path}")
-
-        # 加载提示链配置
-        chain_config = self.prompt_loader.load_chain_config(chain_config_path)
-        logger.info(f"提示链名称: {chain_config['chain_name']}")
-        logger.info(f"步骤数量: {len(chain_config['steps'])}")
-
-        # 如果指定了温度参数,更新执行器配置
-        if temperature is not None:
-            self.chain_executor.default_temperature = temperature
-        if max_tokens is not None:
-            self.chain_executor.default_max_tokens = max_tokens
-
-        # 执行提示链
-        result = await self.chain_executor.execute_chain(
-            chain_config["steps"],
-            input_data
-        )
-
-        logger.info(f"提示链处理完成,共执行 {len(result['steps'])} 个步骤")
-
-        return result
-
-    def get_model_info(self) -> Dict[str, str]:
-        """
-        获取当前模型信息
-
-        Returns:
-            模型信息字典
-        """
-        return {
-            "model_id": self.llm_client.get_model_id(),
-            "server_url": self.llm_client.get_server_url()
-        }

+ 0 - 10
core/construction_review/component/reviewers/utils/llm_chain_client/requirements.txt

@@ -1,10 +0,0 @@
-# LLM 提示链系统依赖项
-
-# 异步HTTP客户端
-aiohttp>=3.9.0
-
-# YAML配置文件解析
-pyyaml>=6.0
-
-# 模板引擎(用于提示词渲染)
-jinja2>=3.1.0

+ 174 - 0
foundation/ai/agent/generate/model_generate.py

@@ -16,6 +16,49 @@ import asyncio
 import time
 from typing import Optional, Callable, Any, List, Union
 
+
+def _sync_retry_with_backoff(
+    func: Callable,
+    *args,
+    max_retries: int = 3,
+    backoff_factor: float = 1.0,
+    trace_id: Optional[str] = None,
+    model_name: Optional[str] = None,
+    **kwargs
+) -> Any:
+    """
+    同步版本的带指数退避重试机制
+
+    注意:对于 502/503/504 等服务不可用错误,立即失败不重试
+    """
+    model_info = model_name or "default"
+
+    def _is_server_unavailable_error(error: Exception) -> bool:
+        """判断是否为服务端不可用错误(应立即失败)"""
+        error_str = str(error).lower()
+        unavailable_codes = ['502', '503', '504', 'internal server error']
+        return any(code in error_str for code in unavailable_codes)
+
+    for attempt in range(max_retries + 1):
+        try:
+            return func(*args, **kwargs)
+        except Exception as e:
+            error_str = str(e)
+
+            # 服务端不可用错误(502/503/504)立即失败,不重试
+            if _is_server_unavailable_error(e):
+                logger.error(f"[模型调用] 服务端不可用,立即失败: {error_str} | trace_id: {trace_id}, model: {model_info}")
+                raise
+
+            if attempt == max_retries:
+                logger.error(f"[模型调用] 达到最大重试次数 {max_retries},最终失败: {error_str} | trace_id: {trace_id}, model: {model_info}")
+                raise
+
+            wait_time = backoff_factor * (2 ** attempt)
+            logger.warning(f"[模型调用] 第 {attempt + 1} 次尝试失败: {error_str}, {wait_time}秒后重试... | trace_id: {trace_id}, model: {model_info}")
+            time.sleep(wait_time)
+
+
 class GenerateModelClient:
     """
         主要是生成式模型
@@ -285,6 +328,137 @@ class GenerateModelClient:
             "messages, system_prompt+user_prompt, prompt, 或 task_prompt_info"
         )
 
+    def get_model_generate_invoke_sync(
+        self,
+        trace_id: str,
+        task_prompt_info: Optional[dict] = None,
+        messages: Optional[List[BaseMessage]] = None,
+        system_prompt: Optional[str] = None,
+        user_prompt: Optional[str] = None,
+        prompt: Optional[str] = None,
+        timeout: Optional[int] = None,
+        model_name: Optional[str] = None,
+        enable_thinking: Optional[bool] = False,
+        function_name: Optional[str] = None
+    ) -> str:
+        """模型非流式生成(同步版本)
+
+        适用于同步上下文调用,功能与异步版本完全一致。
+
+        支持多种调用方式(优先级从高到低):
+        1. messages: 直接传入 LangChain Message 对象列表
+        2. system_prompt + user_prompt: 分别传入系统和用户提示词
+        3. prompt: 传入单条用户提示词字符串
+        4. task_prompt_info: 传入包含 ChatPromptTemplate 的字典(兼容旧接口)
+
+        Args:
+            trace_id: 追踪ID
+            task_prompt_info: 任务提示词信息(兼容旧接口),需包含 format_messages() 方法
+            messages: LangChain Message 对象列表(如 [SystemMessage, HumanMessage])
+            system_prompt: 系统提示词字符串
+            user_prompt: 用户提示词字符串
+            prompt: 单条用户提示词字符串(无系统提示时使用)
+            timeout: 超时时间(秒),默认使用构造时的 default_timeout(同步版本忽略)
+            model_name: 模型名称(可选),支持 doubao/qwen/deepseek/gemini 等
+            enable_thinking: 是否启用思考模式,默认 False(仅对 Qwen3.5 系列模型有效)
+            function_name: 功能名称(可选),如提供则从 model_setting.yaml 加载模型和 thinking 配置
+
+        Returns:
+            str: 模型生成的文本内容
+
+        Raises:
+            ValueError: 参数组合错误
+            Exception: 模型调用异常
+
+        Examples:
+            # 同步调用(用于同步上下文)
+            result = generate_model_client.get_model_generate_invoke_sync(
+                "trace-001",
+                system_prompt="你是专家",
+                user_prompt="请分析...",
+                function_name="doc_classification_secondary"
+            )
+        """
+        start_time = time.time()
+
+        # 如果提供了功能名称,从配置加载模型和 thinking 模式
+        if function_name:
+            try:
+                from foundation.ai.models.model_config_loader import get_model_for_function, get_thinking_mode_for_function
+                config_model = get_model_for_function(function_name)
+                config_thinking = get_thinking_mode_for_function(function_name)
+                if config_model:
+                    model_name = config_model
+                    logger.info(f"[模型调用-同步] 从配置加载功能 '{function_name}' 的模型: {model_name}")
+                if config_thinking is not None and enable_thinking is False:
+                    enable_thinking = config_thinking
+                    logger.info(f"[模型调用-同步] 从配置加载功能 '{function_name}' 的 thinking 模式: {enable_thinking}")
+            except Exception as e:
+                logger.warning(f"[模型调用-同步] 加载功能配置失败 [{function_name}]: {e}")
+
+        # 如果没有指定模型名称,从 model_setting.yaml 读取默认配置
+        if not model_name:
+            try:
+                from foundation.ai.models.model_config_loader import get_model_for_function
+                model_name = get_model_for_function("default")
+                logger.info(f"[模型调用-同步] 从 model_setting.yaml 读取默认模型: {model_name}, trace_id: {trace_id}")
+            except Exception as e:
+                logger.warning(f"[模型调用-同步] 从 model_setting.yaml 读取默认模型失败: {e},使用初始化模型")
+
+        try:
+            # 选择模型
+            llm_to_use = self.model_handler.get_model_by_name(model_name) if model_name else self.llm
+            logger.info(f"[模型调用-同步] 使用{'指定' if model_name else '默认'}模型: {model_name or 'default'}, trace_id: {trace_id}")
+
+            # 构建消息列表(按优先级)
+            final_messages = self._build_messages(
+                messages=messages,
+                system_prompt=system_prompt,
+                user_prompt=user_prompt,
+                prompt=prompt,
+                task_prompt_info=task_prompt_info
+            )
+
+            # 针对 Qwen3.5 模型处理思考模式
+            model_to_invoke = llm_to_use
+            is_qwen35 = model_name and ('qwen3.5' in model_name.lower() or 'qwen3_5' in model_name.lower())
+
+            if is_qwen35:
+                if enable_thinking is False:
+                    model_to_invoke = llm_to_use.bind(
+                        extra_body={"chat_template_kwargs": {"enable_thinking": False}}
+                    )
+                    logger.debug(f"[模型调用-同步] 已禁用 Qwen3.5 思考模式: {model_name}")
+                elif enable_thinking is True:
+                    model_to_invoke = llm_to_use.bind(
+                        extra_body={"chat_template_kwargs": {"enable_thinking": True}}
+                    )
+                    logger.debug(f"[模型调用-同步] 已启用 Qwen3.5 思考模式: {model_name}")
+                else:
+                    logger.debug(f"[模型调用-同步] 使用 Qwen3.5 默认思考模式: {model_name}")
+
+            # 定义模型调用函数,使用同步 invoke
+            def _invoke():
+                return model_to_invoke.invoke(final_messages)
+
+            # 调用带重试机制(同步版本)
+            response = _sync_retry_with_backoff(
+                _invoke,
+                max_retries=self.max_retries,
+                backoff_factor=self.backoff_factor,
+                trace_id=trace_id,
+                model_name=model_name or "default"
+            )
+
+            elapsed_time = time.time() - start_time
+            logger.info(f"[模型调用-同步] 成功 trace_id: {trace_id}, 耗时: {elapsed_time:.2f}s")
+            return response.content
+
+        except Exception as e:
+            elapsed_time = time.time() - start_time
+            logger.error(f"[模型调用-同步] 异常 trace_id: {trace_id}, 耗时: {elapsed_time:.2f}s, 错误: {type(e).__name__}: {str(e)}")
+            raise
+
     def get_model_generate_stream(
         self,
         trace_id: str,

+ 0 - 285
foundation/ai/agent/generate/model_generate.py.bak

@@ -1,285 +0,0 @@
-# !/usr/bin/ python
-# -*- coding: utf-8 -*-
-'''
-@Project    : lq-agent-api
-@File       :model_generate.py
-@IDE        :PyCharm
-@Author     :
-@Date       :2025/7/14 14:22
-'''
-
-from langchain_core.prompts import ChatPromptTemplate
-from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage
-from foundation.ai.models.model_handler import model_handler
-from foundation.observability.logger.loggering import review_logger as logger
-import asyncio
-import time
-from typing import Optional, Callable, Any, List, Union
-
-class GenerateModelClient:
-    """
-        主要是生成式模型
-    """
-
-    def __init__(self, default_timeout: int = 60, max_retries: int = 3, backoff_factor: float = 1.0):
-        # 获取默认模型
-        self.llm = model_handler.get_models()
-        self.chat = self.llm  # 当前chat和llm使用相同模型
-
-        # 配置参数
-        self.default_timeout = default_timeout
-        self.max_retries = max_retries
-        self.backoff_factor = backoff_factor
-
-        # 保存model_handler引用,用于动态获取模型
-        self.model_handler = model_handler
-
-    async def _retry_with_backoff(self, func: Callable, *args, timeout: Optional[int] = None, **kwargs):
-        """
-        带指数退避的重试机制,每次重试都有独立的超时控制
-        """
-        current_timeout = timeout or self.default_timeout
-
-        for attempt in range(self.max_retries + 1):
-            try:
-                # 每次重试都有独立的超时时间
-                return await asyncio.wait_for(
-                    func(*args, **kwargs),
-                    timeout=current_timeout
-                )
-            except asyncio.TimeoutError:
-                if attempt == self.max_retries:
-                    logger.error(f"[模型调用] 达到最大重试次数 {self.max_retries},最终超时")
-                    raise TimeoutError(f"模型调用在 {self.max_retries} 次重试后均超时")
-
-                wait_time = self.backoff_factor * (2 ** attempt)
-                logger.warning(f"[模型调用] 第 {attempt + 1} 次超时, {wait_time}秒后重试...")
-                await asyncio.sleep(wait_time)
-            except Exception as e:
-                if attempt == self.max_retries:
-                    logger.error(f"[模型调用] 达到最大重试次数 {self.max_retries},最终失败: {str(e)}")
-                    raise
-
-                wait_time = self.backoff_factor * (2 ** attempt)
-                logger.warning(f"[模型调用] 第 {attempt + 1} 次尝试失败: {str(e)}, {wait_time}秒后重试...")
-                await asyncio.sleep(wait_time)
-
-    async def get_model_generate_invoke(
-        self,
-        trace_id: str,
-        task_prompt_info: Optional[dict] = None,
-        messages: Optional[List[BaseMessage]] = None,
-        system_prompt: Optional[str] = None,
-        user_prompt: Optional[str] = None,
-        prompt: Optional[str] = None,
-        timeout: Optional[int] = None,
-        model_name: Optional[str] = None
-    ) -> str:
-        """模型非流式生成(异步)
-
-        支持多种调用方式(优先级从高到低):
-        1. messages: 直接传入 LangChain Message 对象列表
-        2. system_prompt + user_prompt: 分别传入系统和用户提示词
-        3. prompt: 传入单条用户提示词字符串
-        4. task_prompt_info: 传入包含 ChatPromptTemplate 的字典(兼容旧接口)
-
-        Args:
-            trace_id: 追踪ID
-            task_prompt_info: 任务提示词信息(兼容旧接口),需包含 format_messages() 方法
-            messages: LangChain Message 对象列表(如 [SystemMessage, HumanMessage])
-            system_prompt: 系统提示词字符串
-            user_prompt: 用户提示词字符串
-            prompt: 单条用户提示词字符串(无系统提示时使用)
-            timeout: 超时时间(秒),默认使用构造时的 default_timeout
-            model_name: 模型名称(可选),支持 doubao/qwen/deepseek/gemini 等
-
-        Returns:
-            str: 模型生成的文本内容
-
-        Raises:
-            ValueError: 参数组合错误
-            TimeoutError: 调用超时
-            Exception: 模型调用异常
-
-        Examples:
-            # 方式1: 使用 Message 列表(推荐)
-            messages = [SystemMessage(content="你是专家"), HumanMessage(content="请分析...")]
-            result = await client.get_model_generate_invoke("trace-001", messages=messages)
-
-            # 方式2: 分别传入系统和用户提示词
-            result = await client.get_model_generate_invoke(
-                "trace-001",
-                system_prompt="你是专家",
-                user_prompt="请分析..."
-            )
-
-            # 方式3: 传入单条提示词
-            result = await client.get_model_generate_invoke("trace-001", prompt="请分析...")
-
-            # 方式4: 兼容旧接口(使用 PromptLoader)
-            task_prompt_info = {"task_prompt": chat_template}
-            result = await client.get_model_generate_invoke("trace-001", task_prompt_info=task_prompt_info)
-        """
-        start_time = time.time()
-        current_timeout = timeout or self.default_timeout
-
-        try:
-            # 选择模型
-            llm_to_use = self.model_handler.get_model_by_name(model_name) if model_name else self.llm
-            logger.info(f"[模型调用] 使用{'指定' if model_name else '默认'}模型: {model_name or 'default'}, trace_id: {trace_id}")
-
-            # 构建消息列表(按优先级)
-            final_messages = self._build_messages(
-                messages=messages,
-                system_prompt=system_prompt,
-                user_prompt=user_prompt,
-                prompt=prompt,
-                task_prompt_info=task_prompt_info
-            )
-
-            # 定义模型调用函数,使用原生 ainvoke
-            async def _invoke():
-                return await llm_to_use.ainvoke(final_messages)
-
-            # 调用带重试机制
-            response = await self._retry_with_backoff(_invoke, timeout=current_timeout)
-
-            elapsed_time = time.time() - start_time
-            logger.info(f"[模型调用] 成功 trace_id: {trace_id}, 耗时: {elapsed_time:.2f}s")
-            return response.content
-
-        except asyncio.TimeoutError:
-            elapsed_time = time.time() - start_time
-            logger.error(f"[模型调用] 超时 trace_id: {trace_id}, 耗时: {elapsed_time:.2f}s, 超时阈值: {current_timeout}s")
-            raise TimeoutError(f"模型调用超时,trace_id: {trace_id}")
-
-        except Exception as e:
-            elapsed_time = time.time() - start_time
-            logger.error(f"[模型调用] 异常 trace_id: {trace_id}, 耗时: {elapsed_time:.2f}s, 错误: {type(e).__name__}: {str(e)}")
-            raise
-
-    def _build_messages(
-        self,
-        messages: Optional[List[BaseMessage]] = None,
-        system_prompt: Optional[str] = None,
-        user_prompt: Optional[str] = None,
-        prompt: Optional[str] = None,
-        task_prompt_info: Optional[dict] = None
-    ) -> List[BaseMessage]:
-        """构建消息列表(内部方法)
-
-        优先级:messages > system_prompt+user_prompt > prompt > task_prompt_info
-        """
-        # 方式1: 直接使用传入的 Message 列表
-        if messages is not None:
-            if not isinstance(messages, list):
-                raise ValueError("messages 必须是列表")
-            if len(messages) == 0:
-                raise ValueError("messages 不能为空列表")
-            logger.debug(f"使用传入的 messages 列表,共 {len(messages)} 条消息")
-            return messages
-
-        # 方式2: system_prompt + user_prompt
-        if system_prompt is not None and user_prompt is not None:
-            logger.debug("使用 system_prompt + user_prompt 构建消息")
-            return [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]
-
-        # 方式3: 单独 system_prompt(可能是特殊情况)
-        if system_prompt is not None:
-            logger.debug("使用单独的 system_prompt 构建消息")
-            return [SystemMessage(content=system_prompt)]
-
-        # 方式4: 单条 prompt 字符串
-        if prompt is not None:
-            logger.debug("使用单条 prompt 字符串构建消息")
-            return [HumanMessage(content=prompt)]
-
-        # 方式5: 兼容旧接口 task_prompt_info
-        if task_prompt_info is not None:
-            if "task_prompt" not in task_prompt_info:
-                raise ValueError("task_prompt_info 必须包含 'task_prompt' 键")
-            task_prompt = task_prompt_info["task_prompt"]
-            if hasattr(task_prompt, 'format_messages'):
-                logger.debug("使用 task_prompt_info 中的 ChatPromptTemplate 构建消息")
-                return task_prompt.format_messages()
-            elif isinstance(task_prompt, str):
-                logger.debug("使用 task_prompt_info 中的字符串构建消息")
-                return [HumanMessage(content=task_prompt)]
-            else:
-                raise ValueError(f"task_prompt 类型不支持: {type(task_prompt)}")
-
-        # 没有提供任何有效参数
-        raise ValueError(
-            "必须提供以下参数之一: "
-            "messages, system_prompt+user_prompt, prompt, 或 task_prompt_info"
-        )
-
-    def get_model_generate_stream(
-        self,
-        trace_id: str,
-        task_prompt_info: Optional[dict] = None,
-        messages: Optional[List[BaseMessage]] = None,
-        system_prompt: Optional[str] = None,
-        user_prompt: Optional[str] = None,
-        prompt: Optional[str] = None,
-        timeout: Optional[int] = None,
-        model_name: Optional[str] = None
-    ):
-        """模型流式生成(同步生成器)
-
-        支持多种调用方式(优先级从高到低):
-        1. messages: 直接传入 LangChain Message 对象列表
-        2. system_prompt + user_prompt: 分别传入系统和用户提示词
-        3. prompt: 传入单条用户提示词字符串
-        4. task_prompt_info: 传入包含 ChatPromptTemplate 的字典(兼容旧接口)
-
-        Args:
-            trace_id: 追踪ID
-            task_prompt_info: 任务提示词信息(兼容旧接口)
-            messages: LangChain Message 对象列表
-            system_prompt: 系统提示词字符串
-            user_prompt: 用户提示词字符串
-            prompt: 单条用户提示词字符串
-            timeout: 超时时间(秒)
-            model_name: 模型名称(可选),支持 doubao/qwen/deepseek/gemini 等
-
-        Yields:
-            str: 生成的文本块
-
-        Raises:
-            ValueError: 参数组合错误
-        """
-        start_time = time.time()
-        current_timeout = timeout or self.default_timeout
-
-        try:
-            logger.info(f"[模型流式调用] 开始处理 trace_id: {trace_id}, 超时配置: {current_timeout}s")
-
-            # 构建消息列表
-            final_messages = self._build_messages(
-                messages=messages,
-                system_prompt=system_prompt,
-                user_prompt=user_prompt,
-                prompt=prompt,
-                task_prompt_info=task_prompt_info
-            )
-
-            response = llm_to_use.stream(final_messages)
-
-            chunk_count = 0
-            for chunk in response:
-                chunk_count += 1
-                if hasattr(chunk, 'content') and chunk.content:
-                    yield chunk.content
-                elif chunk:
-                    yield chunk
-
-            elapsed_time = time.time() - start_time
-            logger.info(f"[模型流式调用] 成功 trace_id: {trace_id}, 生成块数: {chunk_count}, 耗时: {elapsed_time:.2f}s")
-
-        except Exception as e:
-            elapsed_time = time.time() - start_time
-            logger.error(f"[模型流式调用] 异常 trace_id: {trace_id}, 耗时: {elapsed_time:.2f}s, 错误: {type(e).__name__}: {str(e)}")
-            raise
-
-generate_model_client = GenerateModelClient(default_timeout=15, max_retries=2, backoff_factor=0.5)