为了在不同终端和环境中保持日志输出的整洁与一致性,统一移除了日志信息中的 emoji 符号。 此举旨在避免潜在的渲染问题,并使日志更易于程序化解析和人工阅读。同时,对部分代码进行了微小的类型标注优化。
241 lines
7.2 KiB
Python
241 lines
7.2 KiB
Python
"""
|
||
统一的 JSON 解析工具模块
|
||
|
||
提供统一的 LLM 响应 JSON 解析功能,使用 json_repair 库进行修复,
|
||
简化代码并提高解析成功率。
|
||
"""
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
import orjson
|
||
from json_repair import repair_json
|
||
|
||
from src.common.logger import get_logger
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
def extract_and_parse_json(response: str, *, strict: bool = False) -> dict[str, Any] | list | None:
|
||
"""
|
||
从 LLM 响应中提取并解析 JSON
|
||
|
||
处理策略:
|
||
1. 清理 Markdown 代码块标记(```json 和 ```)
|
||
2. 提取 JSON 对象或数组
|
||
3. 使用 json_repair 修复格式问题
|
||
4. 解析为 Python 对象
|
||
|
||
Args:
|
||
response: LLM 响应字符串
|
||
strict: 严格模式,如果为 True 则解析失败时返回 None,否则尝试容错处理
|
||
|
||
Returns:
|
||
解析后的 dict 或 list,失败时返回 None
|
||
|
||
Examples:
|
||
>>> extract_and_parse_json('```json\\n{"key": "value"}\\n```')
|
||
{'key': 'value'}
|
||
|
||
>>> extract_and_parse_json('Some text {"key": "value"} more text')
|
||
{'key': 'value'}
|
||
|
||
>>> extract_and_parse_json('[{"a": 1}, {"b": 2}]')
|
||
[{'a': 1}, {'b': 2}]
|
||
"""
|
||
if not response:
|
||
logger.debug("空响应,无法解析 JSON")
|
||
return None
|
||
|
||
try:
|
||
# 步骤 1: 清理响应
|
||
cleaned = _clean_llm_response(response)
|
||
|
||
if not cleaned:
|
||
logger.warning("清理后的响应为空")
|
||
return None
|
||
|
||
# 步骤 2: 尝试直接解析
|
||
try:
|
||
result = orjson.loads(cleaned)
|
||
logger.debug(f" JSON 直接解析成功,类型: {type(result).__name__}")
|
||
return result
|
||
except Exception as direct_error:
|
||
logger.debug(f"直接解析失败: {type(direct_error).__name__}: {direct_error}")
|
||
|
||
# 步骤 3: 使用 json_repair 修复并解析
|
||
try:
|
||
repaired = repair_json(cleaned)
|
||
|
||
# repair_json 可能返回字符串或已解析的对象
|
||
if isinstance(repaired, str):
|
||
result = orjson.loads(repaired)
|
||
logger.debug(f" JSON 修复后解析成功(字符串模式),类型: {type(result).__name__}")
|
||
else:
|
||
result = repaired
|
||
logger.debug(f" JSON 修复后解析成功(对象模式),类型: {type(result).__name__}")
|
||
|
||
return result
|
||
|
||
except Exception as repair_error:
|
||
logger.warning(f"JSON 修复失败: {type(repair_error).__name__}: {repair_error}")
|
||
|
||
if strict:
|
||
logger.error(f"严格模式下解析失败,响应片段: {cleaned[:200]}")
|
||
return None
|
||
|
||
# 最后的容错尝试:返回空字典或空列表
|
||
if cleaned.strip().startswith("["):
|
||
logger.warning("返回空列表作为容错")
|
||
return []
|
||
else:
|
||
logger.warning("返回空字典作为容错")
|
||
return {}
|
||
|
||
except Exception as e:
|
||
logger.error(f" JSON 解析过程出现异常: {type(e).__name__}: {e}")
|
||
if strict:
|
||
return None
|
||
return {} if not response.strip().startswith("[") else []
|
||
|
||
|
||
def _clean_llm_response(response: str) -> str:
|
||
"""
|
||
清理 LLM 响应,提取 JSON 部分
|
||
|
||
处理步骤:
|
||
1. 移除 Markdown 代码块标记(```json 和 ```)
|
||
2. 提取第一个完整的 JSON 对象 {...} 或数组 [...]
|
||
3. 清理多余的空格和换行
|
||
|
||
Args:
|
||
response: 原始 LLM 响应
|
||
|
||
Returns:
|
||
清理后的 JSON 字符串
|
||
"""
|
||
if not response:
|
||
return ""
|
||
|
||
cleaned = response.strip()
|
||
|
||
# 移除 Markdown 代码块标记
|
||
# 匹配 ```json ... ``` 或 ``` ... ```
|
||
code_block_patterns = [
|
||
r"```json\s*(.*?)```", # ```json ... ```
|
||
r"```\s*(.*?)```", # ``` ... ```
|
||
]
|
||
|
||
for pattern in code_block_patterns:
|
||
match = re.search(pattern, cleaned, re.IGNORECASE | re.DOTALL)
|
||
if match:
|
||
cleaned = match.group(1).strip()
|
||
logger.debug(f"从 Markdown 代码块中提取内容,长度: {len(cleaned)}")
|
||
break
|
||
|
||
# 提取 JSON 对象或数组
|
||
# 优先查找对象 {...},其次查找数组 [...]
|
||
for start_char, end_char in [("{", "}"), ("[", "]")]:
|
||
start_idx = cleaned.find(start_char)
|
||
if start_idx != -1:
|
||
# 使用栈匹配找到对应的结束符
|
||
extracted = _extract_balanced_json(cleaned, start_idx, start_char, end_char)
|
||
if extracted:
|
||
logger.debug(f"提取到 {start_char}...{end_char} 结构,长度: {len(extracted)}")
|
||
return extracted
|
||
|
||
# 如果没有找到明确的 JSON 结构,返回清理后的原始内容
|
||
logger.debug("未找到明确的 JSON 结构,返回清理后的原始内容")
|
||
return cleaned
|
||
|
||
|
||
def _extract_balanced_json(text: str, start_idx: int, start_char: str, end_char: str) -> str | None:
|
||
"""
|
||
从指定位置提取平衡的 JSON 结构
|
||
|
||
使用栈匹配算法找到对应的结束符,处理嵌套和字符串中的特殊字符
|
||
|
||
Args:
|
||
text: 源文本
|
||
start_idx: 起始字符的索引
|
||
start_char: 起始字符({ 或 [)
|
||
end_char: 结束字符(} 或 ])
|
||
|
||
Returns:
|
||
提取的 JSON 字符串,失败时返回 None
|
||
"""
|
||
depth = 0
|
||
in_string = False
|
||
escape_next = False
|
||
|
||
for i in range(start_idx, len(text)):
|
||
char = text[i]
|
||
|
||
# 处理转义字符
|
||
if escape_next:
|
||
escape_next = False
|
||
continue
|
||
|
||
if char == "\\":
|
||
escape_next = True
|
||
continue
|
||
|
||
# 处理字符串
|
||
if char == '"':
|
||
in_string = not in_string
|
||
continue
|
||
|
||
# 只在非字符串内处理括号
|
||
if not in_string:
|
||
if char == start_char:
|
||
depth += 1
|
||
elif char == end_char:
|
||
depth -= 1
|
||
if depth == 0:
|
||
# 找到匹配的结束符
|
||
return text[start_idx : i + 1].strip()
|
||
|
||
# 没有找到匹配的结束符
|
||
logger.debug(f"未找到匹配的 {end_char},深度: {depth}")
|
||
return None
|
||
|
||
|
||
def safe_parse_json(json_str: str, default: Any = None) -> Any:
|
||
"""
|
||
安全解析 JSON,失败时返回默认值
|
||
|
||
Args:
|
||
json_str: JSON 字符串
|
||
default: 解析失败时返回的默认值
|
||
|
||
Returns:
|
||
解析结果或默认值
|
||
"""
|
||
try:
|
||
result = extract_and_parse_json(json_str, strict=False)
|
||
return result if result is not None else default
|
||
except Exception as e:
|
||
logger.warning(f"安全解析 JSON 失败: {e}")
|
||
return default
|
||
|
||
|
||
def extract_json_field(response: str, field_name: str, default: Any = None) -> Any:
|
||
"""
|
||
从 LLM 响应中提取特定字段的值
|
||
|
||
Args:
|
||
response: LLM 响应
|
||
field_name: 字段名
|
||
default: 字段不存在时的默认值
|
||
|
||
Returns:
|
||
字段值或默认值
|
||
"""
|
||
parsed = extract_and_parse_json(response, strict=False)
|
||
|
||
if isinstance(parsed, dict):
|
||
return parsed.get(field_name, default)
|
||
|
||
logger.warning(f"解析结果不是字典,无法提取字段 '{field_name}'")
|
||
return default
|