Fixed the Error "AttributeError: 'list' object has no attribute 'get'"
Showing
2 changed files
with
171 additions
and
15 deletions
| @@ -39,6 +39,10 @@ from .state import ReportState | @@ -39,6 +39,10 @@ from .state import ReportState | ||
| 39 | from .utils.config import settings, Settings | 39 | from .utils.config import settings, Settings |
| 40 | 40 | ||
| 41 | 41 | ||
| 42 | +class StageOutputFormatError(ValueError): | ||
| 43 | + """阶段性输出结构不符合预期时抛出的受控异常。""" | ||
| 44 | + | ||
| 45 | + | ||
| 42 | class FileCountBaseline: | 46 | class FileCountBaseline: |
| 43 | """ | 47 | """ |
| 44 | 文件数量基准管理器。 | 48 | 文件数量基准管理器。 |
| @@ -177,6 +181,7 @@ class ReportAgent: | @@ -177,6 +181,7 @@ class ReportAgent: | ||
| 177 | """ | 181 | """ |
| 178 | _CONTENT_SPARSE_MIN_ATTEMPTS = 3 | 182 | _CONTENT_SPARSE_MIN_ATTEMPTS = 3 |
| 179 | _CONTENT_SPARSE_WARNING_TEXT = "本章LLM生成的内容字数可能过低,必要时可以尝试重新运行程序。" | 183 | _CONTENT_SPARSE_WARNING_TEXT = "本章LLM生成的内容字数可能过低,必要时可以尝试重新运行程序。" |
| 184 | + _STRUCTURAL_RETRY_ATTEMPTS = 2 | ||
| 180 | 185 | ||
| 181 | def __init__(self, config: Optional[Settings] = None): | 186 | def __init__(self, config: Optional[Settings] = None): |
| 182 | """ | 187 | """ |
| @@ -421,6 +426,11 @@ class ReportAgent: | @@ -421,6 +426,11 @@ class ReportAgent: | ||
| 421 | 426 | ||
| 422 | try: | 427 | try: |
| 423 | template_result = self._select_template(query, reports, forum_logs, custom_template) | 428 | template_result = self._select_template(query, reports, forum_logs, custom_template) |
| 429 | + template_result = self._ensure_mapping( | ||
| 430 | + template_result, | ||
| 431 | + "模板选择结果", | ||
| 432 | + expected_keys=["template_name", "template_content"], | ||
| 433 | + ) | ||
| 424 | self.state.metadata.template_used = template_result.get('template_name', '') | 434 | self.state.metadata.template_used = template_result.get('template_name', '') |
| 425 | emit('stage', { | 435 | emit('stage', { |
| 426 | 'stage': 'template_selected', | 436 | 'stage': 'template_selected', |
| @@ -436,13 +446,17 @@ class ReportAgent: | @@ -436,13 +446,17 @@ class ReportAgent: | ||
| 436 | template_text = template_result.get('template_content', '') | 446 | template_text = template_result.get('template_content', '') |
| 437 | template_overview = self._build_template_overview(template_text, sections) | 447 | template_overview = self._build_template_overview(template_text, sections) |
| 438 | # 基于模板骨架+三引擎内容设计全局标题、目录与视觉主题 | 448 | # 基于模板骨架+三引擎内容设计全局标题、目录与视觉主题 |
| 439 | - layout_design = self.document_layout_node.run( | ||
| 440 | - sections, | ||
| 441 | - template_text, | ||
| 442 | - normalized_reports, | ||
| 443 | - forum_logs, | ||
| 444 | - query, | ||
| 445 | - template_overview, | 449 | + layout_design = self._run_stage_with_retry( |
| 450 | + "文档设计", | ||
| 451 | + lambda: self.document_layout_node.run( | ||
| 452 | + sections, | ||
| 453 | + template_text, | ||
| 454 | + normalized_reports, | ||
| 455 | + forum_logs, | ||
| 456 | + query, | ||
| 457 | + template_overview, | ||
| 458 | + ), | ||
| 459 | + expected_keys=["title", "hero", "toc", "tocPlan"], | ||
| 446 | ) | 460 | ) |
| 447 | emit('stage', { | 461 | emit('stage', { |
| 448 | 'stage': 'layout_designed', | 462 | 'stage': 'layout_designed', |
| @@ -451,13 +465,18 @@ class ReportAgent: | @@ -451,13 +465,18 @@ class ReportAgent: | ||
| 451 | }) | 465 | }) |
| 452 | emit('progress', {'progress': 15, 'message': '文档标题/目录设计完成'}) | 466 | emit('progress', {'progress': 15, 'message': '文档标题/目录设计完成'}) |
| 453 | # 使用刚生成的设计稿对全书进行篇幅规划,约束各章字数与重点 | 467 | # 使用刚生成的设计稿对全书进行篇幅规划,约束各章字数与重点 |
| 454 | - word_plan = self.word_budget_node.run( | ||
| 455 | - sections, | ||
| 456 | - layout_design, | ||
| 457 | - normalized_reports, | ||
| 458 | - forum_logs, | ||
| 459 | - query, | ||
| 460 | - template_overview, | 468 | + word_plan = self._run_stage_with_retry( |
| 469 | + "章节篇幅规划", | ||
| 470 | + lambda: self.word_budget_node.run( | ||
| 471 | + sections, | ||
| 472 | + layout_design, | ||
| 473 | + normalized_reports, | ||
| 474 | + forum_logs, | ||
| 475 | + query, | ||
| 476 | + template_overview, | ||
| 477 | + ), | ||
| 478 | + expected_keys=["chapters", "totalWords", "globalGuidelines"], | ||
| 479 | + postprocess=self._normalize_word_plan, | ||
| 461 | ) | 480 | ) |
| 462 | emit('stage', { | 481 | emit('stage', { |
| 463 | 'stage': 'word_plan_ready', | 482 | 'stage': 'word_plan_ready', |
| @@ -871,6 +890,134 @@ class ReportAgent: | @@ -871,6 +890,134 @@ class ReportAgent: | ||
| 871 | ] | 890 | ] |
| 872 | return any(keyword in normalized for keyword in keywords) | 891 | return any(keyword in normalized for keyword in keywords) |
| 873 | 892 | ||
| 893 | + def _run_stage_with_retry( | ||
| 894 | + self, | ||
| 895 | + stage_name: str, | ||
| 896 | + fn: Callable[[], Any], | ||
| 897 | + expected_keys: Optional[List[str]] = None, | ||
| 898 | + postprocess: Optional[Callable[[Dict[str, Any], str], Dict[str, Any]]] = None, | ||
| 899 | + ) -> Dict[str, Any]: | ||
| 900 | + """ | ||
| 901 | + 运行单个LLM阶段并在结构异常时有限次重试。 | ||
| 902 | + | ||
| 903 | + 该方法只针对结构类错误做本地修复/重试,避免整个Agent重启。 | ||
| 904 | + """ | ||
| 905 | + last_error: Optional[Exception] = None | ||
| 906 | + for attempt in range(1, self._STRUCTURAL_RETRY_ATTEMPTS + 1): | ||
| 907 | + try: | ||
| 908 | + raw_result = fn() | ||
| 909 | + result = self._ensure_mapping(raw_result, stage_name, expected_keys) | ||
| 910 | + if postprocess: | ||
| 911 | + result = postprocess(result, stage_name) | ||
| 912 | + return result | ||
| 913 | + except StageOutputFormatError as exc: | ||
| 914 | + last_error = exc | ||
| 915 | + logger.warning( | ||
| 916 | + "{stage} 输出结构异常(第 {attempt}/{total} 次),将尝试修复或重试: {error}", | ||
| 917 | + stage=stage_name, | ||
| 918 | + attempt=attempt, | ||
| 919 | + total=self._STRUCTURAL_RETRY_ATTEMPTS, | ||
| 920 | + error=exc, | ||
| 921 | + ) | ||
| 922 | + if attempt >= self._STRUCTURAL_RETRY_ATTEMPTS: | ||
| 923 | + break | ||
| 924 | + raise last_error # type: ignore[misc] | ||
| 925 | + | ||
| 926 | + def _ensure_mapping( | ||
| 927 | + self, | ||
| 928 | + value: Any, | ||
| 929 | + context: str, | ||
| 930 | + expected_keys: Optional[List[str]] = None, | ||
| 931 | + ) -> Dict[str, Any]: | ||
| 932 | + """ | ||
| 933 | + 确保阶段输出为dict;若返回列表则尝试提取最佳匹配元素。 | ||
| 934 | + """ | ||
| 935 | + if isinstance(value, dict): | ||
| 936 | + return value | ||
| 937 | + | ||
| 938 | + if isinstance(value, list): | ||
| 939 | + candidates = [item for item in value if isinstance(item, dict)] | ||
| 940 | + if candidates: | ||
| 941 | + best = candidates[0] | ||
| 942 | + if expected_keys: | ||
| 943 | + candidates.sort( | ||
| 944 | + key=lambda item: sum(1 for key in expected_keys if key in item), | ||
| 945 | + reverse=True, | ||
| 946 | + ) | ||
| 947 | + best = candidates[0] | ||
| 948 | + logger.warning( | ||
| 949 | + "{context} 返回列表,已自动提取包含最多预期键的元素继续执行", | ||
| 950 | + context=context, | ||
| 951 | + ) | ||
| 952 | + return best | ||
| 953 | + raise StageOutputFormatError(f"{context} 返回列表但缺少可用的对象元素") | ||
| 954 | + | ||
| 955 | + if value is None: | ||
| 956 | + raise StageOutputFormatError(f"{context} 返回空结果") | ||
| 957 | + | ||
| 958 | + raise StageOutputFormatError( | ||
| 959 | + f"{context} 返回类型 {type(value).__name__},期望字典" | ||
| 960 | + ) | ||
| 961 | + | ||
| 962 | + def _normalize_word_plan(self, word_plan: Dict[str, Any], stage_name: str) -> Dict[str, Any]: | ||
| 963 | + """ | ||
| 964 | + 清洗篇幅规划结果,确保 chapters/globalGuidelines/totalWords 类型安全。 | ||
| 965 | + """ | ||
| 966 | + raw_chapters = word_plan.get("chapters", []) | ||
| 967 | + if isinstance(raw_chapters, dict): | ||
| 968 | + chapters_iterable = raw_chapters.values() | ||
| 969 | + elif isinstance(raw_chapters, list): | ||
| 970 | + chapters_iterable = raw_chapters | ||
| 971 | + else: | ||
| 972 | + chapters_iterable = [] | ||
| 973 | + | ||
| 974 | + normalized: List[Dict[str, Any]] = [] | ||
| 975 | + for idx, entry in enumerate(chapters_iterable): | ||
| 976 | + if isinstance(entry, dict): | ||
| 977 | + normalized.append(entry) | ||
| 978 | + continue | ||
| 979 | + if isinstance(entry, list): | ||
| 980 | + dict_candidate = next((item for item in entry if isinstance(item, dict)), None) | ||
| 981 | + if dict_candidate: | ||
| 982 | + logger.warning( | ||
| 983 | + "{stage} 第 {idx} 个章节条目为列表,已提取首个对象用于后续流程", | ||
| 984 | + stage=stage_name, | ||
| 985 | + idx=idx + 1, | ||
| 986 | + ) | ||
| 987 | + normalized.append(dict_candidate) | ||
| 988 | + continue | ||
| 989 | + logger.warning( | ||
| 990 | + "{stage} 跳过无法解析的章节条目#{idx}(类型: {type_name})", | ||
| 991 | + stage=stage_name, | ||
| 992 | + idx=idx + 1, | ||
| 993 | + type_name=type(entry).__name__, | ||
| 994 | + ) | ||
| 995 | + | ||
| 996 | + if not normalized: | ||
| 997 | + raise StageOutputFormatError(f"{stage_name} 缺少有效的章节规划,无法继续") | ||
| 998 | + | ||
| 999 | + word_plan["chapters"] = normalized | ||
| 1000 | + | ||
| 1001 | + guidelines = word_plan.get("globalGuidelines") | ||
| 1002 | + if not isinstance(guidelines, list): | ||
| 1003 | + if guidelines is None or guidelines == "": | ||
| 1004 | + word_plan["globalGuidelines"] = [] | ||
| 1005 | + else: | ||
| 1006 | + logger.warning( | ||
| 1007 | + "{stage} globalGuidelines 类型异常,已转换为列表封装", | ||
| 1008 | + stage=stage_name, | ||
| 1009 | + ) | ||
| 1010 | + word_plan["globalGuidelines"] = [guidelines] | ||
| 1011 | + | ||
| 1012 | + if not isinstance(word_plan.get("totalWords"), (int, float)): | ||
| 1013 | + logger.warning( | ||
| 1014 | + "{stage} totalWords 类型异常,使用默认值 10000", | ||
| 1015 | + stage=stage_name, | ||
| 1016 | + ) | ||
| 1017 | + word_plan["totalWords"] = 10000 | ||
| 1018 | + | ||
| 1019 | + return word_plan | ||
| 1020 | + | ||
| 874 | def _finalize_sparse_chapter(self, chapter: Optional[Dict[str, Any]]) -> Dict[str, Any]: | 1021 | def _finalize_sparse_chapter(self, chapter: Optional[Dict[str, Any]]) -> Dict[str, Any]: |
| 875 | """ | 1022 | """ |
| 876 | 构造内容稀疏兜底章节:复制原始payload并插入温馨提示段落。 | 1023 | 构造内容稀疏兜底章节:复制原始payload并插入温馨提示段落。 |
| @@ -584,6 +584,9 @@ def generate_report(): | @@ -584,6 +584,9 @@ def generate_report(): | ||
| 584 | 584 | ||
| 585 | # 获取请求参数 | 585 | # 获取请求参数 |
| 586 | data = request.get_json() or {} | 586 | data = request.get_json() or {} |
| 587 | + if not isinstance(data, dict): | ||
| 588 | + logger.warning("generate_report 接收到非对象JSON负载,已忽略原始内容") | ||
| 589 | + data = {} | ||
| 587 | query = data.get('query', '智能舆情分析报告') | 590 | query = data.get('query', '智能舆情分析报告') |
| 588 | custom_template = data.get('custom_template', '') | 591 | custom_template = data.get('custom_template', '') |
| 589 | 592 | ||
| @@ -1285,7 +1288,13 @@ def export_pdf_from_ir(): | @@ -1285,7 +1288,13 @@ def export_pdf_from_ir(): | ||
| 1285 | 'system_message': pango_message | 1288 | 'system_message': pango_message |
| 1286 | }), 503 | 1289 | }), 503 |
| 1287 | 1290 | ||
| 1288 | - data = request.get_json() | 1291 | + data = request.get_json() or {} |
| 1292 | + if not isinstance(data, dict): | ||
| 1293 | + logger.warning("export_pdf_from_ir 请求体不是JSON对象") | ||
| 1294 | + return jsonify({ | ||
| 1295 | + 'success': False, | ||
| 1296 | + 'error': '请求体必须是JSON对象' | ||
| 1297 | + }), 400 | ||
| 1289 | 1298 | ||
| 1290 | if not data or 'document_ir' not in data: | 1299 | if not data or 'document_ir' not in data: |
| 1291 | return jsonify({ | 1300 | return jsonify({ |
-
Please register or login to post a comment