Fixed Directory Parsing Issues and Optimized Directory Rendering
Showing
3 changed files
with
215 additions
and
8 deletions
| @@ -114,16 +114,94 @@ class DocumentLayoutNode(BaseNode): | @@ -114,16 +114,94 @@ class DocumentLayoutNode(BaseNode): | ||
| 114 | if not isinstance(result.get("title"), str): | 114 | if not isinstance(result.get("title"), str): |
| 115 | logger.warning("文档设计缺少title字段或类型错误,使用默认值") | 115 | logger.warning("文档设计缺少title字段或类型错误,使用默认值") |
| 116 | result.setdefault("title", "未命名报告") | 116 | result.setdefault("title", "未命名报告") |
| 117 | - if not isinstance(result.get("toc"), (list, dict)): | ||
| 118 | - logger.warning("文档设计缺少toc字段或类型错误,使用空列表") | ||
| 119 | - result.setdefault("toc", []) | 117 | + |
| 118 | + # 处理tocPlan字段 | ||
| 119 | + toc_plan = result.get("tocPlan", []) | ||
| 120 | + if not isinstance(toc_plan, list): | ||
| 121 | + logger.warning("文档设计缺少tocPlan字段或类型错误,使用空列表") | ||
| 122 | + result["tocPlan"] = [] | ||
| 123 | + else: | ||
| 124 | + # 清理tocPlan中的description字段 | ||
| 125 | + result["tocPlan"] = self._clean_toc_plan_descriptions(toc_plan) | ||
| 126 | + | ||
| 120 | if not isinstance(result.get("hero"), dict): | 127 | if not isinstance(result.get("hero"), dict): |
| 121 | logger.warning("文档设计缺少hero字段或类型错误,使用空对象") | 128 | logger.warning("文档设计缺少hero字段或类型错误,使用空对象") |
| 122 | result.setdefault("hero", {}) | 129 | result.setdefault("hero", {}) |
| 130 | + | ||
| 123 | return result | 131 | return result |
| 124 | except JSONParseError as exc: | 132 | except JSONParseError as exc: |
| 125 | # 转换为原有的异常类型以保持向后兼容 | 133 | # 转换为原有的异常类型以保持向后兼容 |
| 126 | raise ValueError(f"文档设计JSON解析失败: {exc}") from exc | 134 | raise ValueError(f"文档设计JSON解析失败: {exc}") from exc |
| 127 | 135 | ||
| 136 | + def _clean_toc_plan_descriptions(self, toc_plan: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | ||
| 137 | + """ | ||
| 138 | + 清理tocPlan中每个条目的description字段,移除可能的JSON片段。 | ||
| 139 | + | ||
| 140 | + 参数: | ||
| 141 | + toc_plan: 原始的目录计划列表 | ||
| 142 | + | ||
| 143 | + 返回: | ||
| 144 | + List[Dict[str, Any]]: 清理后的目录计划列表 | ||
| 145 | + """ | ||
| 146 | + import re | ||
| 147 | + | ||
| 148 | + def clean_text(text: Any) -> str: | ||
| 149 | + """清理文本中的JSON片段""" | ||
| 150 | + if not text or not isinstance(text, str): | ||
| 151 | + return "" | ||
| 152 | + | ||
| 153 | + cleaned = text | ||
| 154 | + | ||
| 155 | + # 移除以逗号+空白+{开头的不完整JSON对象 | ||
| 156 | + cleaned = re.sub(r',\s*\{[^}]*$', '', cleaned) | ||
| 157 | + | ||
| 158 | + # 移除以逗号+空白+[开头的不完整JSON数组 | ||
| 159 | + cleaned = re.sub(r',\s*\[[^\]]*$', '', cleaned) | ||
| 160 | + | ||
| 161 | + # 移除孤立的 { 加上后续内容(如果没有匹配的 }) | ||
| 162 | + open_brace_pos = cleaned.rfind('{') | ||
| 163 | + if open_brace_pos != -1: | ||
| 164 | + close_brace_pos = cleaned.rfind('}') | ||
| 165 | + if close_brace_pos < open_brace_pos: | ||
| 166 | + cleaned = cleaned[:open_brace_pos].rstrip(',,、 \t\n') | ||
| 167 | + | ||
| 168 | + # 移除孤立的 [ 加上后续内容(如果没有匹配的 ]) | ||
| 169 | + open_bracket_pos = cleaned.rfind('[') | ||
| 170 | + if open_bracket_pos != -1: | ||
| 171 | + close_bracket_pos = cleaned.rfind(']') | ||
| 172 | + if close_bracket_pos < open_bracket_pos: | ||
| 173 | + cleaned = cleaned[:open_bracket_pos].rstrip(',,、 \t\n') | ||
| 174 | + | ||
| 175 | + # 移除看起来像JSON键值对的片段 | ||
| 176 | + cleaned = re.sub(r',?\s*"[^"]+"\s*:\s*"[^"]*$', '', cleaned) | ||
| 177 | + cleaned = re.sub(r',?\s*"[^"]+"\s*:\s*[^,}\]]*$', '', cleaned) | ||
| 178 | + | ||
| 179 | + # 清理末尾的逗号和空白 | ||
| 180 | + cleaned = cleaned.rstrip(',,、 \t\n') | ||
| 181 | + | ||
| 182 | + return cleaned.strip() | ||
| 183 | + | ||
| 184 | + cleaned_plan = [] | ||
| 185 | + for entry in toc_plan: | ||
| 186 | + if not isinstance(entry, dict): | ||
| 187 | + continue | ||
| 188 | + | ||
| 189 | + # 清理description字段 | ||
| 190 | + if "description" in entry: | ||
| 191 | + original_desc = entry["description"] | ||
| 192 | + cleaned_desc = clean_text(original_desc) | ||
| 193 | + | ||
| 194 | + if cleaned_desc != original_desc: | ||
| 195 | + logger.warning( | ||
| 196 | + f"清理目录项 '{entry.get('display', 'unknown')}' 的description字段中的JSON片段:\n" | ||
| 197 | + f" 原文: {original_desc[:100]}...\n" | ||
| 198 | + f" 清理后: {cleaned_desc[:100]}..." | ||
| 199 | + ) | ||
| 200 | + entry["description"] = cleaned_desc | ||
| 201 | + | ||
| 202 | + cleaned_plan.append(entry) | ||
| 203 | + | ||
| 204 | + return cleaned_plan | ||
| 205 | + | ||
| 128 | 206 | ||
| 129 | __all__ = ["DocumentLayoutNode"] | 207 | __all__ = ["DocumentLayoutNode"] |
| @@ -369,13 +369,21 @@ SYSTEM_PROMPT_DOCUMENT_LAYOUT = f""" | @@ -369,13 +369,21 @@ SYSTEM_PROMPT_DOCUMENT_LAYOUT = f""" | ||
| 369 | 输入包含 templateOverview(模板标题+目录整体)、sections 列表以及多源报告,请先把模板标题和目录当成一个整体,与多引擎内容对照后设计标题与目录,再延伸出可直接渲染的视觉主题。你的输出会被独立存储以便后续拼接,请确保字段齐备。 | 369 | 输入包含 templateOverview(模板标题+目录整体)、sections 列表以及多源报告,请先把模板标题和目录当成一个整体,与多引擎内容对照后设计标题与目录,再延伸出可直接渲染的视觉主题。你的输出会被独立存储以便后续拼接,请确保字段齐备。 |
| 370 | 370 | ||
| 371 | 目标: | 371 | 目标: |
| 372 | -1. 生成具有中文叙事风格的 title/subtitle/tagline,并确保可直接放在封面中央,文案中需自然提到“文章总览”; | 372 | +1. 生成具有中文叙事风格的 title/subtitle/tagline,并确保可直接放在封面中央,文案中需自然提到"文章总览"; |
| 373 | 2. 给出 hero:包含summary、highlights、actions、kpis(可含tone/delta),用于强调重点洞察与执行提示; | 373 | 2. 给出 hero:包含summary、highlights、actions、kpis(可含tone/delta),用于强调重点洞察与执行提示; |
| 374 | -3. 输出 tocPlan,一级目录固定用中文数字(“一、二、三”),二级目录用“1.1/1.2”,可在description里说明详略;如需定制目录标题,请填写 tocTitle; | 374 | +3. 输出 tocPlan,一级目录固定用中文数字("一、二、三"),二级目录用"1.1/1.2",可在description里说明详略;如需定制目录标题,请填写 tocTitle; |
| 375 | 4. 根据模板结构和素材密度,为 themeTokens / layoutNotes 提出字体、字号、留白建议(需特别强调目录、正文一级标题字号保持统一),如需色板或暗黑模式兼容也在此说明; | 375 | 4. 根据模板结构和素材密度,为 themeTokens / layoutNotes 提出字体、字号、留白建议(需特别强调目录、正文一级标题字号保持统一),如需色板或暗黑模式兼容也在此说明; |
| 376 | 5. 严禁要求外部图片或AI生图,推荐Chart.js图表、表格、色块、KPI卡等可直接渲染的原生组件; | 376 | 5. 严禁要求外部图片或AI生图,推荐Chart.js图表、表格、色块、KPI卡等可直接渲染的原生组件; |
| 377 | 6. 不随意增删章节,仅优化命名或描述;若有排版或章节合并提示,请放入 layoutNotes,渲染层会严格遵循。 | 377 | 6. 不随意增删章节,仅优化命名或描述;若有排版或章节合并提示,请放入 layoutNotes,渲染层会严格遵循。 |
| 378 | 378 | ||
| 379 | +**tocPlan的description字段特别要求:** | ||
| 380 | +- description字段必须是纯文本描述,用于在目录中展示章节简介 | ||
| 381 | +- 严禁在description字段中嵌套JSON结构、对象、数组或任何特殊标记 | ||
| 382 | +- description应该是简洁的一句话或一小段话,描述该章节的核心内容 | ||
| 383 | +- 错误示例:{{"description": "描述内容,{{\"chapterId\": \"S3\"}}"}} | ||
| 384 | +- 正确示例:{{"description": "描述内容,详细分析章节要点"}} | ||
| 385 | +- 如果需要关联chapterId,请使用tocPlan对象的chapterId字段,不要写在description中 | ||
| 386 | + | ||
| 379 | 输出必须满足下述JSON Schema: | 387 | 输出必须满足下述JSON Schema: |
| 380 | <OUTPUT JSON SCHEMA> | 388 | <OUTPUT JSON SCHEMA> |
| 381 | {json.dumps(document_layout_output_schema, ensure_ascii=False, indent=2)} | 389 | {json.dumps(document_layout_output_schema, ensure_ascii=False, indent=2)} |
| @@ -391,7 +399,9 @@ SYSTEM_PROMPT_DOCUMENT_LAYOUT = f""" | @@ -391,7 +399,9 @@ SYSTEM_PROMPT_DOCUMENT_LAYOUT = f""" | ||
| 391 | - 括号必须成对且正确嵌套 | 399 | - 括号必须成对且正确嵌套 |
| 392 | - 不要使用尾随逗号(最后一个元素后不加逗号) | 400 | - 不要使用尾随逗号(最后一个元素后不加逗号) |
| 393 | - 不要在JSON中添加注释 | 401 | - 不要在JSON中添加注释 |
| 402 | + - description等文本字段中不得包含JSON结构 | ||
| 394 | 5. 所有字符串值使用双引号,数值不使用引号 | 403 | 5. 所有字符串值使用双引号,数值不使用引号 |
| 404 | +6. 再次强调:tocPlan中每个条目的description必须是纯文本,不能包含任何JSON片段 | ||
| 395 | """ | 405 | """ |
| 396 | 406 | ||
| 397 | # 篇幅规划提示词 | 407 | # 篇幅规划提示词 |
| @@ -9,6 +9,7 @@ import copy | @@ -9,6 +9,7 @@ import copy | ||
| 9 | import html | 9 | import html |
| 10 | import json | 10 | import json |
| 11 | import os | 11 | import os |
| 12 | +import re | ||
| 12 | from pathlib import Path | 13 | from pathlib import Path |
| 13 | from typing import Any, Dict, List | 14 | from typing import Any, Dict, List |
| 14 | from loguru import logger | 15 | from loguru import logger |
| @@ -451,23 +452,44 @@ class HTMLRenderer: | @@ -451,23 +452,44 @@ class HTMLRenderer: | ||
| 451 | chapters: Document IR中的章节数组。 | 452 | chapters: Document IR中的章节数组。 |
| 452 | 453 | ||
| 453 | 返回: | 454 | 返回: |
| 454 | - list[dict]: 规范化后的目录条目,包含level/text/anchor。 | 455 | + list[dict]: 规范化后的目录条目,包含level/text/anchor/description。 |
| 455 | """ | 456 | """ |
| 456 | metadata = self.metadata | 457 | metadata = self.metadata |
| 457 | toc_config = metadata.get("toc") or {} | 458 | toc_config = metadata.get("toc") or {} |
| 458 | custom_entries = toc_config.get("customEntries") | 459 | custom_entries = toc_config.get("customEntries") |
| 459 | entries: List[Dict[str, Any]] = [] | 460 | entries: List[Dict[str, Any]] = [] |
| 461 | + | ||
| 460 | if custom_entries: | 462 | if custom_entries: |
| 461 | for entry in custom_entries: | 463 | for entry in custom_entries: |
| 462 | anchor = entry.get("anchor") or self.chapter_anchor_map.get(entry.get("chapterId")) | 464 | anchor = entry.get("anchor") or self.chapter_anchor_map.get(entry.get("chapterId")) |
| 465 | + | ||
| 466 | + # 验证anchor是否有效 | ||
| 463 | if not anchor: | 467 | if not anchor: |
| 468 | + logger.warning( | ||
| 469 | + f"目录项 '{entry.get('display') or entry.get('title')}' " | ||
| 470 | + f"缺少有效的anchor,已跳过" | ||
| 471 | + ) | ||
| 464 | continue | 472 | continue |
| 473 | + | ||
| 474 | + # 验证anchor是否在chapter_anchor_map中或在chapters的blocks中 | ||
| 475 | + anchor_valid = self._validate_toc_anchor(anchor, chapters) | ||
| 476 | + if not anchor_valid: | ||
| 477 | + logger.warning( | ||
| 478 | + f"目录项 '{entry.get('display') or entry.get('title')}' " | ||
| 479 | + f"的anchor '{anchor}' 在文档中未找到对应的章节" | ||
| 480 | + ) | ||
| 481 | + | ||
| 482 | + # 清理描述文本 | ||
| 483 | + description = entry.get("description") | ||
| 484 | + if description: | ||
| 485 | + description = self._clean_text_from_json_artifacts(description) | ||
| 486 | + | ||
| 465 | entries.append( | 487 | entries.append( |
| 466 | { | 488 | { |
| 467 | "level": entry.get("level", 2), | 489 | "level": entry.get("level", 2), |
| 468 | "text": entry.get("display") or entry.get("title") or "", | 490 | "text": entry.get("display") or entry.get("title") or "", |
| 469 | "anchor": anchor, | 491 | "anchor": anchor, |
| 470 | - "description": entry.get("description"), | 492 | + "description": description, |
| 471 | } | 493 | } |
| 472 | ) | 494 | ) |
| 473 | return entries | 495 | return entries |
| @@ -479,16 +501,52 @@ class HTMLRenderer: | @@ -479,16 +501,52 @@ class HTMLRenderer: | ||
| 479 | if not anchor: | 501 | if not anchor: |
| 480 | continue | 502 | continue |
| 481 | mapped = self.heading_label_map.get(anchor, {}) | 503 | mapped = self.heading_label_map.get(anchor, {}) |
| 504 | + # 清理描述文本 | ||
| 505 | + description = mapped.get("description") | ||
| 506 | + if description: | ||
| 507 | + description = self._clean_text_from_json_artifacts(description) | ||
| 482 | entries.append( | 508 | entries.append( |
| 483 | { | 509 | { |
| 484 | "level": block.get("level", 2), | 510 | "level": block.get("level", 2), |
| 485 | "text": mapped.get("display") or block.get("text", ""), | 511 | "text": mapped.get("display") or block.get("text", ""), |
| 486 | "anchor": anchor, | 512 | "anchor": anchor, |
| 487 | - "description": mapped.get("description"), | 513 | + "description": description, |
| 488 | } | 514 | } |
| 489 | ) | 515 | ) |
| 490 | return entries | 516 | return entries |
| 491 | 517 | ||
| 518 | + def _validate_toc_anchor(self, anchor: str, chapters: List[Dict[str, Any]]) -> bool: | ||
| 519 | + """ | ||
| 520 | + 验证目录anchor是否在文档中存在对应的章节或heading。 | ||
| 521 | + | ||
| 522 | + 参数: | ||
| 523 | + anchor: 需要验证的anchor | ||
| 524 | + chapters: Document IR中的章节数组 | ||
| 525 | + | ||
| 526 | + 返回: | ||
| 527 | + bool: anchor是否有效 | ||
| 528 | + """ | ||
| 529 | + # 检查是否是章节anchor | ||
| 530 | + if anchor in self.chapter_anchor_map.values(): | ||
| 531 | + return True | ||
| 532 | + | ||
| 533 | + # 检查是否在heading_label_map中 | ||
| 534 | + if anchor in self.heading_label_map: | ||
| 535 | + return True | ||
| 536 | + | ||
| 537 | + # 检查章节的blocks中是否有这个anchor | ||
| 538 | + for chapter in chapters or []: | ||
| 539 | + chapter_anchor = chapter.get("anchor") | ||
| 540 | + if chapter_anchor == anchor: | ||
| 541 | + return True | ||
| 542 | + | ||
| 543 | + for block in chapter.get("blocks", []): | ||
| 544 | + block_anchor = block.get("anchor") | ||
| 545 | + if block_anchor == anchor: | ||
| 546 | + return True | ||
| 547 | + | ||
| 548 | + return False | ||
| 549 | + | ||
| 492 | def _prepare_chapters(self, chapters: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | 550 | def _prepare_chapters(self, chapters: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
| 493 | """复制章节并展开其中序列化的block,避免渲染缺失""" | 551 | """复制章节并展开其中序列化的block,避免渲染缺失""" |
| 494 | prepared: List[Dict[str, Any]] = [] | 552 | prepared: List[Dict[str, Any]] = [] |
| @@ -640,6 +698,9 @@ class HTMLRenderer: | @@ -640,6 +698,9 @@ class HTMLRenderer: | ||
| 640 | str: `<li>` 形式的HTML。 | 698 | str: `<li>` 形式的HTML。 |
| 641 | """ | 699 | """ |
| 642 | desc = entry.get("description") | 700 | desc = entry.get("description") |
| 701 | + # 清理描述文本中的JSON片段 | ||
| 702 | + if desc: | ||
| 703 | + desc = self._clean_text_from_json_artifacts(desc) | ||
| 643 | desc_html = f'<p class="toc-desc">{self._escape_html(desc)}</p>' if desc else "" | 704 | desc_html = f'<p class="toc-desc">{self._escape_html(desc)}</p>' if desc else "" |
| 644 | level = entry.get("level", 2) | 705 | level = entry.get("level", 2) |
| 645 | css_level = 1 if level <= 2 else min(level, 4) | 706 | css_level = 1 if level <= 2 else min(level, 4) |
| @@ -1576,6 +1637,64 @@ class HTMLRenderer: | @@ -1576,6 +1637,64 @@ class HTMLRenderer: | ||
| 1576 | 1637 | ||
| 1577 | # ====== 文本 / 安全工具 ====== | 1638 | # ====== 文本 / 安全工具 ====== |
| 1578 | 1639 | ||
| 1640 | + def _clean_text_from_json_artifacts(self, text: Any) -> str: | ||
| 1641 | + """ | ||
| 1642 | + 清理文本中的JSON片段和伪造的结构标记。 | ||
| 1643 | + | ||
| 1644 | + LLM有时会在文本字段中混入未完成的JSON片段,如: | ||
| 1645 | + "描述文本,{ \"chapterId\": \"S3" 或 "描述文本,{ \"level\": 2" | ||
| 1646 | + | ||
| 1647 | + 此方法会: | ||
| 1648 | + 1. 移除不完整的JSON对象(以 { 开头但未正确闭合的) | ||
| 1649 | + 2. 移除不完整的JSON数组(以 [ 开头但未正确闭合的) | ||
| 1650 | + 3. 移除孤立的JSON键值对片段 | ||
| 1651 | + | ||
| 1652 | + 参数: | ||
| 1653 | + text: 可能包含JSON片段的文本 | ||
| 1654 | + | ||
| 1655 | + 返回: | ||
| 1656 | + str: 清理后的纯文本 | ||
| 1657 | + """ | ||
| 1658 | + if not text: | ||
| 1659 | + return "" | ||
| 1660 | + | ||
| 1661 | + text_str = self._safe_text(text) | ||
| 1662 | + | ||
| 1663 | + # 模式1: 移除以逗号+空白+{开头的不完整JSON对象 | ||
| 1664 | + # 例如: "文本,{ \"key\": \"value\"" 或 "文本,{\\n \"key\"" | ||
| 1665 | + text_str = re.sub(r',\s*\{[^}]*$', '', text_str) | ||
| 1666 | + | ||
| 1667 | + # 模式2: 移除以逗号+空白+[开头的不完整JSON数组 | ||
| 1668 | + text_str = re.sub(r',\s*\[[^\]]*$', '', text_str) | ||
| 1669 | + | ||
| 1670 | + # 模式3: 移除孤立的 { 加上后续内容(如果没有匹配的 }) | ||
| 1671 | + # 检查是否有未闭合的 { | ||
| 1672 | + open_brace_pos = text_str.rfind('{') | ||
| 1673 | + if open_brace_pos != -1: | ||
| 1674 | + close_brace_pos = text_str.rfind('}') | ||
| 1675 | + if close_brace_pos < open_brace_pos: | ||
| 1676 | + # { 在 } 后面或没有 },说明是未闭合的 | ||
| 1677 | + # 截断到 { 之前 | ||
| 1678 | + text_str = text_str[:open_brace_pos].rstrip(',,、 \t\n') | ||
| 1679 | + | ||
| 1680 | + # 模式4: 类似处理 [ | ||
| 1681 | + open_bracket_pos = text_str.rfind('[') | ||
| 1682 | + if open_bracket_pos != -1: | ||
| 1683 | + close_bracket_pos = text_str.rfind(']') | ||
| 1684 | + if close_bracket_pos < open_bracket_pos: | ||
| 1685 | + # [ 在 ] 后面或没有 ],说明是未闭合的 | ||
| 1686 | + text_str = text_str[:open_bracket_pos].rstrip(',,、 \t\n') | ||
| 1687 | + | ||
| 1688 | + # 模式5: 移除看起来像JSON键值对的片段,如 "chapterId": "S3 | ||
| 1689 | + # 这种情况通常出现在上面的模式之后 | ||
| 1690 | + text_str = re.sub(r',?\s*"[^"]+"\s*:\s*"[^"]*$', '', text_str) | ||
| 1691 | + text_str = re.sub(r',?\s*"[^"]+"\s*:\s*[^,}\]]*$', '', text_str) | ||
| 1692 | + | ||
| 1693 | + # 清理末尾的逗号和空白 | ||
| 1694 | + text_str = text_str.rstrip(',,、 \t\n') | ||
| 1695 | + | ||
| 1696 | + return text_str.strip() | ||
| 1697 | + | ||
| 1579 | def _safe_text(self, value: Any) -> str: | 1698 | def _safe_text(self, value: Any) -> str: |
| 1580 | """将任意值安全转换为字符串,None与复杂对象容错""" | 1699 | """将任意值安全转换为字符串,None与复杂对象容错""" |
| 1581 | if value is None: | 1700 | if value is None: |
-
Please register or login to post a comment