马一丁

Fixed Directory Parsing Issues and Optimized Directory Rendering

@@ -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: