| """ |
| JSON解析工具 - 处理LLM返回的JSON响应 |
| """ |
|
|
| import json |
| import re |
| import ast |
| from typing import Dict, Any, Optional, Iterable, Tuple |
| import logging |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class JSONParser: |
| """JSON解析器,用于处理LLM返回的各种格式的JSON响应""" |
| |
| @staticmethod |
| def extract_json_from_response(response: Any) -> Dict[str, Any]: |
| """ |
| 从LLM响应中提取JSON |
| |
| Args: |
| response: LLM的原始响应文本 |
| |
| Returns: |
| 解析后的JSON字典,如果解析失败则返回默认结构 |
| """ |
| if response is None: |
| logger.warning("收到空响应,返回默认JSON结构") |
| return JSONParser._get_default_structure() |
|
|
| if isinstance(response, dict): |
| return response |
|
|
| |
| if hasattr(response, "content") and isinstance(getattr(response, "content"), str): |
| response = getattr(response, "content") |
|
|
| if not isinstance(response, str): |
| response = str(response) |
|
|
| if not response.strip(): |
| logger.warning("收到空响应,返回默认JSON结构") |
| return JSONParser._get_default_structure() |
|
|
| response = response.strip() |
|
|
| candidates: list[str] = [response] |
|
|
| |
| for code_block in JSONParser._extract_fenced_code_blocks(response): |
| candidates.append(code_block) |
|
|
| cleaned_response = JSONParser._clean_response(response) |
| if cleaned_response: |
| candidates.append(cleaned_response) |
|
|
| |
| candidates.extend(JSONParser._extract_json_candidates(response)) |
|
|
| seen: set[str] = set() |
| for candidate in candidates: |
| candidate = candidate.strip() |
| if not candidate or candidate in seen: |
| continue |
| seen.add(candidate) |
|
|
| parsed = JSONParser._loads_best_effort(candidate) |
| if isinstance(parsed, dict): |
| return parsed |
| if isinstance(parsed, list) and all(isinstance(item, dict) for item in parsed): |
| |
| return { |
| "title": "PPT大纲", |
| "total_pages": len(parsed), |
| "page_count_mode": "estimated", |
| "slides": parsed, |
| } |
|
|
| logger.warning( |
| "所有JSON解析方法都失败,响应内容(截断): %s", |
| JSONParser._truncate_for_log(response), |
| ) |
| return JSONParser._get_default_structure() |
|
|
| @staticmethod |
| def _truncate_for_log(text: str, limit: int = 1200) -> str: |
| if len(text) <= limit: |
| return text |
| return text[:limit] + "…(truncated)" |
|
|
| @staticmethod |
| def _extract_fenced_code_blocks(text: str) -> Iterable[str]: |
| |
| for match in re.finditer(r"```(?:json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE): |
| block = match.group(1).strip() |
| |
| block = re.sub(r"^\s*json\s*\n", "", block, flags=re.IGNORECASE) |
| if block: |
| yield block |
|
|
| @staticmethod |
| def _extract_json_candidates(text: str, max_candidates: int = 20) -> list[str]: |
| candidates: list[str] = [] |
| starts = [m.start() for m in re.finditer(r"[\{\[]", text)] |
| for start in starts[:max_candidates]: |
| extracted, missing_closers = JSONParser._extract_balanced_json(text, start) |
| if extracted: |
| candidates.append(extracted) |
| if missing_closers: |
| candidates.append(extracted + missing_closers) |
| return candidates |
|
|
| @staticmethod |
| def _extract_balanced_json(text: str, start: int) -> Tuple[Optional[str], str]: |
| opener = text[start] |
| if opener not in "{[": |
| return None, "" |
|
|
| stack = [opener] |
| in_string = False |
| string_quote = "" |
| escape = False |
|
|
| for idx in range(start + 1, len(text)): |
| ch = text[idx] |
|
|
| if escape: |
| escape = False |
| continue |
|
|
| if in_string: |
| if ch == "\\": |
| escape = True |
| elif ch == string_quote: |
| in_string = False |
| string_quote = "" |
| continue |
|
|
| if ch in ("\"", "'"): |
| in_string = True |
| string_quote = ch |
| continue |
|
|
| if ch in "{[": |
| stack.append(ch) |
| continue |
|
|
| if ch in "}]": |
| if not stack: |
| return None, "" |
| expected = "}" if stack[-1] == "{" else "]" |
| if ch != expected: |
| return None, "" |
| stack.pop() |
| if not stack: |
| return text[start : idx + 1], "" |
|
|
| |
| missing = "".join("}" if s == "{" else "]" for s in reversed(stack)) |
| return text[start:], missing |
|
|
| @staticmethod |
| def _loads_best_effort(text: str) -> Any: |
| |
| try: |
| return json.loads(text) |
| except json.JSONDecodeError: |
| pass |
|
|
| |
| normalized = JSONParser._normalize_json_text(text) |
| if normalized and normalized != text: |
| try: |
| return json.loads(normalized) |
| except json.JSONDecodeError: |
| pass |
|
|
| |
| py_literal = JSONParser._to_python_literal(normalized or text) |
| if py_literal: |
| try: |
| return ast.literal_eval(py_literal) |
| except (ValueError, SyntaxError): |
| pass |
|
|
| return None |
|
|
| @staticmethod |
| def _normalize_json_text(text: str) -> str: |
| s = text.strip().lstrip("\ufeff") |
|
|
| |
| s = re.sub(r"^\s*(?:Here's the JSON:|Here is the JSON:|JSON:|Result:|Output:|Response:)\s*", "", s, flags=re.IGNORECASE) |
|
|
| |
| s = re.sub(r"^\s*```(?:json)?\s*\n", "", s, flags=re.IGNORECASE) |
| s = re.sub(r"\n\s*```\s*$", "", s) |
|
|
| |
| s = s.replace("“", "\"").replace("”", "\"").replace("‘", "'").replace("’", "'") |
|
|
| |
| s = JSONParser._remove_json_comments(s) |
|
|
| |
| s = re.sub(r";\s*$", "", s) |
|
|
| |
| s = re.sub(r",\s*([}\]])", r"\1", s) |
|
|
| |
| s = re.sub(r"(?:(?<=\[)|(?<=,))\s*\.\.\.\s*(?=,|\])", " null ", s) |
|
|
| return s.strip() |
|
|
| @staticmethod |
| def _remove_json_comments(text: str) -> str: |
| result_chars: list[str] = [] |
| in_string = False |
| string_quote = "" |
| escape = False |
| i = 0 |
|
|
| while i < len(text): |
| ch = text[i] |
|
|
| if escape: |
| result_chars.append(ch) |
| escape = False |
| i += 1 |
| continue |
|
|
| if in_string: |
| result_chars.append(ch) |
| if ch == "\\": |
| escape = True |
| elif ch == string_quote: |
| in_string = False |
| string_quote = "" |
| i += 1 |
| continue |
|
|
| if ch in ("\"", "'"): |
| in_string = True |
| string_quote = ch |
| result_chars.append(ch) |
| i += 1 |
| continue |
|
|
| |
| if ch == "/" and i + 1 < len(text) and text[i + 1] == "/": |
| i += 2 |
| while i < len(text) and text[i] not in "\r\n": |
| i += 1 |
| continue |
|
|
| |
| if ch == "/" and i + 1 < len(text) and text[i + 1] == "*": |
| i += 2 |
| while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"): |
| i += 1 |
| i += 2 if i + 1 < len(text) else 0 |
| continue |
|
|
| result_chars.append(ch) |
| i += 1 |
|
|
| return "".join(result_chars) |
|
|
| @staticmethod |
| def _to_python_literal(text: str) -> str: |
| """ |
| 将可能的“类JSON”文本转换为更容易被ast.literal_eval处理的形式: |
| - 将 true/false/null 转为 True/False/None(仅做简单的词边界替换) |
| """ |
| s = text.strip() |
| if not s: |
| return s |
| s = re.sub(r"\btrue\b", "True", s, flags=re.IGNORECASE) |
| s = re.sub(r"\bfalse\b", "False", s, flags=re.IGNORECASE) |
| s = re.sub(r"\bnull\b", "None", s, flags=re.IGNORECASE) |
| return s |
| |
| @staticmethod |
| def _clean_response(response: str) -> Optional[str]: |
| """ |
| 清理响应文本,尝试提取可能的JSON内容 |
| |
| Args: |
| response: 原始响应文本 |
| |
| Returns: |
| 清理后的文本,如果无法清理则返回None |
| """ |
| |
| prefixes_to_remove = [ |
| "Here's the JSON:", |
| "Here is the JSON:", |
| "JSON:", |
| "Result:", |
| "Output:", |
| "Response:", |
| ] |
| |
| cleaned = response.strip() |
| |
| for prefix in prefixes_to_remove: |
| if cleaned.lower().startswith(prefix.lower()): |
| cleaned = cleaned[len(prefix):].strip() |
| |
| |
| cleaned = re.sub(r'^```.*?\n', '', cleaned, flags=re.MULTILINE) |
| cleaned = re.sub(r'\n```$', '', cleaned, flags=re.MULTILINE) |
| |
| |
| first_brace = cleaned.find('{') |
| last_brace = cleaned.rfind('}') |
| |
| if first_brace != -1 and last_brace != -1 and first_brace < last_brace: |
| return cleaned[first_brace:last_brace + 1] |
| |
| return None |
| |
| @staticmethod |
| def _get_default_structure() -> Dict[str, Any]: |
| """ |
| 返回默认的JSON结构 |
| |
| Returns: |
| 默认的PPT大纲结构 |
| """ |
| return { |
| "title": "PPT大纲", |
| "total_pages": 10, |
| "page_count_mode": "estimated", |
| "slides": [ |
| { |
| "page_number": 1, |
| "title": "标题页", |
| "content_points": ["演示标题", "演示者信息", "日期"], |
| "slide_type": "title", |
| "description": "PPT的开场标题页" |
| } |
| ] |
| } |
| |
| @staticmethod |
| def validate_ppt_structure(data: Dict[str, Any]) -> Dict[str, Any]: |
| """ |
| 验证并修复PPT结构 |
| |
| Args: |
| data: 待验证的PPT数据 |
| |
| Returns: |
| 验证并修复后的PPT数据 |
| """ |
| |
| if "title" not in data: |
| data["title"] = "PPT大纲" |
| |
| if "slides" not in data or not isinstance(data["slides"], list): |
| data["slides"] = [] |
| |
| if "total_pages" not in data: |
| data["total_pages"] = len(data["slides"]) |
| |
| if "page_count_mode" not in data: |
| data["page_count_mode"] = "final" |
| |
| |
| valid_slides = [] |
| for i, slide in enumerate(data["slides"]): |
| if not isinstance(slide, dict): |
| continue |
| |
| |
| slide.setdefault("page_number", i + 1) |
| slide.setdefault("title", f"幻灯片 {i + 1}") |
| slide.setdefault("content_points", []) |
| slide.setdefault("slide_type", "content") |
| slide.setdefault("description", "") |
| |
| |
| if slide["slide_type"] not in ["title", "content", "conclusion"]: |
| slide["slide_type"] = "content" |
| |
| |
| if not isinstance(slide["content_points"], list): |
| slide["content_points"] = [] |
| |
| valid_slides.append(slide) |
| |
| data["slides"] = valid_slides |
| data["total_pages"] = len(valid_slides) |
| |
| return data |
|
|