Forráskód Böngészése

@
fix: 优化目录审查器 JSON 解析稳定性,统一各审查服务超时配置

- catalog_reviewer: 5 层 JSON 解析兜底(raw_decode/修复/末块重试/引号修复/重试),
Prompt 精简防幻觉, 推理模式超时 360s
- model_handler: 新增 REQUEST_TIMEOUT_THINKING(360s), 支持 per-call HTTP 超时
动态切换, enable_thinking 缓存隔离
- 统一超时层级: HTTP 180s(非推理)/360s(推理) ≥ 调用级 120s(基准)/180s(报告/合规)
- 合规检查超时 45→120s, 基础 TASK_TIMEOUT 55→120s, 技术 TASK_TIMEOUT 150→180s
- config.ini.template 与 config.ini 同步结构
- 新增 Catalog_Review_Test 测试套件(稳定性/引号修复/thinking 对比)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@

WangXuMing 1 hete
szülő
commit
7554b62169

+ 23 - 16
config/config.ini.template

@@ -3,11 +3,11 @@
 [model]
 # 注意:模型配置已迁移到 model_setting.yaml
 # 请通过 config/model_config_loader.py 获取模型配置
-# Embedding模型类型选择: shutian_qwen3_embed, siliconflow_embed
+# Embedding模型类型选择: lq_qwen3_8b_emd, siliconflow_embed, shutian_qwen3_embed
 EMBEDDING_MODEL_TYPE=shutian_qwen3_embed
 
 # Rerank模型类型选择: bge_rerank_model, lq_rerank_model, silicoflow_rerank_model
-RERANK_MODEL_TYPE=lq_rerank_model
+RERANK_MODEL_TYPE=shutian_rerank_model
 
 
 [deepseek]
@@ -26,6 +26,12 @@ QWEN_SERVER_URL=http://192.168.91.253:8003/v1/
 QWEN_MODEL_ID=qwen3-30b
 QWEN_API_KEY=sk-123456
 
+# Qwen3-30B 独立配置(与qwen配置相同,方便后续独立管理)
+[qwen3_30b]
+QWEN3_30B_SERVER_URL=http://192.168.91.253:8003/v1/
+QWEN3_30B_MODEL_ID=qwen3-30b
+QWEN3_30B_API_KEY=sk-123456
+
 
 [ai_review]
 # 调试模式配置
@@ -100,6 +106,12 @@ QWEN_LOCAL_1_5B_SERVER_URL=http://192.168.91.253:9002/v1
 QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-8B
 QWEN_LOCAL_1_5B_API_KEY=dummy
 
+# 本地部署的Qwen3-Embedding-8B配置
+[lq_qwen3_8b_emd]
+LQ_EMBEDDING_SERVER_URL=http://192.168.91.253:9003/v1
+LQ_EMBEDDING_MODEL_ID=Qwen3-Embedding-8B
+LQ_EMBEDDING_API_KEY=dummy
+
 [lq_qwen3_4b]
 QWEN_LOCAL_1_5B_SERVER_URL=http://192.168.91.253:9001/v1
 QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-4B
@@ -155,7 +167,7 @@ PGVECTOR_PASSWORD=pg16@123
 # Qwen3.5-122B-A10B 模型(端口25423)
 SHUTIAN_122B_SERVER_URL=http://183.220.37.46:25423/v1
 SHUTIAN_122B_MODEL_ID=/model/Qwen3.5-122B-A10B
-SHUTIAN_122B_API_KEY=sk_prod_SELVoIV1d3gku28koH_ONg8L_B2cQis__71f55615
+SHUTIAN_122B_API_KEY=sk-prod_ojkjwcO4TTd9TL3vK6uo8a2Dvcdoz64u_9a89845f
 
 # Qwen3-8B 模型(端口25424)
 SHUTIAN_8B_SERVER_URL=http://183.220.37.46:25424/v1
@@ -165,22 +177,22 @@ SHUTIAN_8B_API_KEY=sk_prod_SELVoIV1d3gku28koH_ONg8L_B2cQis__71f55615
 # Qwen3.6-27B 模型(端口25424)
 SHUTIAN_27B_SERVER_URL=http://183.220.37.46:25424/v1
 SHUTIAN_27B_MODEL_ID=/model/Qwen3.6-27B
-SHUTIAN_27B_API_KEY=sk_prod_SELVoIV1d3gku28koH_ONg8L_B2cQis__71f55615
+SHUTIAN_27B_API_KEY=sk_prod_HH21x5WB9Pm7IM9Bf808BoJPEn_4bPX5_f2c5f3f6
 
 # Qwen3.5-35B 模型(端口25427)
 SHUTIAN_35B_SERVER_URL=http://183.220.37.46:25427/v1
 SHUTIAN_35B_MODEL_ID=/model/Qwen3.5-35B
-SHUTIAN_35B_API_KEY=sk_prod_SELVoIV1d3gku28koH_ONg8L_B2cQis__71f55615
+SHUTIAN_35B_API_KEY=sk_prod_0NuLZt1a2UrD80F9iB-GTxOIuAkJSZxH_5522d7ae
 
 # Qwen3-Embedding-8B 嵌入模型(端口25425)
 SHUTIAN_EMBED_SERVER_URL=http://183.220.37.46:25425/v1
 SHUTIAN_EMBED_MODEL_ID=/model/Qwen3-Embedding-8B
-SHUTIAN_EMBED_API_KEY=sk_prod_SELVoIV1d3gku28koH_ONg8L_B2cQis__71f55615
+SHUTIAN_EMBED_API_KEY=sk_prod_3HDoVka8mU8Jqj9Xnmfkn8bxk5kmzKrz_700c186f
 
 # Qwen3-Reranker-8B 重排序模型(端口25426)
 SHUTIAN_RERANK_SERVER_URL=http://183.220.37.46:25426/v1/rerank
 SHUTIAN_RERANK_MODEL_ID=/model/Qwen3-Reranker-8B
-SHUTIAN_RERANK_API_KEY=sk_prod_SELVoIV1d3gku28koH_ONg8L_B2cQis__71f55615
+SHUTIAN_RERANK_API_KEY=sk_prod_dvgYHKWFoQlYAKmkIvBSyuguNSQGeNh0_23c65608
 
 
 [milvus]
@@ -199,8 +211,8 @@ SPARSE_WEIGHT=0.7
 [rag_collections]
 # RAG 检索链路使用的 Milvus 集合名
 ENTITY_COLLECTION=first_bfp_collection_entity
-BFP_COLLECTION=rag_children_hybrid
-CHILDREN_COLLECTIONION=rag_parent_hybrid
+CHILDREN_COLLECTION=t_rag_kng_standard # 子分段集合
+PARENT_COLLECTION=t_rag_kng_standard_parent # 父分段集合
 
 
 # ============================================================
@@ -240,11 +252,6 @@ MAX_TOKENS=1024
 [construction_review]
 MAX_CELERY_TASKS=1
 
-[timeliness_review]
-# 时效性审查中用于匹配前需要去除的符号(第二轮处理)
-# 这些符号会在基础规范化(去除空白、书名号、括号、HTML标签)之后去除
-# 包含各种连接符:半角连字符(-)、全角连接号(-)、全角破折号(—)
-# 包含各种连接符:半角连字符(-)、全角连接号(-)、全角破折号(—)、水平线(―)、
-# 连字符(‐)、不换行连字符(‑)、数字线(‒)、短破折号(–)、减号(−)
-REMOVE_SYMBOLS=),-,.,/,,:,[,],【,】,〔,〕,(,),-,—,―,‐,‑,‒,–,−
+
+
 

+ 1 - 1
config/model_setting.yaml

@@ -151,7 +151,7 @@ model_settings:
   # 目录完整性审查(对比实际目录与标准目录)
   catalog_integrity_review:
     model: shutian_qwen3_5_122b
-    enable_thinking: false
+    enable_thinking: true
     description: "目录完整性审查,对比OCR提取目录与标准目录,找出缺失项,蜀天122B"
 
   # ============================================================

+ 4 - 4
core/construction_review/component/ai_review_engine.py

@@ -315,7 +315,7 @@ class AIReviewEngine(BaseReviewer):
             async with self.semaphore:
                 return await check_func(**kwargs)
 
-        TASK_TIMEOUT = 55
+        TASK_TIMEOUT = 120
 
         basic_tasks = []
 
@@ -476,7 +476,7 @@ class AIReviewEngine(BaseReviewer):
         technical_tasks = []
         task_mapping = []
 
-        TASK_TIMEOUT = 150
+        TASK_TIMEOUT = 180
 
         # 判断是否需要基于 entity_results 创建动态任务
         if entity_results and len(entity_results) > 0:
@@ -1080,7 +1080,7 @@ class AIReviewEngine(BaseReviewer):
             combined_content = review_content
 
         return await self.review("non_parameter_compliance_check", trace_id, reviewer_type, prompt_name, combined_content, review_references,
-                               reference_source, state, stage_name, timeout=45, function_name="non_parameter_compliance_check")
+                               reference_source, state, stage_name, timeout=120, function_name="non_parameter_compliance_check")
 
     async def check_parameter_compliance(self, trace_id_idx: str, review_content: str, review_references: str,
                                         reference_source: str, state: str, stage_name: str,
@@ -1113,7 +1113,7 @@ class AIReviewEngine(BaseReviewer):
             combined_content = review_content
 
         return await self.review("parameter_compliance_check", trace_id, reviewer_type, prompt_name, combined_content, review_references,
-                               reference_source, state, stage_name, timeout=45, function_name="parameter_compliance_check")
+                               reference_source, state, stage_name, timeout=120, function_name="parameter_compliance_check")
 
     async def reference_basis_reviewer(self, review_data: Dict[str, Any], trace_id: str,
                                 state: dict = None, stage_name: str = None) -> Dict[str, Any]:

+ 2 - 2
core/construction_review/component/reviewers/base_reviewer.py

@@ -36,7 +36,7 @@ class BaseReviewer(ABC):
     
     #@obverse
     async def  review(self, name: str, trace_id: str, reviewer_type: str, prompt_name: str, review_content: str, review_references: str = None,
-                    reference_source: str = None, state:str =None,stage_name:str = None, timeout: int = 60, model_name: str = None,
+                    reference_source: str = None, state:str =None,stage_name:str = None, timeout: int = 120, model_name: str = None,
                     function_name: str = None) -> ReviewResult:
         """
         执行审查
@@ -55,7 +55,7 @@ class BaseReviewer(ABC):
             reference_source: 参考来源 (可选)
             state: 状态字典 (可选,用于进度更新)
             stage_name: 阶段名称 (可选,用于进度更新)
-            timeout: 模型调用超时时间,默认60秒 (可选)
+            timeout: 模型调用超时时间,默认120秒 (可选)
             model_name: 模型名称 (可选),支持动态切换模型
                       支持的模型:doubao, qwen, deepseek, gemini,
                                   lq_qwen3_8b, lq_qwen3_8b_lq_lora,

+ 101 - 35
core/construction_review/component/reviewers/catalog_reviewer.py

@@ -92,7 +92,7 @@ class CatalogReviewer:
             system_prompt=system_prompt,
             user_prompt=user_prompt,
             function_name="catalog_integrity_review",
-            timeout=120
+            timeout=360
         )
         logger.info(f"[CatalogReviewer] content length: {len(content)}")
         return content
@@ -103,7 +103,7 @@ class CatalogReviewer:
         """从 YAML 模板组装系统提示词"""
         template = self._prompt_config.get('system_prompt', '')
         if not template:
-            return "你是一位施工方案文档审查专家,请对比实际目录和标准目录,找出缺失项。"
+            return "你是一位施工方案文档审查专家。只输出JSON,不要思考过程。"
 
         page_num = toc_page_range.get('start', 3) if toc_page_range else 3
         if toc_page_range:
@@ -113,15 +113,7 @@ class CatalogReviewer:
         else:
             page_info = "目录页页码未知,统一使用 page=3"
 
-        to_json = lambda d: json.dumps(d or {}, ensure_ascii=False, indent=2)
-
-        return template.format(
-            page_info=page_info,
-            page_num=page_num,
-            output_schema=to_json(self._prompt_config.get('output_schema')),
-            issues_example=to_json(self._prompt_config.get('issues_example')),
-            no_issue_example=to_json(self._prompt_config.get('no_issue_example')),
-        )
+        return template.format(page_info=page_info, page_num=page_num)
 
     def _build_user_prompt(self, actual_catalog_text: str) -> str:
         """从 YAML 模板组装用户提示词"""
@@ -138,7 +130,16 @@ class CatalogReviewer:
         if not template:
             return f"请修正JSON格式重新输出: {malformed_content[:200]}"
 
-        preview = malformed_content[:2000]
+        # 错误通常在内容末尾(超长 reason 后的闭合部分),
+        # 所以优先展示尾部 + 头部摘要
+        MAX_PREVIEW = 2000
+        if len(malformed_content) > MAX_PREVIEW:
+            # 尾部 1200 字(包含真正的错误位置)+ 头部 800 字(结构参考)
+            tail = malformed_content[-1200:]
+            head = malformed_content[:800]
+            preview = f"【开头部分】...\n{head}\n\n...【末尾部分(错误在此)】...\n{tail}"
+        else:
+            preview = malformed_content[:MAX_PREVIEW]
         error_info = f"\n## 具体错误\n{parse_error}\n" if parse_error else ""
         return template.replace('{error_info}', error_info).replace('{preview}', preview)
 
@@ -176,48 +177,101 @@ class CatalogReviewer:
             return result, None
         preview = content[:500]
         try:
-            json.loads(content)
-            return None, "JSON结构异常但loads未报错"
+            parsed = json.loads(content)
+            logger.warning(f"[CatalogReviewer] _extract_json失败但json.loads成功,已使用loads结果")
+            return parsed, None
         except json.JSONDecodeError as e:
+            # 兜底修复:LLM 可能在中文文本中混入未转义的 ASCII 双引号
+            fixed = self._repair_unescaped_quotes(content)
+            if fixed != content:
+                try:
+                    parsed = json.loads(fixed)
+                    logger.warning(f"[CatalogReviewer] 修复中文引号后json.loads成功")
+                    return parsed, None
+                except json.JSONDecodeError:
+                    pass
             return None, f"JSONDecodeError: {e} | 内容前500字: {preview}"
         except Exception as e:
             return None, f"{type(e).__name__}: {e} | 内容前500字: {preview}"
 
+    @staticmethod
+    def _repair_unescaped_quotes(content: str) -> str:
+        """修复 LLM 在中文文本中混入的未转义 ASCII 双引号
+
+        LLM 输出的 reason 字段中有时会用 "中文术语" 包裹(例如
+        '如"保障措施"≈"保证措施"'),导致 JSON 解析失败。
+
+        原理:在合法 JSON 中,字符串结束后紧跟的字符只能是 , } ] : 或空白。
+        CJK + " + CJK 模式不可能出现在结构边界上,因此将其中间的 " 替换为 '
+        是安全的——它只影响字符串值内部未转义的内容引号。
+        """
+        # CJK统一表意文字 + 扩展A + 部首 + 符号标点 + 全角 + 数学符号(含≈)
+        _CJK = (
+            '\\u4e00-\\u9fff\\u3400-\\u4dbf\\u2e80-\\u2eff'
+            '\\u3000-\\u303f\\uff00-\\uffef\\u2200-\\u22ff'
+        )
+        # 替换 "CJK文字" 中的未转义双引号为单引号
+        # 也覆盖 CJK + " + "(连续两个双引号,第一个是内容)
+        return re.sub(
+            rf'(?<=[{_CJK}])"(?=[{_CJK}"])',
+            "'", content
+        )
+
     def _extract_json(self, content: str) -> Optional[Dict[str, Any]]:
+        """从LLM响应中提取JSON,使用 raw_decode 自动忽略多余文本"""
         try:
-            content = content.strip()
-            original_preview = content[:500]
+            raw_content = content.strip()
+            content = raw_content
 
+            # 移除 markdown 代码块标记
             content = re.sub(r'^```json\s*', '', content, flags=re.IGNORECASE | re.MULTILINE)
             content = re.sub(r'\s*```\s*$', '', content, flags=re.MULTILINE)
             content = re.sub(r'^```\s*', '', content, flags=re.MULTILINE)
 
+            # 找到第一个 { 开始的位置
             json_start = content.find('{')
             if json_start == -1:
                 logger.warning(f"[CatalogReviewer] 未找到 JSON 开始标记 '{{'")
+                logger.info(f"[CatalogReviewer] LLM原始响应(前2000字):\n{raw_content[:2000]}")
                 return None
-            content = content[json_start:]
-
-            json_end = content.rfind('}')
-            if json_end == -1:
-                logger.warning(f"[CatalogReviewer] 未找到 JSON 结束标记 '}}'")
-                return None
-            content = content[:json_end + 1]
 
+            content = content[json_start:]
             content = content.replace('\n', ' ').replace('\r', ' ')
 
-            try:
-                return json.loads(content)
-            except json.JSONDecodeError as e:
-                logger.debug(f"[CatalogReviewer] 直接解析失败: {e}")
-
-            fixed_content = self._fix_json_content(content)
-            try:
-                return json.loads(fixed_content)
-            except json.JSONDecodeError as e:
-                logger.debug(f"[CatalogReviewer] 修复后解析失败: {e}")
-
-            logger.error(f"[CatalogReviewer] JSON解析失败: {original_preview}")
+            decoder = json.JSONDecoder(strict=False)
+
+            def _try_parse(text: str) -> Optional[dict]:
+                try:
+                    result, _ = decoder.raw_decode(text)
+                    return result
+                except json.JSONDecodeError:
+                    return None
+
+            # 1. 主尝试:从第一个 { 解析
+            result = _try_parse(content)
+            if result:
+                return result
+
+            # 2. 修复常见格式后重试
+            result = _try_parse(self._fix_json_content(content))
+            if result:
+                return result
+
+            # 3. 兜底:LLM 可能输出了"错误JSON + 文字 + 修正JSON",
+            #    尝试从最后一个 {"details" 开始解析(取修正版)
+            last_details = content.rfind('{"details"')
+            if last_details > 0:
+                logger.info(f"[CatalogReviewer] 尝试从最后一个JSON块解析 (pos={last_details})")
+                result = _try_parse(content[last_details:])
+                if result:
+                    logger.info(f"[CatalogReviewer] 最后一个JSON块解析成功")
+                    return result
+                result = _try_parse(self._fix_json_content(content[last_details:]))
+                if result:
+                    logger.info(f"[CatalogReviewer] 最后一个JSON块(修复后)解析成功")
+                    return result
+
+            logger.error(f"[CatalogReviewer] JSON解析失败,LLM完整原始响应:\n{content[:8000]}")
             return None
 
         except Exception as e:
@@ -227,11 +281,23 @@ class CatalogReviewer:
 
     def _fix_json_content(self, content: str) -> str:
         content = content.strip()
+
+        # 1. 修复单引号
         content = re.sub(r"'([a-zA-Z_][a-zA-Z0-9_]*)'\s*:", r'"\1":', content)
         content = re.sub(r":\s*'([^']*)'\s*([,}\]])", r': "\1"\2', content)
         content = re.sub(r":\s*'([^']*)'\s*$", r': "\1"', content)
+
+        # 2. 修复未加引号的键名
         content = re.sub(r'(\{|,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', content)
+
+        # 3. 修复尾逗号
         content = re.sub(r',\s*([}\]])', r'\1', content)
+
+        # 4. 修复相邻 JSON 元素之间缺逗号(LLM 最常见:}\n{ → },{)
+        content = re.sub(r'\}\s*\{', r'},{', content)
+        content = re.sub(r'\]\s*\{', r'],{', content)
+        content = re.sub(r'\}\s*\[', r'},\[', content)
+
         return content
 
 

+ 45 - 149
core/construction_review/component/reviewers/prompt/catalog_reviewers.yaml

@@ -2,163 +2,59 @@ catalog_integrity_review:
   # ========================
   # 系统提示词
   # ========================
-  # 占位符: {page_info}, {page_num}, {output_schema}, {issues_example}, {no_issue_example}
   system_prompt: |
-    你是一位施工方案文档审查专家,负责对比实际目录和标准目录,找出缺失项。
-
-    ## 最高规则(优先于所有其他规则)
-    **只审查缺失,不审查多余**:必须以标准目录为蓝本,逐项检查实际目录缺少了标准目录中的哪些内容。
-    实际目录中任何超出标准目录的额外项,一律忽略,不报告、不提及。
-
-    ## 审查原则
-    1. **精确匹配为主**:实际目录与标准目录必须逐字对应,除非属于下面的一字之差容错范围
-    2. **一级标题严格**:如"编制依据"不能变成"引用标准",必须原文匹配
-    3. **二级标题同此**:同样要求精确匹配,不接受语义替换
-
-    ## 容错规则(以下情况不视为缺失)
-    1. 一字之差:如"技术保障措施"≈"技术保证措施"(仅单个字不同,其余完全一致)
-    2. 增删虚词:如"安全保证措施"≈"安全的保证措施"(仅增加/减少"的""了"等虚词)
-    3. 顺序不计:如标准目录中某项在第5位,实际在第6位不算缺失
-    4. 不确定不报:不确定是否缺失时,不要报告;只报告确定缺失的项
-    5. 超出以上范围的任何差异均视为不匹配,报告为缺失
-
-    ## 输出规则
-    1. 只审查缺失的项,不审查多余的项
-    2. 一级缺失:实际目录中完全没有对应的章,或标题不匹配
-    3. 二级缺失:只有父级一级目录存在时,才检查其下的二级目录
-    4. 如果某个一级目录缺失,不要报告该章节下的任何二级缺失(避免重复)
-    5. 每个缺失项必须单独输出,禁止合并到同一个 issue_point
+    你是施工方案目录审查专家。严格按以下规则对比实际目录与标准目录。
+
+    ## 最高规则(优先级从高到低)
+    规则A — 不审查多余:实际目录比标准目录多的项,一律忽略,不得报告。
+    规则B — 不审查顺序:同一章下二级目录的顺序差异,不得视为缺失。
+    规则C — 容错匹配:以下情况视为匹配,不得报告为缺失:
+      C1. 一字之差:如"保障措施"≈"保证措施"(仅单字不同,如"证"vs"障")
+      C2. 增删虚词:如"安全保证措施"≈"安全的保证措施"
+    规则D — 只报告肯定缺失:不满足A/B/C且确实缺少标题原文的,才报告。
+
+    ## 常见误报场景(这些都不是缺失!)
+    ❌ "技术保障措施" vs "技术保证措施" → 仅"证"和"障"不同 → 容错,不缺失
+    ❌ 实际目录某章下有6项但标准只有5项 → 多出的项忽略,不缺失
+    ❌ 标准目录的"四"在实际目录的"五" → 顺序不同,不缺失
+    ❌ 实际目录有"特种作业人员"但标准没有 → 实际多出的项,不缺失
 
     ## 页码信息
     {page_info}
 
     ## 输出格式
-    直接输出 JSON,不要用 markdown 代码块包裹。JSON 结构如下:
-    {output_schema}
-
-    字段说明:
-    - issue_point: 问题描述(格式见下方示例)
-    - location: 一级缺失填"目录页",二级缺失填对应的一级章节名
-    - page: 页码数字({page_num})
-    - suggestion: 补充建议
-    - reason: 原因说明
-    - risk_level: "高风险"(一级)/ "中风险"(二级)/ "无风险"(无缺失)
-    - exist_issue: true(有缺失)/ false(无缺失)
-    - risk_info.risk_level: "high"(一级)/ "medium"(二级)/ "none"(无缺失)
-
-    ## 示例:有缺失
-    {issues_example}
-
-    ## 示例:无缺失
-    {no_issue_example}
-
-    ## 风险等级对照
-    - 一级缺失 → risk_level="高风险", risk_info.risk_level="high"
-    - 二级缺失 → risk_level="中风险", risk_info.risk_level="medium"
-    - 无缺失   → risk_level="无风险", risk_info.risk_level="none"
+    只输出一个 JSON 对象,以 {{ 开头 }} 结尾,不要思考过程,不要 markdown。
+    reason 字段不超过 30 字,简洁说明即可,不要长篇分析。
+
+    ⚠️ JSON 严格规则(违反会导致解析失败):
+    1. 字符串值中严禁出现未转义的 ASCII 双引号 ",如果要用引号请使用 ' 或 「」
+    2. 每个 check_result 必须包含完整的字段集:issue_point, location, page, suggestion, reason, risk_level
+    3. 最后一个 response 条目最容易遗漏字段,请重点检查
+    4. 不要在字符串内用 ASCII 双引号包裹中文术语
+
+    示例:
+    {{"details":{{"name":"catalog_check","response":[{{"check_item":"completeness_check","chapter_code":"catalogue","check_item_code":"catalogue_completeness_check","check_result":{{"issue_point":"【一级缺失】第一章 编制依据","location":"目录页","page":{page_num},"suggestion":"建议补充'第一章 编制依据'","reason":"目录页缺少该章节","risk_level":"高风险"}},"exist_issue":true,"risk_info":{{"risk_level":"high"}}}}],"review_location_label":"目录完整性审查","chapter_code":"catalogue"}},"success":true}}
+
+    无缺失时,response 只有一条:issue_point="【目录完整】一二级目录结构完整", exist_issue=false, risk_level="无风险", risk_info.risk_level="none"
+
+    每个缺失项独立输出,不合并。
+    一级缺失 → risk_level"高风险", risk_info.risk_level"high"
+    二级缺失 → risk_level"中风险", risk_info.risk_level"medium"
 
   # ========================
   # 用户提示词模板
   # ========================
-  # 占位符: {actual_catalog_text}, {standard_text}
   user_prompt_template: |
-    对比以下【实际目录标准目录,找出缺失项。
+    对比实际目录和标准目录,找出缺失项。
 
-    ## 实际目录(来自OCR识别
+    实际目录(OCR)
     {actual_catalog_text}
 
-    ## 标准目录(必须包含的完整结构)
+    标准目录:
     {standard_text}
 
-  # ========================
-  # JSON 输出结构模板
-  # ========================
-  # 由代码 json.dumps 后注入到 system_prompt 的 {output_schema}
-  output_schema:
-    details:
-      name: catalog_check
-      response:
-        - check_item: completeness_check
-          chapter_code: catalogue
-          check_item_code: catalogue_completeness_check
-          check_result:
-            issue_point: "<问题描述>"
-            location: "<问题定位>"
-            page: 3
-            suggestion: "<补充建议>"
-            reason: "<原因说明>"
-            risk_level: "<风险等级>"
-          exist_issue: true
-          risk_info:
-            risk_level: "<risk_level>"
-      review_location_label: "目录完整性审查"
-      chapter_code: catalogue
-    success: true
-
-  # ========================
-  # 有缺失示例
-  # ========================
-  issues_example:
-    details:
-      name: catalog_check
-      response:
-        - check_item: completeness_check
-          chapter_code: catalogue
-          check_item_code: catalogue_completeness_check
-          check_result:
-            issue_point: "【一级缺失】第四章 施工工艺技术"
-            location: "目录页"
-            page: 3
-            suggestion: "建议补充'第四章 施工工艺技术'章节"
-            reason: "目录页缺少该章节"
-            risk_level: "高风险"
-          exist_issue: true
-          risk_info:
-            risk_level: high
-        - check_item: completeness_check
-          chapter_code: catalogue
-          check_item_code: catalogue_completeness_check
-          check_result:
-            issue_point: "【二级缺失】第一章 编制依据 - 四、编制原则"
-            location: "第一章"
-            page: 3
-            suggestion: "建议补充'四、编制原则'"
-            reason: "第一章缺少该二级目录"
-            risk_level: "中风险"
-          exist_issue: true
-          risk_info:
-            risk_level: medium
-      review_location_label: "目录完整性审查"
-      chapter_code: catalogue
-    success: true
-
-  # ========================
-  # 无缺失示例
-  # ========================
-  no_issue_example:
-    details:
-      name: catalog_check
-      response:
-        - check_item: completeness_check
-          chapter_code: catalogue
-          check_item_code: catalogue_completeness_check
-          check_result:
-            issue_point: "【目录完整】一二级目录结构完整"
-            location: "目录页"
-            page: 3
-            suggestion: "无"
-            reason: "实际目录与标准目录匹配"
-            risk_level: "无风险"
-          exist_issue: false
-          risk_info:
-            risk_level: none
-      review_location_label: "目录完整性审查"
-      chapter_code: catalogue
-    success: true
-
   # ========================
   # 默认标准目录模板
-  # StandardCatalogTemplate.yaml 加载失败时使用
   # ========================
   default_standard_template: |
     第一章 编制依据
@@ -231,18 +127,18 @@ catalog_integrity_review:
   # ========================
   # JSON 修复提示词模板
   # ========================
-  # 占位符: {error_info}, {preview}
   fix_prompt_template: |
-    你上次输出的JSON格式不正确,无法解析。请仔细检查以下问题并重新输出:
-    {error_info}
+    你上次输出的JSON格式不正确。请修正后输出完整JSON。
 
-    ## 常见问题
-    1. 确保所有字符串键和值使用双引号
-    2. 确保字符串值内没有未转义的换行符,如有请用\n替代
-    3. 确保所有括号、方括号正确闭合
-    4. 不要使用markdown代码块包裹JSON
-    5. 不要输出任何JSON之外的内容(包括思考过程)
+    错误:{error_info}
 
-    ## 你上次的输出(请修正后重新输出完整JSON结果)
+    常见问题检查:
+    1. ⚠️ 字符串值中是否含有未转义的 ASCII 双引号 "?如有,请替换为单引号 ' 或中文引号「」
+    2. ⚠️ 每个 check_result 是否都包含 risk_level 字段?最后一个条目特别容易遗漏
+    3. 双引号、正确闭合
+    4. 不要markdown代码块
+    5. 不要思考过程
+    6. reason不超过30字
 
+    上次的输出(修正后重输):
     {preview}

+ 1 - 1
core/construction_review/component/reviewers/sensitive_check_reviewer.py

@@ -74,7 +74,7 @@ class SensitiveCheckReviewer(BaseReviewer):
                 result = await self.review(
                     "sensitive_check", trace_id, "basic", "sensitive_word_check",
                     review_content, formatted_sensitive_words,
-                    None, state, stage_name, timeout=60, function_name="sensitive_check"
+                    None, state, stage_name, timeout=120, function_name="sensitive_check"
                 )
 
                 logger.info(f"sensitive_check 审查完成(已二审),耗时: {time.time() - start_time:.2f}s")

+ 10 - 4
foundation/ai/agent/generate/model_generate.py

@@ -330,7 +330,9 @@ class GenerateModelClient:
 
         try:
             # 选择模型
-            llm_to_use = self.model_handler.get_model_by_name(model_name) if model_name else self.llm
+            llm_to_use = self.model_handler.get_model_by_name(
+                model_name, enable_thinking=(enable_thinking or False)
+            ) if model_name else self.llm
             logger.info(f"[模型调用] 使用{'指定' if model_name else '默认'}模型: {model_name or 'default'}, trace_id: {trace_id}")
 
             # 构建消息列表(按优先级)
@@ -519,7 +521,9 @@ class GenerateModelClient:
 
         try:
             # 选择模型
-            llm_to_use = self.model_handler.get_model_by_name(model_name) if model_name else self.llm
+            llm_to_use = self.model_handler.get_model_by_name(
+                model_name, enable_thinking=(enable_thinking or False)
+            ) if model_name else self.llm
             logger.info(f"[模型调用-同步] 使用{'指定' if model_name else '默认'}模型: {model_name or 'default'}, trace_id: {trace_id}")
 
             # 构建消息列表(按优先级)
@@ -633,7 +637,9 @@ class GenerateModelClient:
 
         try:
             # 选择模型
-            llm_to_use = self.model_handler.get_model_by_name(model_name) if model_name else self.llm
+            llm_to_use = self.model_handler.get_model_by_name(
+                model_name, enable_thinking=(enable_thinking or False)
+            ) if model_name else self.llm
             logger.info(f"[模型流式调用] 使用{'指定' if model_name else '默认'}模型:{model_name or 'default'}, trace_id: {trace_id}")
 
             logger.info(f"[模型流式调用] 开始处理 trace_id: {trace_id}, 超时配置: {current_timeout}s")
@@ -671,4 +677,4 @@ class GenerateModelClient:
             logger.error(f"[模型流式调用] 异常 trace_id: {trace_id}, 耗时: {elapsed_time:.2f}s, 错误: {type(e).__name__}: {str(e)}")
             raise
 
-generate_model_client = GenerateModelClient(default_timeout=60, max_retries=10, backoff_factor=0.5)
+generate_model_client = GenerateModelClient(default_timeout=120, max_retries=10, backoff_factor=0.5)

+ 37 - 21
foundation/ai/models/model_handler.py

@@ -61,7 +61,8 @@ class ModelHandler:
 
     # 模型连接超时时间配置(秒)
     CONNECTION_TIMEOUT = 30
-    REQUEST_TIMEOUT = 120
+    REQUEST_TIMEOUT = 180
+    REQUEST_TIMEOUT_THINKING = 360
     MAX_RETRIES = 2
 
     def __init__(self):
@@ -72,6 +73,12 @@ class ModelHandler:
         """
         self.config = config_handler
         self._model_cache = {}  # 模型实例缓存
+        self._request_timeout_override = None
+
+    @property
+    def request_timeout(self):
+        """当前请求超时时间,有 override 时优先返回 override"""
+        return self._request_timeout_override or self.REQUEST_TIMEOUT
 
     def _check_connection(self, base_url: str, api_key: str = None, timeout: int = 5) -> bool:
         """
@@ -252,7 +259,7 @@ class ModelHandler:
             # 如果所有模型都失败,抛出异常
             raise ModelConnectionError(f"无法初始化任何模型服务: {e}")
 
-    def get_model_by_name(self, model_type: str = None):
+    def get_model_by_name(self, model_type: str = None, enable_thinking: bool = False):
         """
         根据模型名称动态获取指定的AI模型实例
 
@@ -261,6 +268,7 @@ class ModelHandler:
                        支持的模型类型:doubao, qwen, deepseek, gemini,
                                      lq_qwen3_8b, lq_qwen3_8b_lq_lora,
                                      lq_qwen3_4b, qwen_local_14b
+            enable_thinking: 是否启用推理模式,影响 HTTP 超时时间
 
         Returns:
             ChatOpenAI: 配置好的AI模型实例
@@ -274,14 +282,19 @@ class ModelHandler:
         if model_type is None:
             model_type = self.config.get("model", "MODEL_TYPE")
 
-        logger.info(f"动态获取AI模型,模型类型: {model_type}")
+        logger.info(f"动态获取AI模型,模型类型: {model_type}, thinking: {enable_thinking}")
 
-        # 检查缓存
-        cache_key = f"chat_{model_type}"
+        # 检查缓存(thinking 模式使用独立缓存)
+        cache_key = f"chat_{model_type}" if not enable_thinking else f"chat_{model_type}_thinking"
         if cache_key in self._model_cache:
             logger.info(f"使用缓存的模型: {model_type}")
             return self._model_cache[cache_key]
 
+        # 设置超时 override,工厂方法通过 self.request_timeout 读取
+        self._request_timeout_override = (
+            self.REQUEST_TIMEOUT_THINKING if enable_thinking else None
+        )
+
         model = None
 
         try:
@@ -344,6 +357,9 @@ class ModelHandler:
             # 如果所有模型都失败,抛出异常
             raise ModelConnectionError(f"无法初始化任何模型服务: {e}")
 
+        finally:
+            self._request_timeout_override = None
+
     def get_model_by_function(self, function_name: str):
         """
         根据功能名称获取对应的AI模型实例
@@ -472,7 +488,7 @@ class ModelHandler:
                 model=doubao_model_id,
                 api_key=doubao_api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
                 extra_body={
                     "enable_thinking": False,
                 })
@@ -521,7 +537,7 @@ class ModelHandler:
                 model=qwen_model_id,
                 api_key=qwen_api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
                 extra_body={
                     "enable_thinking": False,
                 })
@@ -570,7 +586,7 @@ class ModelHandler:
                 model=deepseek_model_id,
                 api_key=deepseek_api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
                 extra_body={
                     "enable_thinking": False,
                 })
@@ -607,7 +623,7 @@ class ModelHandler:
                 model=model_id,
                 api_key="dummy",
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
             )
 
             logger.info(f"本地Qwen3-8B模型初始化成功: {model_id}")
@@ -650,7 +666,7 @@ class ModelHandler:
                 model=model_id,
                 api_key=api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
             )
 
             logger.info(f"本地Qwen3-8B-lq-lora模型初始化成功: {model_id}")
@@ -685,7 +701,7 @@ class ModelHandler:
                 model=model_id,
                 api_key="dummy",
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
             )
 
             logger.info(f"本地Qwen3-4B模型初始化成功: {model_id}")
@@ -718,7 +734,7 @@ class ModelHandler:
                 model=model_id,
                 api_key="dummy",
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
             )
 
             logger.info(f"本地Qwen3-14B模型初始化成功: {model_id}")
@@ -758,7 +774,7 @@ class ModelHandler:
                 model=model_id,
                 api_key=api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
                 extra_body={
                     "chat_template_kwargs": {"enable_thinking": False}
                 }
@@ -799,7 +815,7 @@ class ModelHandler:
                 model=model_id,
                 api_key=api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
                 extra_body={
                     "chat_template_kwargs": {"enable_thinking": False}
                 }
@@ -840,7 +856,7 @@ class ModelHandler:
                 model=model_id,
                 api_key=api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
                 extra_body={
                     "chat_template_kwargs": {"enable_thinking": False}
                 }
@@ -887,7 +903,7 @@ class ModelHandler:
                 base_url=server_url,
                 model=model_id,
                 api_key=api_key,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
                 tiktoken_enabled=False,
                 check_embedding_ctx_length=False,
                 max_retries=0,  # 禁用SDK内置重试,由EmbeddingClient统一管理
@@ -926,7 +942,7 @@ class ModelHandler:
                 model=model_id,
                 api_key=api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
             )
 
             logger.info(f"蜀天Qwen3.5-122B模型初始化成功: {model_id}")
@@ -960,7 +976,7 @@ class ModelHandler:
                 model=model_id,
                 api_key=api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
             )
 
             logger.info(f"蜀天Qwen3-8B模型初始化成功: {model_id}")
@@ -994,7 +1010,7 @@ class ModelHandler:
                 model=model_id,
                 api_key=api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
             )
 
             logger.info(f"蜀天Qwen3.6-27B模型初始化成功: {model_id}")
@@ -1037,7 +1053,7 @@ class ModelHandler:
                 model=model_id,
                 api_key=api_key,
                 temperature=0.7,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
             )
 
             # 记录模型实例的详细信息用于调试
@@ -1071,7 +1087,7 @@ class ModelHandler:
                 base_url=server_url,
                 model=model_id,
                 api_key=api_key,
-                timeout=self.REQUEST_TIMEOUT,
+                timeout=self.request_timeout,
                 tiktoken_enabled=False,
                 check_embedding_ctx_length=False,
                 max_retries=0,  # 禁用SDK内置重试,由EmbeddingClient统一管理

+ 85 - 0
utils_test/Catalog_Review_Test/check_semantic.py

@@ -0,0 +1,85 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""检查 LLM 审查结论是否符合测试用例预期"""
+import sys
+import json
+sys.path.insert(0, '.')
+sys.path.insert(0, 'utils_test/Catalog_Review_Test')
+
+from test_catalog_review_stability import (
+    TestCatalogReviewStability, REAL_CATALOG, FULL_CATALOG, REAL_MISSING_CHAPTER
+)
+
+import asyncio
+
+async def inspect():
+    tester = TestCatalogReviewStability()
+    if not tester.setup():
+        return
+
+    scenarios = [
+        (REAL_CATALOG, "真实目录(缺第1-2章)"),
+        (FULL_CATALOG, "完整目录(无缺失预期)"),
+        (REAL_MISSING_CHAPTER, "真实目录再少一章"),
+    ]
+
+    for catalog_text, name in scenarios:
+        print(f"\n{'='*60}")
+        print(f"场景: {name}")
+        print(f"{'='*60}")
+        result = await tester.reviewer.review(
+            actual_catalog_text=catalog_text,
+            trace_id_idx=f"verify_{name[:8]}"
+        )
+        response = result.get("details", {}).get("response", [])
+        print(f"返回条目数: {len(response)}")
+        for i, item in enumerate(response):
+            cr = item.get("check_result", {})
+            issue = cr.get("issue_point", "N/A")
+            risk = cr.get("risk_level", "N/A")
+            exist = item.get("exist_issue", "N/A")
+            print(f"  [{i}] exist_issue={exist} risk={risk} | {issue}")
+
+        # 语义验证
+        val = tester._validate_result(result, name, 0)
+        if val.get("semantic_issues"):
+            print(f"  >> 语义问题: {val['semantic_issues']}")
+        elif val.get("issues"):
+            print(f"  >> 结构问题: {val['issues']}")
+        else:
+            print(f"  >> 验证通过")
+
+        # 额外检查:是否有误报(实际的非缺失项被报告)
+        print(f"  误报检查:")
+        points = []
+        for item in response:
+            cr = item.get("check_result", {})
+            points.append(cr.get("issue_point", ""))
+        all_ok = True
+        if "无缺失" in name or "完整目录" in name:
+            for p in points:
+                if "缺失" not in p:
+                    if "目录完整" not in p and "结构完整" not in p:
+                        print(f"    - 非缺失项? {p}")
+                        all_ok = False
+            if all_ok:
+                print(f"    -- 正常(无缺失报告)")
+        if "缺第1-2章" in name:
+            # 应检测到 第1章+第2章
+            found_ch1 = any("第一章" in p or "编制依据" in p for p in points)
+            found_ch2 = any("第二章" in p or "工程概况" in p for p in points)
+            print(f"    - 检测到第一章: {found_ch1}")
+            print(f"    - 检测到第二章: {found_ch2}")
+            # 不应报告第4章等
+            extra = [p for p in points if "第一章" not in p and "第二章" not in p and "编制依据" not in p and "工程概况" not in p]
+            if extra:
+                print(f"    - 额外条目: {extra}")
+        if "再少一章" in name:
+            found_ch1 = any("第一章" in p or "编制依据" in p for p in points)
+            found_ch2 = any("第二章" in p or "工程概况" in p for p in points)
+            found_ch4 = any("第四章" in p or "施工工艺技术" in p for p in points)
+            print(f"    - 检测到第一章: {found_ch1}")
+            print(f"    - 检测到第二章: {found_ch2}")
+            print(f"    - 检测到第四章: {found_ch4}")
+
+asyncio.run(inspect())

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 8 - 0
utils_test/Catalog_Review_Test/debug_extract_json.py


+ 78 - 0
utils_test/Catalog_Review_Test/speed_compare.py

@@ -0,0 +1,78 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""对比 thinking 模式 vs 非 thinking 模式的速度(直接操作配置)"""
+import sys, time, asyncio
+sys.path.insert(0, '.')
+sys.path.insert(0, 'utils_test/Catalog_Review_Test')
+
+from test_catalog_review_stability import REAL_CATALOG, FULL_CATALOG, REAL_MISSING_CHAPTER
+from core.construction_review.component.reviewers.catalog_reviewer import CatalogReviewer
+from foundation.ai.models.model_config_loader import model_config_loader
+
+# 直接覆写 thinking 模式
+ORIG_CONFIG = {}
+
+def set_thinking(mode):
+    global ORIG_CONFIG
+    cfg = model_config_loader.get_model_config("catalog_integrity_review")
+    if not ORIG_CONFIG:
+        ORIG_CONFIG['thinking'] = cfg.enable_thinking
+    # 通过修改 model_config_loader._config 的对应条目来覆盖
+    for func_name, func_cfg in model_config_loader._config.items():
+        if func_name == "catalog_integrity_review":
+            func_cfg['enable_thinking'] = mode
+            for provider in func_cfg.get('model_providers', []):
+                provider['enable_thinking'] = mode
+            for model_name, model_cfg in func_cfg.items():
+                if isinstance(model_cfg, dict):
+                    model_cfg['enable_thinking'] = mode
+            break
+    cfg.enable_thinking = mode
+
+async def run_test(label, mode, scenarios):
+    print(f"\n--- {label} ---")
+    set_thinking(mode)
+    # 让配置生效(清除缓存)
+    total_time = 0
+    total_calls = 0
+    for catalog_text, sc_name in scenarios:
+        for i in range(2):
+            reviewer = CatalogReviewer()
+            start = time.time()
+            result = await reviewer.review(
+                actual_catalog_text=catalog_text,
+                trace_id_idx=f"speed_{label}_{sc_name[:4]}_{i}"
+            )
+            elapsed = time.time() - start
+            resp = result.get('details', {}).get('response', [])
+            total_time += elapsed
+            total_calls += 1
+            print(f"  {sc_name[:12]}: {elapsed:.1f}s | {len(resp)}条")
+    avg = total_time / total_calls
+    print(f"  平均: {avg:.1f}s ({total_calls}次调用)")
+    return avg
+
+async def main():
+    scenarios = [
+        (REAL_CATALOG, "缺第1-2章"),
+        (FULL_CATALOG, "完整目录"),
+        (REAL_MISSING_CHAPTER, "再少一章"),
+    ]
+
+    avg_false = await run_test("THINKING=False", False, scenarios)
+    avg_true = await run_test("THINKING=True", True, scenarios)
+
+    # 恢复
+    set_thinking(ORIG_CONFIG.get('thinking', False))
+
+    ratio = avg_true / avg_false if avg_false > 0 else 0
+    print(f"\n{'='*50}")
+    print(f"THINKING=False 平均: {avg_false:.1f}s")
+    print(f"THINKING=True  平均: {avg_true:.1f}s")
+    print(f"速度比: {ratio:.1f}x")
+    if ratio > 1:
+        print(f"thinking=True 慢 {(ratio-1)*100:.0f}%")
+    else:
+        print(f"thinking=True 快 {(1-ratio)*100:.0f}%")
+
+asyncio.run(main())

+ 486 - 0
utils_test/Catalog_Review_Test/test_catalog_review_stability.py

@@ -0,0 +1,486 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+目录审查器稳定性测试
+
+专门测试 CatalogReviewer 的 JSON 解析稳定性,复现服务端"LLM输出非JSON/畸形JSON"问题。
+测试目标:
+  1. 多次调用结果是否都是合法 JSON
+  2. 结果结构是否包含 "details" 键
+  3. 是否落入 fallback(fallback 意味着 JSON 解析全部失败)
+  4. 相同输入是否得到一致的结果(确定性)
+"""
+
+
+  # 每场景跑 20 次
+ # python utils_test/Catalog_Review_Test/test_catalog_review_stability.py --runs 20
+
+  # 或只跑真实目录场景 20 次
+ # python utils_test/Catalog_Review_Test/test_catalog_review_stability.py --scenario real --runs 20
+
+import asyncio
+import sys
+import os
+import json
+import time
+from pathlib import Path
+
+current_dir = Path(__file__).parent.absolute()
+project_root = current_dir.parent.parent
+os.chdir(str(project_root))
+sys.path.insert(0, str(project_root))
+
+from core.construction_review.component.reviewers.catalog_reviewer import CatalogReviewer
+
+
+# ======================== 测试用例 ========================
+#
+# 数据来源:真实业务场景的 structured_content.catalog,
+# 经过节点 json.loads → catalog_raw.get('formatted_text') 后得到的文本格式。
+# 如果 catalog_raw 中没有 formatted_text,则代码会从 chapters 构建
+# (chapter.title + subsections[].title),如下所示。
+
+# 场景 1:真实目录(来自系统实际业务数据)
+# 特点:只有第 3~10 章,缺第 1~2 章(编制依据、工程概况)
+REAL_CATALOG = """第三章 施工计划
+  一、 施工进度计划
+  二、 施工材料计划
+  三、 施工设备计划
+  四、 劳动力计划
+  五、 安全生产费用使用计划
+第四章 施工工艺技术
+  一、 主要施工方法概述
+  二、 技术参数
+  三、 工艺流程
+  四、 施工准备
+  五、 施工方法及操作要求
+  六、 检查要求
+第五章 安全保证措施
+  一、 安全保证体系
+  二、 组织保证措施
+  三、 技术保障措施
+  四、 安全防护措施
+  五、 监测监控措施
+  六、 应急处置措施
+第六章 质量保证措施
+  一、 质量保证体系
+  二、 质量目标
+  三、 工程创优规划
+  四、 质量控制程序与具体措施
+第七章 环境保证措施
+  一、 环境保证体系
+  二、 环境保护组织机构
+  三、 环境保护及文明施工措施
+第八章 施工管理及作业人员配备与分工
+  一、 施工管理人员
+  二、 专职安全生产管理人员
+  三、 特种作业人员
+  四、 其他作业人员
+第九章 验收要求
+  一、 验收标准
+  二、 验收程序
+  三、 验收内容
+  四、 验收时间
+  五、 验收人员
+第十章 其他资料
+  一、 计算书
+  二、 相关施工图纸
+  三、 附图附表
+  四、 编制及审核人员情况"""
+
+# 场景 2:完整目录(实际目录与标准目录完全一致,用于验证"无缺失"是否稳定)
+FULL_CATALOG = """第一章 编制依据
+  一、法律法规
+  二、标准规范
+  三、文件制度
+  四、编制原则
+  五、编制范围
+第二章 工程概况
+  一、设计概况
+  二、工程地质与水文气象
+  三、周边环境
+  四、施工平面及立面布置
+  五、施工要求和技术保证条件
+  六、风险辨识与分级
+  七、参建各方责任主体单位
+第三章 施工计划
+  一、施工进度计划
+  二、施工材料计划
+  三、施工设备计划
+  四、劳动力计划
+  五、安全生产费用使用计划
+第四章 施工工艺技术
+  一、主要施工方法概述
+  二、技术参数
+  三、工艺流程
+  四、施工准备
+  五、施工方法及操作要求
+  六、检查要求
+第五章 安全保证措施
+  一、安全保证体系
+  二、组织保证措施
+  三、技术保证措施
+  四、监测监控措施
+  五、应急处置措施
+第六章 质量保证措施
+  一、质量保证体系
+  二、质量目标
+  三、工程创优规划
+  四、质量控制程序与具体措施
+第七章 环境保证措施
+  一、环境保证体系
+  二、环境保护组织机构
+  三、环境保护及文明施工措施
+第八章 施工管理及作业人员配备与分工
+  一、施工管理人员
+  二、专职安全生产管理人员
+  三、其他作业人员
+第九章 验收要求
+  一、验收标准
+  二、验收程序
+  三、验收内容
+  四、验收时间
+  五、验收人员
+第十章 其他资料
+  一、计算书
+  二、相关施工图纸
+  三、附图附表
+  四、编制及审核人员情况"""
+
+# 场景 3:真实目录 + 自定义缺失(在真实数据上去掉"第四章 施工工艺技术")
+REAL_MISSING_CHAPTER = """第三章 施工计划
+  一、 施工进度计划
+  二、 施工材料计划
+  三、 施工设备计划
+  四、 劳动力计划
+  五、 安全生产费用使用计划
+第五章 安全保证措施
+  一、 安全保证体系
+  二、 组织保证措施
+  三、 技术保障措施
+  四、 安全防护措施
+  五、 监测监控措施
+  六、 应急处置措施
+第六章 质量保证措施
+  一、 质量保证体系
+  二、 质量目标
+  三、 工程创优规划
+  四、 质量控制程序与具体措施
+第七章 环境保证措施
+  一、 环境保证体系
+  二、 环境保护组织机构
+  三、 环境保护及文明施工措施
+第八章 施工管理及作业人员配备与分工
+  一、 施工管理人员
+  二、 专职安全生产管理人员
+  三、 特种作业人员
+  四、 其他作业人员
+第九章 验收要求
+  一、 验收标准
+  二、 验收程序
+  三、 验收内容
+  四、 验收时间
+  五、 验收人员
+第十章 其他资料
+  一、 计算书
+  二、 相关施工图纸
+  三、 附图附表
+  四、 编制及审核人员情况"""
+
+
+class TestCatalogReviewStability:
+    """目录审查稳定性测试"""
+
+    def __init__(self):
+        self.reviewer = None
+        self.results = {}
+
+    def setup(self):
+        """初始化审查器"""
+        try:
+            self.reviewer = CatalogReviewer()
+            print("CatalogReviewer 初始化成功")
+            return True
+        except Exception as e:
+            print(f"CatalogReviewer 初始化失败: {e}")
+            return False
+
+    def _validate_result(self, result: dict, scenario_name: str, attempt: int) -> dict:
+        """
+        验证审查结果是否合法。
+
+        返回 { "pass": bool, "issues": [str], "is_fallback": bool, ... }
+        """
+        issues = []
+        is_fallback = False
+
+        if not result:
+            issues.append("结果为空")
+            return {"pass": False, "issues": issues, "is_fallback": is_fallback}
+
+        if "execution_time" not in result:
+            issues.append("缺少 execution_time")
+
+        details = result.get("details")
+        if not details:
+            issues.append("缺少 details 或 details 为空")
+            return {"pass": False, "issues": issues, "is_fallback": is_fallback}
+
+        response = details.get("response")
+        if not response or not isinstance(response, list):
+            issues.append("details.response 缺失或不是列表")
+            return {"pass": False, "issues": issues, "is_fallback": is_fallback}
+
+        if len(response) == 0:
+            issues.append("details.response 为空列表")
+
+        for i, item in enumerate(response):
+            if not isinstance(item, dict):
+                issues.append(f"response[{i}] 不是字典")
+                continue
+            cr = item.get("check_result")
+            if not cr:
+                issues.append(f"response[{i}] 缺少 check_result")
+                continue
+            for field in ["issue_point", "location", "suggestion", "risk_level"]:
+                if field not in cr:
+                    issues.append(f"response[{i}].check_result 缺少 {field}")
+            if "exist_issue" not in item:
+                issues.append(f"response[{i}] 缺少 exist_issue")
+
+        # 判断是否 fallback(有缺失场景却只返回一条"无")
+        if len(response) == 1:
+            cr = response[0].get("check_result", {})
+            if cr.get("issue_point") == "无" and cr.get("risk_level") == "无风险" and response[0].get("exist_issue") is False:
+                is_fallback = True
+
+        # === 语义验证 ===
+        semantic_issues = self._check_semantic(response, scenario_name)
+
+        return {
+            "pass": len(issues) == 0 and not semantic_issues,
+            "issues": issues,
+            "semantic_issues": semantic_issues,
+            "is_fallback": is_fallback,
+            "response_count": len(response),
+            "execution_time": result.get("execution_time", 0)
+        }
+
+    def _check_semantic(self, response: list, scenario_name: str) -> list:
+        """
+        验证审查结果的语义是否符合测试用例预期。
+        返回语义问题列表,空列表表示符合预期。
+        """
+        semantic_issues = []
+
+        # 提取所有 issue_point
+        points = []
+        for item in response:
+            cr = item.get("check_result", {})
+            points.append(cr.get("issue_point", ""))
+
+        points_str = " ".join(points)
+
+        if "完整目录" in scenario_name or "无缺失" in scenario_name:
+            # 预期:无缺失,exist_issue=false
+            if not points:
+                semantic_issues.append("完整目录场景未返回任何结果")
+            else:
+                has_missing = any("缺失" in p for p in points)
+                if has_missing:
+                    semantic_issues.append("完整目录场景不应报告缺失,但结果包含缺失标记")
+
+        elif "缺第1-2章" in scenario_name:
+            # 预期:至少报告第1、2章缺失
+            if not points:
+                semantic_issues.append("未返回任何结果")
+            else:
+                if not any("第一章" in p or "编制依据" in p for p in points):
+                    semantic_issues.append("应检测到第一章(编制依据)缺失但未报告")
+                if not any("第二章" in p or "工程概况" in p for p in points):
+                    semantic_issues.append("应检测到第二章(工程概况)缺失但未报告")
+
+        elif "再少一章" in scenario_name:
+            # 预期:至少报告第1、2、4章缺失
+            if not points:
+                semantic_issues.append("未返回任何结果")
+            else:
+                if not any("第一章" in p or "编制依据" in p for p in points):
+                    semantic_issues.append("应检测到第一章(编制依据)缺失但未报告")
+                if not any("第二章" in p or "工程概况" in p for p in points):
+                    semantic_issues.append("应检测到第二章(工程概况)缺失但未报告")
+                if not any("第四章" in p or "施工工艺技术" in p for p in points):
+                    semantic_issues.append("应检测到第四章(施工工艺技术)缺失但未报告")
+
+        return semantic_issues
+
+    async def test_scenario(self, catalog_text: str, scenario_name: str, runs: int = 3):
+        """
+        对同一个场景运行多次,验证:
+        1. 每次调用都不抛异常
+        2. 每次调用结果都是合法 JSON 结构
+        3. 不落入 fallback(除非输入本身就是完整目录)
+        """
+        print(f"\n{'='*60}")
+        print(f"场景: {scenario_name}")
+        print(f"运行次数: {runs}")
+        print(f"{'='*60}")
+
+        scenario_results = []
+        all_pass = True
+
+        for i in range(runs):
+            print(f"\n--- 第 {i+1} 次调用 ---")
+            try:
+                start = time.time()
+                result = await self.reviewer.review(
+                    actual_catalog_text=catalog_text,
+                    trace_id_idx=f"test_stability_{scenario_name}_{i}"
+                )
+                elapsed = time.time() - start
+                val = self._validate_result(result, scenario_name, i)
+                val["elapsed"] = elapsed
+
+                status = "PASS" if val["pass"] else "FAIL"
+                if val["is_fallback"] and "完整" not in scenario_name and "一致" not in scenario_name:
+                    print(f"  [{status}] ⚠️ 落入 fallback(JSON 解析全部失败)")
+                    all_pass = False
+                elif val["semantic_issues"]:
+                    print(f"  [{status}] ❌ 语义不符: {val['semantic_issues']}")
+                    all_pass = False
+                elif val["pass"]:
+                    print(f"  [{status}] ✅ 合法结果 | response 数: {val['response_count']} | 耗时: {elapsed:.1f}s")
+                else:
+                    print(f"  [{status}] ❌ 结构异常: {val['issues']}")
+                    all_pass = False
+
+                scenario_results.append(val)
+
+            except Exception as e:
+                print(f"  [ERROR] ❌ 异常: {e}")
+                all_pass = False
+                scenario_results.append({"pass": False, "error": str(e)})
+
+        # 汇总
+        print(f"\n--- 场景汇总: {scenario_name} ---")
+        pass_count = sum(1 for r in scenario_results if r.get("pass"))
+        fallback_count = sum(1 for r in scenario_results if r.get("is_fallback"))
+        semantic_fail = sum(1 for r in scenario_results if r.get("semantic_issues"))
+        times = [r.get("elapsed", 0) for r in scenario_results if "elapsed" in r]
+        retry_count = sum(1 for r in scenario_results if r.get("was_retry", False))
+
+        print(f"  结构合法: {pass_count}/{runs}")
+        if semantic_fail > 0:
+            print(f"  语义不符: {semantic_fail}/{runs} ⚠️")
+        if fallback_count > 0 and "完整" not in scenario_name:
+            print(f"  落入 fallback: {fallback_count}/{runs}")
+        if retry_count > 0:
+            print(f"  经过重试: {retry_count}/{runs}")
+        if times:
+            print(f"  耗时: avg={sum(times)/len(times):.1f}s min={min(times):.1f}s max={max(times):.1f}s")
+
+        return {
+            "scenario": scenario_name,
+            "all_pass": all_pass,
+            "results": scenario_results
+        }
+
+    def print_summary(self, scenario_results: list):
+        """打印最终汇总"""
+        print(f"\n{'='*60}")
+        print("最终测试汇总")
+        print(f"{'='*60}")
+
+        total = 0
+        struct_ok = 0
+        semantic_ok = 0
+        semantic_fails = []
+        fallback_total = 0
+
+        for sr in scenario_results:
+            runs = len(sr["results"])
+            total += runs
+            sp = sum(1 for r in sr["results"] if r.get("pass"))
+            sf = sum(1 for r in sr["results"] if r.get("semantic_issues"))
+            fb = sum(1 for r in sr["results"] if r.get("is_fallback"))
+            struct_ok += sp
+            semantic_ok += (runs - sf)
+            fallback_total += fb
+
+            if sf > 0:
+                semantic_fails.append(f"{sr['scenario']}({sf}/{runs})")
+
+            issues_detail = []
+            if sf > 0:
+                issues_detail.append(f"语义不符{sf}")
+            if fb > 0 and "完整" not in sr['scenario']:
+                issues_detail.append(f"fallback{fb}")
+            detail = f" ({', '.join(issues_detail)})" if issues_detail else ""
+
+            if sr["all_pass"]:
+                print(f"  ✅ {sr['scenario']}: {sp}/{runs} 通过{detail}")
+            else:
+                print(f"  ❌ {sr['scenario']}: {sp}/{runs} 通过{detail}")
+
+        print(f"\n结构合法: {struct_ok}/{total}")
+        print(f"语义正确: {semantic_ok}/{total}")
+        if fallback_total > 0:
+            print(f"fallback: {fallback_total}/{total}")
+        if semantic_fails:
+            print(f"语义不符场景: {'; '.join(semantic_fails)}")
+            return False
+        else:
+            print("所有场景全部通过 ✅")
+            return True
+
+    async def run_all(self, runs_per_scenario: int = 3):
+        """运行所有测试场景"""
+        if not self.setup():
+            return False
+
+        scenarios = [
+            (REAL_CATALOG, "真实目录(缺第1-2章)"),
+            (FULL_CATALOG, "完整目录(无缺失预期)"),
+            (REAL_MISSING_CHAPTER, "真实目录再少一章"),
+        ]
+
+        all_results = []
+        for catalog_text, scenario_name in scenarios:
+            sr = await self.test_scenario(catalog_text, scenario_name, runs=runs_per_scenario)
+            all_results.append(sr)
+
+        return self.print_summary(all_results)
+
+
+async def main():
+    import argparse
+
+    parser = argparse.ArgumentParser(description="目录审查器稳定性测试")
+    parser.add_argument("--runs", type=int, default=3, help="每个场景运行次数(默认 3)")
+    parser.add_argument("--scenario", type=str, default=None,
+                        help="指定场景: real | full | real_missing")
+    args = parser.parse_args()
+
+    tester = TestCatalogReviewStability()
+
+    if args.scenario:
+        scenario_map = {
+            "real": (REAL_CATALOG, "真实目录(缺第1-2章)"),
+            "full": (FULL_CATALOG, "完整目录(无缺失预期)"),
+            "real_missing": (REAL_MISSING_CHAPTER, "真实目录再少一章"),
+        }
+        if args.scenario not in scenario_map:
+            print(f"未知场景: {args.scenario},可选: {list(scenario_map.keys())}")
+            return False
+        catalog_text, scenario_name = scenario_map[args.scenario]
+        if not tester.setup():
+            return False
+        sr = await tester.test_scenario(catalog_text, scenario_name, runs=args.runs)
+        return sr["all_pass"]
+    else:
+        return await tester.run_all(runs_per_scenario=args.runs)
+
+
+if __name__ == "__main__":
+    success = asyncio.run(main())
+    sys.exit(0 if success else 1)

+ 61 - 0
utils_test/Catalog_Review_Test/test_repair_on_real_log.py

@@ -0,0 +1,61 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""用实际日志内容验证 _repair_unescaped_quotes"""
+import json
+import re
+from pathlib import Path
+
+Q = chr(34)
+
+def repair(content):
+    _CJK = '\\u4e00-\\u9fff\\u3400-\\u4dbf\\u2e80-\\u2eff\\u3000-\\u303f\\uff00-\\uffef\\u2200-\\u22ff'
+    return re.sub(rf'(?<=[{_CJK}])"(?=[{_CJK}"])', "'", content)
+
+log_path = Path('logs/construction_review/2026-W21_0518-0524/0520_construction_review_error.log')
+lines = log_path.read_text(encoding='utf-8').split('\n')
+
+entries = [
+    ("P13820 #1", lines[1].strip()),
+    ("P13820 #2", lines[3].strip()),
+    ("P10456", lines[5].strip()),
+]
+
+for name, raw in entries:
+    print(f"\n{'='*60}")
+    print(f"{name} (长度={len(raw)})")
+    try:
+        json.loads(raw)
+        print("  原始: ✅ 合法")
+    except json.JSONDecodeError as e:
+        err_pos = e.pos
+        print(f"  原始: ❌ {e}")
+        # 显示错误位置附近的上下文
+        s = max(0, err_pos - 30)
+        e_ = min(len(raw), err_pos + 30)
+        print(f"  上下文: ...{raw[s:err_pos]}▶{raw[err_pos:e_]}...")
+
+    fixed = repair(raw)
+    # 统计修复了多少处
+    fixed_count = sum(1 for a, b in zip(raw, fixed) if a != b)
+    print(f"  修复替换: {fixed_count} 处")
+    try:
+        p = json.loads(fixed)
+        resp = p['details']['response']
+        all_have_risk_level = all(
+            'risk_level' in item.get('check_result', {})
+            for item in resp
+        )
+        print(f"  修复后: ✅ 成功 | response={len(resp)}条 | 全有risk_level={all_have_risk_level}")
+    except json.JSONDecodeError as e:
+        err2 = e.pos
+        print(f"  修复后: ❌ {e}")
+        s = max(0, err2 - 40)
+        e_ = min(len(fixed), err2 + 40)
+        print(f"  上下文: ...{fixed[s:err2]}▶{fixed[err2:e_]}...")
+        # 检查新错误位置的字符
+        if err2 < len(fixed):
+            c = fixed[err2]
+            print(f"  字符: U+{ord(c):04X} ({repr(c)})")
+
+print(f"\n{'='*60}")
+print("完成")

+ 66 - 0
utils_test/Catalog_Review_Test/test_repair_quotes.py

@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""验证 _repair_unescaped_quotes — 直接用真实日志场景验证"""
+import json
+import re
+
+Q = chr(34)
+
+def repair(content):
+    _CJK = '\\u4e00-\\u9fff\\u3400-\\u4dbf\\u2e80-\\u2eff\\u3000-\\u303f\\uff00-\\uffef\\u2200-\\u22ff'
+    return re.sub(rf'(?<=[{_CJK}])"(?=[{_CJK}"])', "'", content)
+
+# 场景:模拟P13820 #1 的 reason 字段含未转义引号
+# 用替换标记的方式来构造精确的测试字符串
+MARKER = "<<DQ>>"
+
+def make_json(reason_marker_text):
+    """用 <<DQ>> 标记未转义引号位置"""
+    raw = '{"r":"' + reason_marker_text + '","x":1}'
+    return raw.replace(MARKER, Q)
+
+# 构造: "如"保障措施"≈"保证措施"" → 4个未转义双引号
+text = '如' + MARKER + '保障措施' + MARKER + '≈' + MARKER + '保证措施' + MARKER
+test_json = make_json(text)
+
+print("="*60)
+print("场景: P13820 #1 reason 中的未转义引号")
+print("="*60)
+print(f"测试JSON: {test_json[:100]}...")
+
+# 修复前
+try:
+    json.loads(test_json)
+    print("修复前: ✅ 合法 (无需修复)")
+except json.JSONDecodeError as e:
+    print(f"修复前: ❌ {e}")
+
+# 修复后
+fixed = repair(test_json)
+print(f"修复后: {fixed[:100]}...")
+try:
+    p = json.loads(fixed)
+    print(f"修复后: ✅ 解析成功")
+    print(f"  r = {p['r']}")
+    expected = "如'保障措施'≈'保证措施'"
+    assert p['r'] == expected, f"语义错误: {p['r']}"
+    print(f"  语义验证: ✅")
+except json.JSONDecodeError as e:
+    print(f"修复后: ❌ {e}")
+
+# 验证正常JSON不被破坏
+for normal in [
+    '{"a":"b","c":"d"}',
+    '{"原因":"因为"}',
+    '{"a":"高风险","b":"中风险"}',
+    '{"x":{"y":"中文"}}',
+    '{"details":{"response":[{"check_result":{"risk_level":"无风险"}}]}}',
+]:
+    fixed = repair(normal)
+    assert normal == fixed, f"合法JSON被修改: {normal} → {fixed}"
+    json.loads(fixed)
+print("\n所有正常JSON: ✅ 未被破坏")
+
+print("\n" + "="*60)
+print("全部通过 ✅")
+print("="*60)

+ 126 - 0
utils_test/Catalog_Review_Test/test_report_catalog_review_stability.md

@@ -0,0 +1,126 @@
+# 目录审查器稳定性测试报告
+
+## 测试概述
+
+- **测试脚本**: `test_catalog_review_stability.py`
+- **测试对象**: `CatalogReviewer`(目录完整性审查器)
+- **测试目标**:
+  1. 多次调用结果是否都是合法 JSON 结构
+  2. 结果结构是否包含必要的字段(details、response、check_result 等)
+  3. 是否落入 fallback(JSON 解析全部失败)
+  4. 相同输入的审查结果是否符合语义预期(确定性)
+- **测试日期**: 2026-05-20
+- **模型**: shutian_qwen3_5_122b(Qwen3.5-122B-A10B)
+- **每场景运行次数**: 5
+
+---
+
+## 测试场景
+
+| 场景 | 描述 | 预期 |
+|------|------|------|
+| 真实目录(缺第1-2章) | 实际业务数据,缺少"编制依据"和"工程概况"两章 | 检测到第 1、2 章缺失 |
+| 完整目录(无缺失预期) | 目录与标准目录完全一致 | 不报告任何缺失 |
+| 真实目录再少一章 | 在真实数据基础上去掉"第四章 施工工艺技术" | 检测到第 1、2、4 章缺失 |
+
+---
+
+## 测试结果汇总
+
+| 场景 | 通过/总数 | 结构合法率 | 语义正确率 | 平均耗时 | 结果 |
+|------|-----------|-----------|-----------|---------|------|
+| 真实目录(缺第1-2章) | 5/5 | 100% | 100% | 6.1s | ✅ |
+| 完整目录(无缺失预期) | 5/5 | 100% | 100% | 5.9s | ✅ |
+| 真实目录再少一章 | 4/5 | 100% | 80% | 6.0s | ⚠️ |
+| **合计** | **14/15** | **100%** | **93.3%** | **6.0s** | |
+
+---
+
+## 详细结果
+
+### 场景 1:真实目录(缺第1-2章)
+
+| 运行次数 | 结果 | response 数 | 耗时 | 说明 |
+|---------|------|------------|------|------|
+| 第 1 次 | PASS | 6 | 8.0s | 合法 JSON,结构完整 |
+| 第 2 次 | PASS | 4 | 4.5s | 合法 JSON,结构完整 |
+| 第 3 次 | PASS | 5 | 7.1s | 合法 JSON,结构完整 |
+| 第 4 次 | PASS | 5 | 5.3s | 合法 JSON,结构完整 |
+| 第 5 次 | PASS | 4 | 5.8s | 合法 JSON,结构完整 |
+
+**结论**: ✅ 全部通过。所有运行均返回合法 JSON,成功检测到第 1-2 章缺失。
+
+---
+
+### 场景 2:完整目录(无缺失预期)
+
+| 运行次数 | 结果 | response 数 | 耗时 | 说明 |
+|---------|------|------------|------|------|
+| 第 1 次 | PASS | 3 | 4.7s | 合法 JSON,无缺失报告 |
+| 第 2 次 | PASS | 3 | 5.5s | 合法 JSON,无缺失报告 |
+| 第 3 次 | PASS | 3 | 5.2s | 合法 JSON,无缺失报告 |
+| 第 4 次 | PASS | 3 | 7.6s | 合法 JSON,无缺失报告 |
+| 第 5 次 | PASS | 3 | 6.8s | 合法 JSON,无缺失报告 |
+
+**结论**: ✅ 全部通过。对于完整目录,审查器一致返回"无缺失",符合预期。
+
+---
+
+### 场景 3:真实目录再少一章(缺第 1、2、4 章)
+
+| 运行次数 | 结果 | response 数 | 耗时 | 说明 |
+|---------|------|------------|------|------|
+| 第 1 次 | PASS | 8 | 4.8s | 合法 JSON,正确检测缺失 |
+| 第 2 次 | PASS | 5 | 7.1s | 合法 JSON,正确检测缺失 |
+| 第 3 次 | PASS | 7 | 6.4s | 合法 JSON,正确检测缺失 |
+| 第 4 次 | PASS | 6 | 5.1s | 合法 JSON,正确检测缺失 |
+| 第 5 次 | FAIL | - | 6.8s | 语义不符:未检测到第二章(工程概况)缺失 |
+
+**结论**: ⚠️ 1 次语义不符(第 5 次调用未报告第二章缺失),其余 4 次均正确检测到第 1、2、4 章缺失。
+
+---
+
+## 问题分析
+
+### 1. 语义不一致问题
+
+- **场景 3(真实目录再少一章)的第 5 次调用** 未能检测到"第二章 工程概况"缺失。
+- **可能原因**: LLM 输出存在随机性,同一提示词在不同调用中可能返回不同的审查粒度。模型倾向于关注显式缺失(如直接跳过的章节),但对跨章节的连续性判断存在偶发遗漏。
+- **影响评估**: 5 次中出现 1 次遗漏,漏检率 20%。对于生产环境,建议通过多次投票或设置重试阈值来缓解。
+
+### 2. JSON 解析稳定性
+
+- **总调用次数**: 15 次
+- **JSON 解析失败**: 0 次
+- **结构异常**: 0 次
+- **fallback 触发**: 0 次
+- **结论**: JSON 解析稳定性良好,无解析失败或 fallback 情况。
+
+### 3. 响应时间
+
+| 指标 | 值 |
+|------|----|
+| 最短耗时 | 4.5s |
+| 最长耗时 | 8.0s |
+| 平均耗时 | 6.0s |
+| 标准差 | ~1.1s |
+
+响应时间稳定在 5~8s 范围内,无异常超时。
+
+---
+
+## 结论与建议
+
+| 检查项 | 结果 | 说明 |
+|--------|------|------|
+| JSON 合法性 | ✅ 100% (15/15) | 所有调用均返回合法 JSON |
+| 结构完整性 | ✅ 100% (15/15) | 均包含 details.response.check_result 等必要字段 |
+| Fallback 触发率 | ✅ 0% (0/15) | 无 JSON 解析失败导致的降级 |
+| 语义正确性 | ⚠️ 93.3% (14/15) | 1 次遗漏第二章缺失检测 |
+| 响应稳定性 | ✅ 稳定 | 无超时或异常,耗时 4.5~8.0s |
+
+### 建议
+
+1. **增加重试机制**: 对于语义审查结果,建议对同一输入进行 2~3 次调用并取多数投票结果,以降低单次 LLM 输出的随机性影响。
+2. **增强场景 3 的提示词**: 针对连续章节跳跃的场景,可在提示词中增加明确指令,要求逐章比对,特别是关注中间缺失的章节。
+3. **持续监控**: 建议将此稳定性测试纳入 CI/CD 流程,每次模型或提示词变更后运行以验证回归。

+ 51 - 0
utils_test/Catalog_Review_Test/test_thinking_mode.py

@@ -0,0 +1,51 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+验证 thinking=True 是否真的生效。
+方法:直接调用 API,设置不同的 thinking 模式,检查响应是否包含 <think> 思考块
+"""
+import sys, time, json
+sys.path.insert(0, '.')
+
+from foundation.ai.agent.generate.model_generate import generate_model_client
+
+async def test():
+    prompt = "请简要回答:施工方案中第一章应包含什么内容?一句话。"
+
+    for thinking_mode in [False, True, None]:
+        print(f"\n{'='*50}")
+        print(f"enable_thinking = {thinking_mode}")
+        print(f"{'='*50}")
+
+        start = time.time()
+        content = await generate_model_client.get_model_generate_invoke(
+            trace_id=f"thinking_test_{thinking_mode}",
+            system_prompt="你是一个施工方案专家,请简洁回答。",
+            user_prompt=prompt,
+            function_name="catalog_integrity_review",
+            enable_thinking=thinking_mode,
+            timeout=60
+        )
+        elapsed = time.time() - start
+
+        has_think_tag = "<think>" in content
+        has_thinking_process = "Thinking Process" in content or "thinking" in content.lower()[:200]
+
+        print(f"耗时: {elapsed:.1f}s")
+        print(f"内容长度: {len(content)} 字")
+        print(f"包含 <think> 标签: {has_think_tag}")
+        print(f"内容前200字: {content[:200]}")
+
+        if has_think_tag:
+            # 提取思考内容
+            import re
+            think_match = re.search(r'<think>(.*?)</think>', content, re.DOTALL)
+            if think_match:
+                think_text = think_match.group(1)
+                print(f"思考内容: {think_text[:100]}...")
+            # 去除思考标签后的纯回答
+            clean = re.sub(r'<think>.*?</think>\s*', '', content, flags=re.DOTALL)
+            print(f"纯回答: {clean[:100]}...")
+
+import asyncio
+asyncio.run(test())

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott