From 50b6ab403e6e7aa58ed23ec63104034a066fc3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E4=B8=80=E4=B8=81?= <1769123563@qq.com> Date: Mon, 17 Nov 2025 15:39:02 +0800 Subject: [PATCH] Cleaning Data Returned by Report Engine's LLM --- ReportEngine/nodes/document_layout_node.py | 45 +- ReportEngine/nodes/template_selection_node.py | 55 +- ReportEngine/nodes/word_budget_node.py | 45 +- ReportEngine/prompts/prompts.py | 37 +- ReportEngine/utils/json_parser.py | 632 ++++++++++++++++++ ReportEngine/utils/test_json_parser.py | 235 +++++++ 6 files changed, 985 insertions(+), 64 deletions(-) create mode 100644 ReportEngine/utils/json_parser.py create mode 100644 ReportEngine/utils/test_json_parser.py diff --git a/ReportEngine/nodes/document_layout_node.py b/ReportEngine/nodes/document_layout_node.py index 096a92e..48e8c32 100644 --- a/ReportEngine/nodes/document_layout_node.py +++ b/ReportEngine/nodes/document_layout_node.py @@ -14,6 +14,7 @@ from ..prompts import ( SYSTEM_PROMPT_DOCUMENT_LAYOUT, build_document_layout_prompt, ) +from ..utils.json_parser import RobustJSONParser, JSONParseError from .base_node import BaseNode @@ -27,6 +28,12 @@ class DocumentLayoutNode(BaseNode): def __init__(self, llm_client): """记录LLM客户端并设置节点名字,供BaseNode日志使用""" super().__init__(llm_client, "DocumentLayoutNode") + # 初始化鲁棒JSON解析器,启用所有修复策略 + self.json_parser = RobustJSONParser( + enable_json_repair=True, + enable_llm_repair=False, # 可以根据需要启用LLM修复 + max_repair_attempts=3, + ) def run( self, @@ -82,8 +89,14 @@ class DocumentLayoutNode(BaseNode): """ 解析LLM返回的JSON文本,若失败则抛出友好错误。 + 使用鲁棒JSON解析器进行多重修复尝试: + 1. 清理markdown标记和思考内容 + 2. 本地语法修复(括号平衡、逗号补全、控制字符转义等) + 3. 使用json_repair库进行高级修复 + 4. 可选的LLM辅助修复 + 参数: - raw: LLM原始返回字符串,允许带```包裹。 + raw: LLM原始返回字符串,允许带```包裹、思考内容等。 返回: dict: 结构化的设计稿。 @@ -91,19 +104,25 @@ class DocumentLayoutNode(BaseNode): 异常: ValueError: 当响应为空或JSON解析失败时抛出。 """ - cleaned = raw.strip() - if cleaned.startswith("```json"): - cleaned = cleaned[7:] - if cleaned.startswith("```"): - cleaned = cleaned[3:] - if cleaned.endswith("```"): - cleaned = cleaned[:-3] - cleaned = cleaned.strip() - if not cleaned: - raise ValueError("文档设计LLM返回空内容") try: - return json.loads(cleaned) - except json.JSONDecodeError as exc: + result = self.json_parser.parse( + raw, + context_name="文档设计", + expected_keys=["title", "toc", "hero"], + ) + # 验证关键字段的类型 + if not isinstance(result.get("title"), str): + logger.warning("文档设计缺少title字段或类型错误,使用默认值") + result.setdefault("title", "未命名报告") + if not isinstance(result.get("toc"), (list, dict)): + logger.warning("文档设计缺少toc字段或类型错误,使用空列表") + result.setdefault("toc", []) + if not isinstance(result.get("hero"), dict): + logger.warning("文档设计缺少hero字段或类型错误,使用空对象") + result.setdefault("hero", {}) + return result + except JSONParseError as exc: + # 转换为原有的异常类型以保持向后兼容 raise ValueError(f"文档设计JSON解析失败: {exc}") from exc diff --git a/ReportEngine/nodes/template_selection_node.py b/ReportEngine/nodes/template_selection_node.py index a48c55c..da59a84 100644 --- a/ReportEngine/nodes/template_selection_node.py +++ b/ReportEngine/nodes/template_selection_node.py @@ -12,6 +12,7 @@ from loguru import logger from .base_node import BaseNode from ..prompts import SYSTEM_PROMPT_TEMPLATE_SELECTION +from ..utils.json_parser import RobustJSONParser, JSONParseError class TemplateSelectionNode(BaseNode): @@ -25,13 +26,19 @@ class TemplateSelectionNode(BaseNode): def __init__(self, llm_client, template_dir: str = "ReportEngine/report_template"): """ 初始化模板选择节点 - + Args: llm_client: LLM客户端 template_dir: 模板目录路径 """ super().__init__(llm_client, "TemplateSelectionNode") self.template_dir = template_dir + # 初始化鲁棒JSON解析器,启用所有修复策略 + self.json_parser = RobustJSONParser( + enable_json_repair=True, + enable_llm_repair=False, + max_repair_attempts=3, + ) def run(self, input_data: Dict[str, Any], **kwargs) -> Dict[str, Any]: """ @@ -137,20 +144,22 @@ class TemplateSelectionNode(BaseNode): # 调用LLM response = self.llm_client.stream_invoke_to_string(SYSTEM_PROMPT_TEMPLATE_SELECTION, user_message) - + # 检查响应是否为空 if not response or not response.strip(): logger.error("LLM返回空响应") return None - + logger.info(f"LLM原始响应: {response}") - - # 尝试解析JSON响应 + + # 尝试解析JSON响应,使用鲁棒解析器 try: - # 清理响应文本 - cleaned_response = self._clean_llm_response(response) - result = json.loads(cleaned_response) - + result = self.json_parser.parse( + response, + context_name="模板选择", + expected_keys=["template_name", "selection_reason"], + ) + # 验证选择的模板是否存在 selected_template_name = result.get('template_name', '') for template in available_templates: @@ -161,38 +170,16 @@ class TemplateSelectionNode(BaseNode): 'template_content': template['content'], 'selection_reason': result.get('selection_reason', 'LLM智能选择') } - + logger.error(f"LLM选择的模板不存在: {selected_template_name}") return None - - except json.JSONDecodeError as e: + + except JSONParseError as e: logger.error(f"JSON解析失败: {str(e)}") # 尝试从文本响应中提取模板信息 return self._extract_template_from_text(response, available_templates) - def _clean_llm_response(self, response: str) -> str: - """ - 清理LLM响应。 - 去掉 ```json``` 包裹以及前后空白,方便 `json.loads`。 - - 参数: - response: LLM原始响应。 - - 返回: - str: 适合直接做JSON解析的纯文本。 - """ - # 移除可能的markdown代码块标记 - if '```json' in response: - response = response.split('```json')[1].split('```')[0] - elif '```' in response: - response = response.split('```')[1].split('```')[0] - - # 移除前后空白 - response = response.strip() - - return response - def _extract_template_from_text(self, response: str, available_templates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """ 从文本响应中提取模板信息。 diff --git a/ReportEngine/nodes/word_budget_node.py b/ReportEngine/nodes/word_budget_node.py index 51a2881..45d5b97 100644 --- a/ReportEngine/nodes/word_budget_node.py +++ b/ReportEngine/nodes/word_budget_node.py @@ -14,6 +14,7 @@ from ..prompts import ( SYSTEM_PROMPT_WORD_BUDGET, build_word_budget_prompt, ) +from ..utils.json_parser import RobustJSONParser, JSONParseError from .base_node import BaseNode @@ -27,6 +28,12 @@ class WordBudgetNode(BaseNode): def __init__(self, llm_client): """仅记录LLM客户端引用,方便run阶段发起请求""" super().__init__(llm_client, "WordBudgetNode") + # 初始化鲁棒JSON解析器,启用所有修复策略 + self.json_parser = RobustJSONParser( + enable_json_repair=True, + enable_llm_repair=False, # 可以根据需要启用LLM修复 + max_repair_attempts=3, + ) def run( self, @@ -79,8 +86,14 @@ class WordBudgetNode(BaseNode): """ 将LLM输出的JSON文本转为字典,失败时提示规划异常。 + 使用鲁棒JSON解析器进行多重修复尝试: + 1. 清理markdown标记和思考内容 + 2. 本地语法修复(括号平衡、逗号补全、控制字符转义等) + 3. 使用json_repair库进行高级修复 + 4. 可选的LLM辅助修复 + 参数: - raw: LLM返回值,可能包含```包裹。 + raw: LLM返回值,可能包含```包裹、思考内容等。 返回: dict: 合法的篇幅规划JSON。 @@ -88,19 +101,25 @@ class WordBudgetNode(BaseNode): 异常: ValueError: 当响应为空或JSON解析失败时抛出。 """ - cleaned = raw.strip() - if cleaned.startswith("```json"): - cleaned = cleaned[7:] - if cleaned.startswith("```"): - cleaned = cleaned[3:] - if cleaned.endswith("```"): - cleaned = cleaned[:-3] - cleaned = cleaned.strip() - if not cleaned: - raise ValueError("篇幅规划LLM返回空内容") try: - return json.loads(cleaned) - except json.JSONDecodeError as exc: + result = self.json_parser.parse( + raw, + context_name="篇幅规划", + expected_keys=["totalWords", "globalGuidelines", "chapters"], + ) + # 验证关键字段的类型 + if not isinstance(result.get("totalWords"), (int, float)): + logger.warning("篇幅规划缺少totalWords字段或类型错误,使用默认值") + result.setdefault("totalWords", 10000) + if not isinstance(result.get("globalGuidelines"), list): + logger.warning("篇幅规划缺少globalGuidelines字段或类型错误,使用空列表") + result.setdefault("globalGuidelines", []) + if not isinstance(result.get("chapters"), (list, dict)): + logger.warning("篇幅规划缺少chapters字段或类型错误,使用空列表") + result.setdefault("chapters", []) + return result + except JSONParseError as exc: + # 转换为原有的异常类型以保持向后兼容 raise ValueError(f"篇幅规划JSON解析失败: {exc}") from exc diff --git a/ReportEngine/prompts/prompts.py b/ReportEngine/prompts/prompts.py index eb2cac0..39b7dd0 100644 --- a/ReportEngine/prompts/prompts.py +++ b/ReportEngine/prompts/prompts.py @@ -216,8 +216,17 @@ SYSTEM_PROMPT_TEMPLATE_SELECTION = f""" {json.dumps(output_schema_template_selection, indent=2, ensure_ascii=False)} -确保输出是一个符合上述输出JSON模式定义的JSON对象。 -只返回JSON对象,不要有解释或额外文本。 +**重要的输出格式要求:** +1. 只返回符合上述Schema的纯JSON对象 +2. 严禁在JSON外添加任何思考过程、说明文字或解释 +3. 可以使用```json和```标记包裹JSON,但不要添加其他内容 +4. 确保JSON语法完全正确: + - 对象和数组元素之间必须有逗号分隔 + - 字符串中的特殊字符必须正确转义(\n, \t, \"等) + - 括号必须成对且正确嵌套 + - 不要使用尾随逗号(最后一个元素后不加逗号) + - 不要在JSON中添加注释 +5. 所有字符串值使用双引号,数值不使用引号 """ # HTML报告生成的系统提示词 @@ -372,7 +381,17 @@ SYSTEM_PROMPT_DOCUMENT_LAYOUT = f""" {json.dumps(document_layout_output_schema, ensure_ascii=False, indent=2)} -只返回JSON,勿附加额外文本。 +**重要的输出格式要求:** +1. 只返回符合上述Schema的纯JSON对象 +2. 严禁在JSON外添加任何思考过程、说明文字或解释 +3. 可以使用```json和```标记包裹JSON,但不要添加其他内容 +4. 确保JSON语法完全正确: + - 对象和数组元素之间必须有逗号分隔 + - 字符串中的特殊字符必须正确转义(\n, \t, \"等) + - 括号必须成对且正确嵌套 + - 不要使用尾随逗号(最后一个元素后不加逗号) + - 不要在JSON中添加注释 +5. 所有字符串值使用双引号,数值不使用引号 """ # 篇幅规划提示词 @@ -390,7 +409,17 @@ SYSTEM_PROMPT_WORD_BUDGET = f""" {json.dumps(word_budget_output_schema, ensure_ascii=False, indent=2)} -只返回JSON,无额外说明。 +**重要的输出格式要求:** +1. 只返回符合上述Schema的纯JSON对象 +2. 严禁在JSON外添加任何思考过程、说明文字或解释 +3. 可以使用```json和```标记包裹JSON,但不要添加其他内容 +4. 确保JSON语法完全正确: + - 对象和数组元素之间必须有逗号分隔 + - 字符串中的特殊字符必须正确转义(\n, \t, \"等) + - 括号必须成对且正确嵌套 + - 不要使用尾随逗号(最后一个元素后不加逗号) + - 不要在JSON中添加注释 +5. 所有字符串值使用双引号,数值不使用引号 """ diff --git a/ReportEngine/utils/json_parser.py b/ReportEngine/utils/json_parser.py new file mode 100644 index 0000000..2278e83 --- /dev/null +++ b/ReportEngine/utils/json_parser.py @@ -0,0 +1,632 @@ +""" +统一的JSON解析和修复工具。 + +提供鲁棒的JSON解析能力,支持: +1. 自动清理markdown代码块标记和思考内容 +2. 本地语法修复(括号平衡、逗号补全、控制字符转义等) +3. 使用json_repair库进行高级修复 +4. LLM辅助修复(可选) +5. 详细的错误日志和调试信息 +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Optional, Tuple, Callable +from loguru import logger + +try: + from json_repair import repair_json as _json_repair_fn +except ImportError: + _json_repair_fn = None + + +class JSONParseError(ValueError): + """JSON解析失败时抛出的异常,附带原始文本方便排查。""" + + def __init__(self, message: str, raw_text: Optional[str] = None): + """ + 构造异常并附加原始输出,便于日志中定位。 + + Args: + message: 人类可读的错误描述。 + raw_text: 触发异常的完整LLM输出。 + """ + super().__init__(message) + self.raw_text = raw_text + + +class RobustJSONParser: + """ + 鲁棒的JSON解析器。 + + 集成多种修复策略,确保LLM返回的内容能够被正确解析: + - 清理markdown包裹、思考内容等额外信息 + - 修复常见语法错误(缺少逗号、括号不平衡等) + - 转义未转义的控制字符 + - 使用第三方库进行高级修复 + - 可选的LLM辅助修复 + """ + + # 常见的LLM思考内容模式 + _THINKING_PATTERNS = [ + r".*?", + r".*?", + r"让我想想.*?(?=\{|\[|$)", + r"首先.*?(?=\{|\[|$)", + r"分析.*?(?=\{|\[|$)", + r"根据.*?(?=\{|\[|$)", + ] + + # 冒号等号模式(LLM常见错误) + _COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=') + + def __init__( + self, + llm_repair_fn: Optional[Callable[[str, str], Optional[str]]] = None, + enable_json_repair: bool = True, + enable_llm_repair: bool = False, + max_repair_attempts: int = 3, + ): + """ + 初始化JSON解析器。 + + Args: + llm_repair_fn: 可选的LLM修复函数,接收(原始JSON, 错误信息)返回修复后的JSON + enable_json_repair: 是否启用json_repair库 + enable_llm_repair: 是否启用LLM辅助修复 + max_repair_attempts: 最大修复尝试次数 + """ + self.llm_repair_fn = llm_repair_fn + self.enable_json_repair = enable_json_repair and _json_repair_fn is not None + self.enable_llm_repair = enable_llm_repair + self.max_repair_attempts = max_repair_attempts + + def parse( + self, + raw_text: str, + context_name: str = "JSON", + expected_keys: Optional[List[str]] = None, + extract_wrapper_key: Optional[str] = None, + ) -> Dict[str, Any]: + """ + 解析LLM返回的JSON文本。 + + 参数: + raw_text: LLM原始输出(可能包含```包裹、思考内容等) + context_name: 上下文名称,用于错误信息 + expected_keys: 期望的键列表,用于验证 + extract_wrapper_key: 如果JSON被包裹在某个键中,指定该键名进行提取 + + 返回: + dict: 解析后的JSON对象 + + 异常: + JSONParseError: 多种修复策略仍无法解析合法JSON + """ + if not raw_text or not raw_text.strip(): + raise JSONParseError(f"{context_name}返回空内容") + + # 步骤1: 清理markdown标记和思考内容 + cleaned = self._clean_response(raw_text) + + # 步骤2: 收集候选payload + candidates = [cleaned] + + # 步骤3: 应用本地修复策略 + local_repaired = self._apply_local_repairs(cleaned) + if local_repaired != cleaned: + candidates.append(local_repaired) + + # 步骤4: 尝试解析所有候选 + last_error: Optional[json.JSONDecodeError] = None + for i, candidate in enumerate(candidates): + try: + data = json.loads(candidate) + logger.debug(f"{context_name} JSON解析成功(候选{i + 1}/{len(candidates)})") + return self._extract_and_validate( + data, expected_keys, extract_wrapper_key, context_name + ) + except json.JSONDecodeError as exc: + last_error = exc + logger.debug(f"{context_name} 候选{i + 1}解析失败: {exc}") + + # 步骤5: 使用json_repair库 + if self.enable_json_repair: + repaired = self._attempt_json_repair(cleaned, context_name) + if repaired: + try: + data = json.loads(repaired) + logger.info(f"{context_name} JSON通过json_repair库修复成功") + return self._extract_and_validate( + data, expected_keys, extract_wrapper_key, context_name + ) + except json.JSONDecodeError as exc: + last_error = exc + logger.debug(f"{context_name} json_repair修复后仍无法解析: {exc}") + + # 步骤6: 使用LLM修复(如果启用) + if self.enable_llm_repair and self.llm_repair_fn: + llm_repaired = self._attempt_llm_repair(cleaned, str(last_error), context_name) + if llm_repaired: + try: + data = json.loads(llm_repaired) + logger.info(f"{context_name} JSON通过LLM修复成功") + return self._extract_and_validate( + data, expected_keys, extract_wrapper_key, context_name + ) + except json.JSONDecodeError as exc: + last_error = exc + logger.warning(f"{context_name} LLM修复后仍无法解析: {exc}") + + # 所有策略都失败了 + error_msg = f"{context_name} JSON解析失败: {last_error}" + logger.error(error_msg) + logger.debug(f"原始文本前500字符: {raw_text[:500]}") + raise JSONParseError(error_msg, raw_text=raw_text) from last_error + + def _clean_response(self, raw: str) -> str: + """ + 清理LLM响应,去除markdown标记和思考内容。 + + 参数: + raw: LLM原始输出 + + 返回: + str: 清理后的文本 + """ + cleaned = raw.strip() + + # 移除思考内容(多语言支持) + for pattern in self._THINKING_PATTERNS: + cleaned = re.sub(pattern, "", cleaned, flags=re.DOTALL | re.IGNORECASE) + + # 移除markdown代码块标记 + if cleaned.startswith("```json"): + cleaned = cleaned[7:] + elif cleaned.startswith("```"): + cleaned = cleaned[3:] + + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + + cleaned = cleaned.strip() + + # 尝试提取第一个完整的JSON对象或数组 + cleaned = self._extract_first_json_structure(cleaned) + + return cleaned + + def _extract_first_json_structure(self, text: str) -> str: + """ + 从文本中提取第一个完整的JSON对象或数组。 + + 这对于处理LLM在JSON前后添加说明文字的情况很有用。 + + 参数: + text: 可能包含JSON的文本 + + 返回: + str: 提取的JSON文本,如果找不到则返回原文本 + """ + # 查找第一个 { 或 [ + start_brace = text.find("{") + start_bracket = text.find("[") + + if start_brace == -1 and start_bracket == -1: + return text + + # 确定起始位置 + if start_brace == -1: + start = start_bracket + opener = "[" + closer = "]" + elif start_bracket == -1: + start = start_brace + opener = "{" + closer = "}" + else: + start = min(start_brace, start_bracket) + opener = text[start] + closer = "}" if opener == "{" else "]" + + # 查找对应的结束位置 + depth = 0 + in_string = False + escaped = False + + for i in range(start, len(text)): + ch = text[i] + + if escaped: + escaped = False + continue + + if ch == "\\": + escaped = True + continue + + if ch == '"': + in_string = not in_string + continue + + if in_string: + continue + + if ch in "{[": + depth += 1 + elif ch in "}]": + depth -= 1 + if depth == 0: + return text[start : i + 1] + + # 如果没找到完整的结构,返回从起始位置到结尾 + return text[start:] if start < len(text) else text + + def _apply_local_repairs(self, text: str) -> str: + """ + 应用本地修复策略。 + + 参数: + text: 原始JSON文本 + + 返回: + str: 修复后的文本 + """ + repaired = text + mutated = False + + # 修复 ":=" 错误 + new_text = self._COLON_EQUALS_PATTERN.sub(r"\1", repaired) + if new_text != repaired: + logger.warning("检测到\":=\"字符,已自动移除多余的'='号") + repaired = new_text + mutated = True + + # 转义控制字符 + repaired, escaped = self._escape_control_characters(repaired) + if escaped: + logger.warning("检测到未转义的控制字符,已自动转换为转义序列") + mutated = True + + # 修复缺少的逗号 + repaired, commas_fixed = self._fix_missing_commas(repaired) + if commas_fixed: + logger.warning("检测到对象/数组之间缺少逗号,已自动补齐") + mutated = True + + # 平衡括号 + repaired, balanced = self._balance_brackets(repaired) + if balanced: + logger.warning("检测到括号不平衡,已自动补齐/剔除异常括号") + mutated = True + + # 移除尾随逗号 + repaired, trailing_removed = self._remove_trailing_commas(repaired) + if trailing_removed: + logger.warning("检测到尾随逗号,已自动移除") + mutated = True + + return repaired if mutated else text + + def _escape_control_characters(self, text: str) -> Tuple[str, bool]: + """ + 将字符串字面量中的裸换行/制表符/控制字符替换为JSON合法的转义序列。 + + 参数: + text: 原始JSON文本 + + 返回: + Tuple[str, bool]: (修复后的文本, 是否有修改) + """ + if not text: + return text, False + + result: List[str] = [] + in_string = False + escaped = False + mutated = False + control_map = {"\n": "\\n", "\r": "\\r", "\t": "\\t"} + + for ch in text: + if escaped: + result.append(ch) + escaped = False + continue + + if ch == "\\": + result.append(ch) + escaped = True + continue + + if ch == '"': + result.append(ch) + in_string = not in_string + continue + + if in_string and ch in control_map: + result.append(control_map[ch]) + mutated = True + continue + + if in_string and ord(ch) < 0x20: + result.append(f"\\u{ord(ch):04x}") + mutated = True + continue + + result.append(ch) + + return "".join(result), mutated + + def _fix_missing_commas(self, text: str) -> Tuple[str, bool]: + """ + 在对象/数组元素之间自动补逗号。 + + 参数: + text: 原始JSON文本 + + 返回: + Tuple[str, bool]: (修复后的文本, 是否有修改) + """ + if not text: + return text, False + + chars: List[str] = [] + mutated = False + in_string = False + escaped = False + length = len(text) + i = 0 + + while i < length: + ch = text[i] + chars.append(ch) + + if escaped: + escaped = False + i += 1 + continue + + if ch == "\\": + escaped = True + i += 1 + continue + + if ch == '"': + # 如果我们正在退出字符串,检查后面是否需要逗号 + if in_string: + # 查找下一个非空白字符 + j = i + 1 + while j < length and text[j] in " \t\r\n": + j += 1 + # 如果下一个字符是 " { [ 或数字,可能需要逗号 + if j < length: + next_ch = text[j] + if next_ch in "\"[{" or next_ch.isdigit(): + # 检查是否已经在对象或数组中 + # 通过检查前面是否有未闭合的 { 或 [ + has_opener = False + for k in range(len(chars) - 1, -1, -1): + if chars[k] in "{[": + has_opener = True + break + elif chars[k] in "]}": + break + + if has_opener: + chars.append(",") + mutated = True + + in_string = not in_string + i += 1 + continue + + # 在 } 或 ] 后面检查是否需要逗号 + if not in_string and ch in "}]": + j = i + 1 + # 跳过空白 + while j < length and text[j] in " \t\r\n": + j += 1 + # 如果下一个非空白字符是 { [ " 或数字,添加逗号 + if j < length: + next_ch = text[j] + if next_ch in "{[\"" or next_ch.isdigit(): + chars.append(",") + mutated = True + + i += 1 + + return "".join(chars), mutated + + def _balance_brackets(self, text: str) -> Tuple[str, bool]: + """ + 尝试修复因LLM多写/少写括号导致的不平衡结构。 + + 参数: + text: 原始JSON文本 + + 返回: + Tuple[str, bool]: (修复后的文本, 是否有修改) + """ + if not text: + return text, False + + result: List[str] = [] + stack: List[str] = [] + mutated = False + in_string = False + escaped = False + + opener_map = {"{": "}", "[": "]"} + + for ch in text: + if escaped: + result.append(ch) + escaped = False + continue + + if ch == "\\": + result.append(ch) + escaped = True + continue + + if ch == '"': + result.append(ch) + in_string = not in_string + continue + + if in_string: + result.append(ch) + continue + + if ch in "{[": + stack.append(ch) + result.append(ch) + continue + + if ch in "}]": + if stack and ( + (ch == "}" and stack[-1] == "{") or (ch == "]" and stack[-1] == "[") + ): + stack.pop() + result.append(ch) + else: + # 不匹配的闭括号,忽略 + mutated = True + continue + + result.append(ch) + + # 补齐未闭合的括号 + while stack: + opener = stack.pop() + result.append(opener_map[opener]) + mutated = True + + return "".join(result), mutated + + def _remove_trailing_commas(self, text: str) -> Tuple[str, bool]: + """ + 移除JSON对象和数组中的尾随逗号。 + + 参数: + text: 原始JSON文本 + + 返回: + Tuple[str, bool]: (修复后的文本, 是否有修改) + """ + if not text: + return text, False + + # 使用正则表达式移除尾随逗号 + # 匹配 , 后面跟着空白和 } 或 ] 的情况 + pattern = r",(\s*[}\]])" + new_text = re.sub(pattern, r"\1", text) + + return new_text, new_text != text + + def _attempt_json_repair(self, text: str, context_name: str) -> Optional[str]: + """ + 使用json_repair库进行高级修复。 + + 参数: + text: 原始JSON文本 + context_name: 上下文名称 + + 返回: + Optional[str]: 修复后的JSON文本,失败返回None + """ + if not _json_repair_fn: + return None + + try: + fixed = _json_repair_fn(text) + if fixed and fixed != text: + logger.info(f"{context_name} 使用json_repair库自动修复JSON") + return fixed + except Exception as exc: + logger.debug(f"{context_name} json_repair修复失败: {exc}") + + return None + + def _attempt_llm_repair( + self, text: str, error_msg: str, context_name: str + ) -> Optional[str]: + """ + 使用LLM进行JSON修复。 + + 参数: + text: 原始JSON文本 + error_msg: 解析错误信息 + context_name: 上下文名称 + + 返回: + Optional[str]: 修复后的JSON文本,失败返回None + """ + if not self.llm_repair_fn: + return None + + try: + logger.info(f"{context_name} 尝试使用LLM修复JSON") + repaired = self.llm_repair_fn(text, error_msg) + if repaired and repaired != text: + return repaired + except Exception as exc: + logger.warning(f"{context_name} LLM修复失败: {exc}") + + return None + + def _extract_and_validate( + self, + data: Any, + expected_keys: Optional[List[str]], + extract_wrapper_key: Optional[str], + context_name: str, + ) -> Dict[str, Any]: + """ + 提取并验证JSON数据。 + + 参数: + data: 解析后的数据 + expected_keys: 期望的键列表 + extract_wrapper_key: 包裹键名 + context_name: 上下文名称 + + 返回: + Dict[str, Any]: 提取并验证后的数据 + + 异常: + JSONParseError: 如果数据格式不符合预期 + """ + # 提取包裹的数据 + if extract_wrapper_key and isinstance(data, dict): + if extract_wrapper_key in data: + data = data[extract_wrapper_key] + else: + logger.warning( + f"{context_name} 未找到包裹键'{extract_wrapper_key}',使用原始数据" + ) + + # 验证数据类型 + if not isinstance(data, dict): + if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict): + logger.warning(f"{context_name} 返回数组,自动提取第一个元素") + data = data[0] + else: + raise JSONParseError( + f"{context_name} 返回的不是JSON对象: {type(data).__name__}" + ) + + # 验证必需的键 + if expected_keys: + missing_keys = [key for key in expected_keys if key not in data] + if missing_keys: + logger.warning( + f"{context_name} 缺少预期的键: {', '.join(missing_keys)}" + ) + + return data + + +__all__ = ["RobustJSONParser", "JSONParseError"] diff --git a/ReportEngine/utils/test_json_parser.py b/ReportEngine/utils/test_json_parser.py new file mode 100644 index 0000000..6c39069 --- /dev/null +++ b/ReportEngine/utils/test_json_parser.py @@ -0,0 +1,235 @@ +""" +测试RobustJSONParser的各种修复能力。 + +验证解析器能够处理: +1. 基本的markdown包裹 +2. 思考内容清理 +3. 缺少逗号的修复 +4. 括号不平衡的修复 +5. 控制字符转义 +6. 尾随逗号移除 +""" + +import json +import unittest +from json_parser import RobustJSONParser, JSONParseError + + +class TestRobustJSONParser(unittest.TestCase): + """测试鲁棒JSON解析器的各种修复策略。""" + + def setUp(self): + """初始化解析器。""" + self.parser = RobustJSONParser( + enable_json_repair=False, # 先测试本地修复 + enable_llm_repair=False, + ) + + def test_basic_json(self): + """测试解析基本的合法JSON。""" + json_str = '{"name": "test", "value": 123}' + result = self.parser.parse(json_str, "基本测试") + self.assertEqual(result["name"], "test") + self.assertEqual(result["value"], 123) + + def test_markdown_wrapped(self): + """测试解析被```json包裹的JSON。""" + json_str = """```json +{ + "name": "test", + "value": 123 +} +```""" + result = self.parser.parse(json_str, "Markdown包裹测试") + self.assertEqual(result["name"], "test") + self.assertEqual(result["value"], 123) + + def test_thinking_content_removal(self): + """测试清理思考内容。""" + json_str = """让我想想如何构造这个JSON +{ + "name": "test", + "value": 123 +}""" + result = self.parser.parse(json_str, "思考内容清理测试") + self.assertEqual(result["name"], "test") + self.assertEqual(result["value"], 123) + + def test_missing_comma_fix(self): + """测试修复缺少的逗号。""" + # 这是实际错误中常见的情况:数组元素之间缺少逗号 + json_str = """{ + "totalWords": 40000, + "globalGuidelines": [ + "重点突出技术红利分配失衡" + "详略策略:技术创新" + ], + "chapters": [] +}""" + result = self.parser.parse(json_str, "缺少逗号修复测试") + self.assertEqual(len(result["globalGuidelines"]), 2) + + def test_unbalanced_brackets(self): + """测试修复括号不平衡。""" + # 缺少结束括号 + json_str = """{ + "name": "test", + "nested": { + "value": 123 + } +""" # 缺少最外层的 } + result = self.parser.parse(json_str, "括号不平衡测试") + self.assertEqual(result["name"], "test") + self.assertEqual(result["nested"]["value"], 123) + + def test_control_character_escape(self): + """测试转义控制字符。""" + # JSON字符串中的裸换行符应该被转义 + json_str = """{ + "text": "这是第一行 +这是第二行", + "value": 123 +}""" + result = self.parser.parse(json_str, "控制字符转义测试") + # 确保换行符被正确处理 + self.assertIn("第一行", result["text"]) + self.assertIn("第二行", result["text"]) + + def test_trailing_comma_removal(self): + """测试移除尾随逗号。""" + json_str = """{ + "name": "test", + "value": 123, + "items": [1, 2, 3,], +}""" + result = self.parser.parse(json_str, "尾随逗号测试") + self.assertEqual(result["name"], "test") + self.assertEqual(len(result["items"]), 3) + + def test_colon_equals_fix(self): + """测试修复冒号等号错误。""" + json_str = """{ + "name":= "test", + "value": 123 +}""" + result = self.parser.parse(json_str, "冒号等号测试") + self.assertEqual(result["name"], "test") + + def test_extract_first_json(self): + """测试从文本中提取第一个JSON结构。""" + json_str = """这是一些说明文字,下面是JSON: +{ + "name": "test", + "value": 123 +} +后面还有一些其他文字""" + result = self.parser.parse(json_str, "提取JSON测试") + self.assertEqual(result["name"], "test") + self.assertEqual(result["value"], 123) + + def test_complex_real_world_case(self): + """测试真实世界的复杂案例(类似实际错误)。""" + # 模拟实际错误:缺少逗号、有markdown包裹、有思考内容 + json_str = """我需要构造一个篇幅规划 +```json +{ + "totalWords": 40000, + "tolerance": 2000, + "globalGuidelines": [ + "重点突出技术红利分配失衡、人才流失与职业认同危机等结构性矛盾" + "详略策略:技术创新与传统技艺的碰撞" + "案例导向:优先引用真实数据和调研" + ], + "chapters": [ + { + "chapterId": "ch1", + "targetWords": 5000 + } + ] +} +```""" + result = self.parser.parse(json_str, "复杂真实案例测试") + self.assertEqual(result["totalWords"], 40000) + self.assertEqual(result["tolerance"], 2000) + self.assertEqual(len(result["globalGuidelines"]), 3) + self.assertEqual(len(result["chapters"]), 1) + + def test_expected_keys_validation(self): + """测试期望键的验证。""" + json_str = '{"name": "test"}' + # 不应该因为缺少键而失败,只是警告 + result = self.parser.parse( + json_str, "键验证测试", expected_keys=["name", "value"] + ) + self.assertIn("name", result) + + def test_wrapper_key_extraction(self): + """测试从包裹键中提取数据。""" + json_str = """{ + "wrapper": { + "name": "test", + "value": 123 + } +}""" + result = self.parser.parse( + json_str, "包裹键测试", extract_wrapper_key="wrapper" + ) + self.assertEqual(result["name"], "test") + self.assertEqual(result["value"], 123) + + def test_empty_input(self): + """测试空输入。""" + with self.assertRaises(JSONParseError): + self.parser.parse("", "空输入测试") + + def test_invalid_json_after_all_repairs(self): + """测试所有修复策略都无法处理的情况。""" + # 这是一个严重损坏的JSON,无法修复 + json_str = "{完全不是JSON格式的内容###" + with self.assertRaises(JSONParseError): + self.parser.parse(json_str, "无法修复测试") + + +def run_manual_test(): + """手动运行测试,打印详细信息。""" + print("=" * 60) + print("开始测试RobustJSONParser") + print("=" * 60) + + parser = RobustJSONParser(enable_json_repair=False, enable_llm_repair=False) + + # 测试实际错误案例 + test_case = """```json +{ + "totalWords": 40000, + "tolerance": 2000, + "globalGuidelines": [ + "重点突出技术红利分配失衡、人才流失与职业认同危机等结构性矛盾" + "详略策略:技术创新与传统技艺的碰撞" + ], + "chapters": [] +} +```""" + + print("\n测试案例:") + print(test_case) + print("\n" + "=" * 60) + + try: + result = parser.parse(test_case, "手动测试") + print("\n✓ 解析成功!") + print("\n解析结果:") + print(json.dumps(result, ensure_ascii=False, indent=2)) + except Exception as e: + print(f"\n✗ 解析失败: {e}") + + print("\n" + "=" * 60) + + +if __name__ == "__main__": + # 运行手动测试 + run_manual_test() + + # 运行单元测试 + print("\n\n运行单元测试...") + unittest.main(verbosity=2)