diff --git a/deepsearch/docs/feature/algorithm/chart-generation.md b/deepsearch/docs/feature/algorithm/chart-generation.md
index 3bd0da41..6b97d08f 100644
--- a/deepsearch/docs/feature/algorithm/chart-generation.md
+++ b/deepsearch/docs/feature/algorithm/chart-generation.md
@@ -13,6 +13,7 @@
## 可见行为
- 系统先识别适合插入图表的段落锚点。
+- 按一级标题切分报告时,目录、摘要、结论和参考文章等非正文部分会按标题语义排除,正文章节索引保持连续。
- 再从 `all_classified_contents` 中收集图表数据。
- LLM 生成 Python 绘图代码,沙箱执行后产生图表文件。
- 如果配置了 VLM 迭代,图表会经过多模态反馈优化;没有可用 VLM 时可降级跳过迭代。
diff --git a/deepsearch/docs/feature/algorithm/report-generation.md b/deepsearch/docs/feature/algorithm/report-generation.md
index 6f36da7a..9826fb30 100644
--- a/deepsearch/docs/feature/algorithm/report-generation.md
+++ b/deepsearch/docs/feature/algorithm/report-generation.md
@@ -19,6 +19,7 @@
## 可见行为
- 报告正文以 Markdown 输出,并清理标题编号和过深标题。
+- 最终报告在标题与摘要之间生成一级目录;目录以无项目符号的独立链接行列出正文一级章节,不展开子标题,也不包含摘要、结论和参考文章。
- 子报告根据章节计划和候选资料生成,失败时使用统一错误格式。
- 候选资料会先去重、按 step 分桶和按评分均衡筛选,再进入 LLM 分类。
- 表格 caption 会被标准化为稳定的“表 N”或英文对应格式,避免引用错位。
@@ -67,7 +68,7 @@
4. 子报告 Prompt 根据章节契约、选中文档和历史上下文生成 Markdown。
5. 报告工具清理标题编号、规范化表格标题,并按报告类型生成摘要、结论或建议。
6. 可视化内容如需插入,先抽取结构化数据并校验 schema,再生成 Mermaid 或交给图表模块。
-7. 最终报告、classified contents、sidecar 和引用相关元数据交给后续溯源和前端展示。
+7. 总报告按“标题 → 一级目录 → 摘要 → 正文章节 → 结论 → 参考文章”拼接,再将最终报告、classified contents、sidecar 和引用相关元数据交给后续溯源和前端展示。
## 数据契约与依赖
diff --git a/deepsearch/docs/feature/server/report-conversion/html-docx-mermaid-export.md b/deepsearch/docs/feature/server/report-conversion/html-docx-mermaid-export.md
index aca47abe..d6b514f1 100644
--- a/deepsearch/docs/feature/server/report-conversion/html-docx-mermaid-export.md
+++ b/deepsearch/docs/feature/server/report-conversion/html-docx-mermaid-export.md
@@ -7,9 +7,12 @@
## 可见行为
- HTML 导出会生成完整 HTML 文件并注入报告 CSS。
+- HTML 导出在转换层根据报告目录链接给对应 H1 添加 `id="chapter-N"`,普通页面和美化页面均可点击跳转;生成的 `report.md` 不包含 HTML 锚点。
+- 美化 HTML 按报告顺序保留目录、摘要和章节:摘要位于目录之后、正文首章之前。
- HTML 中数学公式通过 KaTeX 脚本(`katex.min.js` + `auto-render.min.js` + `katex.min.css`,版本固定 0.16.11)渲染,使用 `$...$` / `$$...$$` 作为定界符;`\bm` 宏映射为 `\boldsymbol{#1}`,`throwOnError=false` 保证无法解析的公式不打断页面渲染。
- HTML 在 KaTeX 渲染前会做"货币美元保护":遍历正文文本节点,把 `$` + 数字开头且不像公式的片段替换为全角 `$`(U+FF04)占位符,渲染完成后还原为 `$`,避免 `$4`、`$1,200.50` 等金额被 KaTeX 误配对为公式定界符。
- DOCX 导出使用纯 Python 流水线从 Markdown 生成 Word 文件。
+- DOCX 导出在转换层将 `#chapter-N` 目录链接转换为 Word 内部超链接,并将对应 H1 ID 转换为书签;普通外部链接行为不变。
- HTML 路径通过 `conversion_utils.protect_math_spans`、DOCX 路径通过 `word_utils._iter_math_spans` 切分公式段;两者复用 `conversion_utils` 中的公式判别函数 `_is_likely_inline_math` / 货币判别函数 `_is_currency_start` / `_find_inline_math_end` / `_is_escaped` / `_is_double_dollar`,保证两侧对"哪些 `$...$` 是公式、哪些是货币或纯文本"的判定一致。
- DOCX 超链接文本中若包含 `$...$` 或 `$$...$$`,会把公式段单独切出并转为 OMML 公式 run,其余文本保持为普通文本 run;HTML 实体先经 `html.unescape` 解码再进入公式处理。
- DOCX 列表嵌套按列表类型显式分支:同类型嵌套(`ul→ul` / `ol→ol`)沿用同一编号并增加缩进层级;异类型嵌套(如 `ul` 内含 `ol`)创建独立编号,避免编号串号。
diff --git a/deepsearch/openjiuwen_deepsearch/algorithm/chart_generation/figure_placeholders.py b/deepsearch/openjiuwen_deepsearch/algorithm/chart_generation/figure_placeholders.py
index ad7b03e1..089ca98d 100644
--- a/deepsearch/openjiuwen_deepsearch/algorithm/chart_generation/figure_placeholders.py
+++ b/deepsearch/openjiuwen_deepsearch/algorithm/chart_generation/figure_placeholders.py
@@ -137,11 +137,32 @@ def _split_report_by_h1(report_content: str) -> List[Dict[str, str]]:
)
current_index += 1
- # 过滤报告标题,摘要、结论章节章节
- sections = sections[2:-2]
-
- logger.info(f"Split report into {len(sections)} sections by H1")
- return sections
+ # 过滤报告标题及非正文 H1。目录是报告正文中的额外 H1,不能依赖
+ # 固定位置切片,否则会把摘要当成正文首章并使章节索引整体偏移。
+ excluded_titles = {
+ "目录",
+ "table of contents",
+ "contents",
+ "摘要",
+ "abstract",
+ "结论",
+ "conclusion",
+ "参考文章",
+ "参考文献",
+ "reference articles",
+ "references",
+ }
+ report_sections = [
+ section
+ for section in sections[1:]
+ if re.sub(r"\s+", " ", section["title"]).strip().casefold()
+ not in excluded_titles
+ ]
+ for index, section in enumerate(report_sections, start=1):
+ section["index"] = index
+
+ logger.info(f"Split report into {len(report_sections)} sections by H1")
+ return report_sections
@staticmethod
def _split_section_by_h2(section: Dict) -> List[Dict[str, str]]:
diff --git a/deepsearch/openjiuwen_deepsearch/algorithm/report/report.py b/deepsearch/openjiuwen_deepsearch/algorithm/report/report.py
index 422d05da..1501795e 100644
--- a/deepsearch/openjiuwen_deepsearch/algorithm/report/report.py
+++ b/deepsearch/openjiuwen_deepsearch/algorithm/report/report.py
@@ -1075,10 +1075,15 @@ async def generate_report(self, gen_report_context: dict) -> Tuple[bool, str]:
_outline_title = current_outline.get("title", "")
else:
_outline_title = getattr(current_outline, "title", "")
+ table_of_contents = self._build_table_of_contents(
+ sub_reports_content,
+ gen_report_context["language"],
+ )
report_content = (
f"{'# ' + _outline_title}\n\n" # Use outline title directly for report title
+ f"{table_of_contents}\n\n"
f"{self._post_process_abstract(abstract)}\n\n"
- f"{sub_report_res.get('sub_reports_content')}\n\n"
+ f"{sub_reports_content}\n\n"
f"{self._post_process_conclusion(conclusion)}\n\n"
f"{ArticlePart.get_title('reference', gen_report_context['language'])}"
f"{sub_report_res.get('sub_references')}\n\n"
@@ -1479,6 +1484,59 @@ async def _generate_with_llm(self, task_type, prompt, content):
)
return llm_output.get("content")
+ @staticmethod
+ def _build_table_of_contents(sub_reports_content: str, language: str) -> str:
+ """Build a clickable level-one TOC from the final body headings."""
+ headings = Reporter._extract_level_one_headings(sub_reports_content)
+ toc_title = ArticlePart.get_title("toc", language).strip()
+ if not headings:
+ return toc_title
+
+ toc_entries = "\n\n".join(
+ "[{0}](#chapter-{1})".format(heading["title"], index)
+ for index, heading in enumerate(headings, start=1)
+ )
+ return f"{toc_title}\n\n{toc_entries}"
+
+ @staticmethod
+ def _extract_level_one_headings(sub_reports_content: str) -> list[dict]:
+ """Extract real Markdown H1 headings while ignoring fenced code blocks."""
+ headings = []
+ fence_char = ""
+ fence_length = 0
+
+ offset = 0
+ for line in (sub_reports_content or "").splitlines(keepends=True):
+ content_line = line.rstrip("\r\n")
+ if fence_char:
+ closing_fence = re.match(r"^\s{0,3}(`{3,}|~{3,})\s*$", content_line)
+ if closing_fence:
+ marker = closing_fence.group(1)
+ if marker[0] == fence_char and len(marker) >= fence_length:
+ fence_char = ""
+ fence_length = 0
+ offset += len(line)
+ continue
+
+ opening_fence = re.match(r"^\s{0,3}(`{3,}|~{3,})", content_line)
+ if opening_fence:
+ marker = opening_fence.group(1)
+ fence_char = marker[0]
+ fence_length = len(marker)
+ offset += len(line)
+ continue
+
+ heading_match = re.match(r"^\s{0,3}#(?!#)\s+(.+?)\s*$", content_line)
+ if heading_match:
+ heading = re.sub(
+ r"[ \t]+#+[ \t]*$", "", heading_match.group(1)
+ ).strip()
+ if heading:
+ headings.append({"title": heading, "offset": offset})
+ offset += len(line)
+
+ return headings
+
def _post_process_abstract(self, content: str) -> str:
language = self.gen_report_context["language"]
if content is None or content == "":
diff --git a/deepsearch/openjiuwen_deepsearch/algorithm/report/report_utils.py b/deepsearch/openjiuwen_deepsearch/algorithm/report/report_utils.py
index bddb602a..5736a42e 100644
--- a/deepsearch/openjiuwen_deepsearch/algorithm/report/report_utils.py
+++ b/deepsearch/openjiuwen_deepsearch/algorithm/report/report_utils.py
@@ -137,6 +137,7 @@ class ArticlePart:
},
}
titles = {
+ "toc": {CHINESE: "# 目录\n\n", ENGLISH: "# Table of Contents\n\n"},
"abstract": {CHINESE: "# 摘要\n\n", ENGLISH: "# Abstract\n\n"},
"conclusion": {CHINESE: "# 结论\n\n", ENGLISH: "# Conclusion\n\n"},
"reference": {CHINESE: "# 参考文章\n\n", ENGLISH: "# Reference Articles\n\n"},
diff --git a/deepsearch/openjiuwen_deepsearch/algorithm/report_export/conversion_utils.py b/deepsearch/openjiuwen_deepsearch/algorithm/report_export/conversion_utils.py
index 941cae64..b4377bb4 100644
--- a/deepsearch/openjiuwen_deepsearch/algorithm/report_export/conversion_utils.py
+++ b/deepsearch/openjiuwen_deepsearch/algorithm/report_export/conversion_utils.py
@@ -42,6 +42,17 @@
)
MARKDOWN_TABLE_ROW_RE = re.compile(r"^[ \t]{0,3}\|")
MARKDOWN_TABLE_DELIMITER_RE = re.compile(r":?-{1,}:?")
+REPORT_TOC_LINK_RE = re.compile(
+ r"^[ \t]*\[(?P
.+?)\]\(\s*#chapter-(?P\d+)\s*\)[ \t]*$",
+ flags=re.MULTILINE,
+)
+REPORT_CHAPTER_ANCHOR_RE = re.compile(
+ r']*\bid\s*=\s*["\']chapter-\d+["\'])[^>]*>\s*',
+ flags=re.IGNORECASE,
+)
+REPORT_CHAPTER_ATTRIBUTE_RE = re.compile(
+ r"[ \t]+\{#chapter-\d+\}(?=[ \t]*(?:\r?\n|$))"
+)
SENTENCE_END_RE = re.compile(r"[。!??!…]$")
CITATION_RE = re.compile(r"\[\[(\d+)\]\]\((https?://[^\s)]+(?:\([^\s)]+\)[^\s)]*)*)\)")
CHECKED_CITATION_RE = re.compile(
@@ -181,6 +192,88 @@ def normalize_whitespace(text: str) -> str:
return text.replace("\u00a0", " ").replace("\u3000", " ")
+def add_report_chapter_ids(markdown_text: str) -> str:
+ """Add stable chapter IDs while preparing Markdown for export.
+
+ Report generation keeps the Markdown document format-neutral. Exporters
+ call this helper after reading the finished report and before rendering it
+ to HTML, DOCX, or another format. IDs are assigned only to H1 headings
+ referenced by the native report TOC.
+
+ Args:
+ markdown_text: Completed report Markdown containing ``#chapter-N`` TOC
+ links.
+
+ Returns:
+ Markdown with ``{#chapter-N}`` attributes added to matching chapter
+ headings.
+ """
+ normalized = REPORT_CHAPTER_ANCHOR_RE.sub("", markdown_text or "")
+ normalized = REPORT_CHAPTER_ATTRIBUTE_RE.sub("", normalized)
+ toc_entries = list(REPORT_TOC_LINK_RE.finditer(normalized))
+ if not toc_entries:
+ return normalized
+
+ headings: list[dict[str, int | str]] = []
+ fence_char = ""
+ fence_length = 0
+ offset = 0
+ for line in normalized.splitlines(keepends=True):
+ content_line = line.rstrip("\r\n")
+ if fence_char:
+ closing_fence = re.match(
+ r"^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$", content_line
+ )
+ if closing_fence:
+ marker = closing_fence.group(1)
+ if marker[0] == fence_char and len(marker) >= fence_length:
+ fence_char = ""
+ fence_length = 0
+ offset += len(line)
+ continue
+
+ opening_fence = re.match(r"^[ \t]{0,3}(`{3,}|~{3,})", content_line)
+ if opening_fence:
+ marker = opening_fence.group(1)
+ fence_char = marker[0]
+ fence_length = len(marker)
+ offset += len(line)
+ continue
+
+ heading_match = re.match(
+ r"^[ \t]{0,3}#[ \t]+(?P.+?)\s*$", content_line
+ )
+ if heading_match:
+ title = heading_match.group("title")
+ title = re.sub(r"[ \t]+#+[ \t]*$", "", title).strip()
+ if title:
+ headings.append(
+ {
+ "title": title,
+ "attribute_offset": offset + heading_match.end("title"),
+ }
+ )
+ offset += len(line)
+
+ insertions: list[tuple[int, str]] = []
+ heading_cursor = 0
+ for toc_entry in toc_entries:
+ toc_title = toc_entry.group("title").strip()
+ while heading_cursor < len(headings):
+ heading = headings[heading_cursor]
+ heading_cursor += 1
+ if heading["title"] == toc_title:
+ chapter_index = toc_entry.group("index")
+ insertions.append(
+ (int(heading["attribute_offset"]), f" {{#chapter-{chapter_index}}}")
+ )
+ break
+
+ for offset, attribute in reversed(insertions):
+ normalized = normalized[:offset] + attribute + normalized[offset:]
+ return normalized
+
+
def replace_citations(text: str) -> str:
"""Convert citation markdown into HTML superscript links.
diff --git a/deepsearch/openjiuwen_deepsearch/algorithm/report_export/docx_export.py b/deepsearch/openjiuwen_deepsearch/algorithm/report_export/docx_export.py
index 8d5a2d30..a9b926a6 100644
--- a/deepsearch/openjiuwen_deepsearch/algorithm/report_export/docx_export.py
+++ b/deepsearch/openjiuwen_deepsearch/algorithm/report_export/docx_export.py
@@ -14,6 +14,7 @@
from openjiuwen_deepsearch.algorithm.report_export.conversion_utils import (
MERMAID_BLOCK_RE,
MermaidRenderStats,
+ add_report_chapter_ids,
normalize_docx_fonts,
normalize_docx_tables,
normalize_headings,
@@ -150,7 +151,9 @@ def convert_md_to_docx(md_path: str | Path, docx_path: str | Path) -> None:
content, mermaid_stats = replace_mermaid_blocks(content)
# Mermaid fence 是否属于运行时契约必须在标题归一化前判断,后者会移除短缩进。
content = normalize_headings(content)
- html_text = DOCX_HTML_TEMPLATE.format(content=render_markdown_html_fragment(content))
+ content = add_report_chapter_ids(content)
+ html_content = render_markdown_html_fragment(content)
+ html_text = DOCX_HTML_TEMPLATE.format(content=html_content)
document = Document()
set_global_styles(document)
diff --git a/deepsearch/openjiuwen_deepsearch/algorithm/report_export/html_export.py b/deepsearch/openjiuwen_deepsearch/algorithm/report_export/html_export.py
index 1b3da82c..cefc51e4 100644
--- a/deepsearch/openjiuwen_deepsearch/algorithm/report_export/html_export.py
+++ b/deepsearch/openjiuwen_deepsearch/algorithm/report_export/html_export.py
@@ -14,6 +14,7 @@
from openjiuwen_deepsearch.algorithm.report_export.conversion_utils import (
MERMAID_BLOCK_RE,
+ add_report_chapter_ids,
inline_chart_images,
preprocess_markdown_text,
read_text_with_fallback,
@@ -510,9 +511,11 @@ def convert_md_to_html(
output_path.parent.mkdir(parents=True, exist_ok=True)
md_content = read_text_with_fallback(input_path)
md_content = preprocess_markdown(md_content, options)
+ md_content = add_report_chapter_ids(md_content)
+ html_content = render_markdown_html_fragment(md_content)
full_html = HTML_TEMPLATE.format(
title=html.escape(options.title, quote=True),
- content=render_markdown_html_fragment(md_content),
+ content=html_content,
variant_css=STYLED_CSS_OVERLAY if options.page_variant == "styled" else "",
)
if options.page_variant == "styled":
diff --git a/deepsearch/openjiuwen_deepsearch/algorithm/report_export/word_utils.py b/deepsearch/openjiuwen_deepsearch/algorithm/report_export/word_utils.py
index 84d8cb65..6b041d4d 100644
--- a/deepsearch/openjiuwen_deepsearch/algorithm/report_export/word_utils.py
+++ b/deepsearch/openjiuwen_deepsearch/algorithm/report_export/word_utils.py
@@ -414,17 +414,14 @@ def _add_hyperlink(paragraph, url, text, *, style_r_fonts=None, superscript: boo
若文本含 LaTeX 定界符($$...$$ 或 $...$),将其转为 OMML 公式
插入超链接内部。HTML 实体会先解码再进行 LaTeX 处理。
"""
- # 创建关系 id
- part = paragraph.part
- r_id = part.relate_to(
- url,
- HYPERLINK_URI,
- is_external=True
- )
-
# 创建
hyperlink = OxmlElement("w:hyperlink")
- hyperlink.set(qn("r:id"), r_id)
+ if url.startswith("#") and len(url) > 1:
+ hyperlink.set(qn("w:anchor"), _word_bookmark_name(url[1:]))
+ hyperlink.set(qn("w:history"), "1")
+ else:
+ r_id = paragraph.part.relate_to(url, HYPERLINK_URI, is_external=True)
+ hyperlink.set(qn("r:id"), r_id)
# 解码 HTML 实体(处理 \ → \ → \ 等双重转义)
text = html.unescape(text)
@@ -467,6 +464,61 @@ def _add_hyperlink(paragraph, url, text, *, style_r_fonts=None, superscript: boo
_docx_paragraph_p(paragraph).append(hyperlink)
+def _word_bookmark_name(value: str) -> str:
+ """Convert an HTML anchor into a valid, stable Word bookmark name."""
+ name = re.sub(r"[^A-Za-z0-9_]", "_", unquote(value).strip())
+ if not name or name[0].isdigit():
+ name = f"_{name}"
+ return name[:40]
+
+
+def _prepare_heading_bookmarks(container) -> None:
+ """Move standalone HTML anchors onto their following heading elements."""
+ bookmark_id = 0
+
+ def _set_bookmark(heading, anchor_id: str) -> None:
+ nonlocal bookmark_id
+ if heading.get("data-docx-bookmark-name") is not None:
+ return
+ heading["data-docx-bookmark-name"] = _word_bookmark_name(anchor_id)
+ heading["data-docx-bookmark-id"] = str(bookmark_id)
+ bookmark_id += 1
+
+ for heading in container.find_all(list(HEADING_TAGS)):
+ heading_id = heading.get("id", "")
+ if re.fullmatch(r"chapter-\d+", heading_id):
+ _set_bookmark(heading, heading_id)
+
+ for anchor in list(container.select("a[id]:not([href])")):
+ if anchor.get_text(strip=True):
+ continue
+ heading = anchor.find_parent(lambda tag: tag.name in HEADING_TAGS)
+ if heading is None:
+ heading = anchor.find_next(lambda tag: tag.name in HEADING_TAGS)
+ if heading is None:
+ continue
+
+ _set_bookmark(heading, anchor["id"])
+
+ parent = anchor.parent
+ if parent is not None and parent.name == "p" and not parent.get_text(strip=True):
+ parent.decompose()
+ else:
+ anchor.decompose()
+
+
+def _add_bookmark(paragraph, name: str, bookmark_id: str) -> None:
+ paragraph_element = _docx_paragraph_p(paragraph)
+ start = OxmlElement("w:bookmarkStart")
+ start.set(qn("w:id"), bookmark_id)
+ start.set(qn("w:name"), name)
+ end = OxmlElement("w:bookmarkEnd")
+ end.set(qn("w:id"), bookmark_id)
+
+ paragraph_element.insert(1 if paragraph_element.pPr is not None else 0, start)
+ paragraph_element.append(end)
+
+
def _is_relative_to(path: Path, base_path: Path) -> bool:
try:
path.relative_to(base_path)
@@ -661,6 +713,11 @@ def _add_para_and_apply_style(doc, element, context: HtmlToDocContext):
for child in element.contents:
_process_inline(p, child, replace(context, style_r_fonts=style_r_fonts))
+ bookmark_name = element.get("data-docx-bookmark-name")
+ bookmark_id = element.get("data-docx-bookmark-id")
+ if bookmark_name is not None and bookmark_id is not None:
+ _add_bookmark(p, bookmark_name, bookmark_id)
+
def _insert_omml(paragraph, omml_xml: str):
"""向段落中插入 OMML 公式"""
@@ -1069,6 +1126,7 @@ def html_to_doc(doc, html_content, style_dict, base_path: str | Path | None = No
container = soup.find("div", class_="report-container")
if container is None:
container = soup.body or soup
+ _prepare_heading_bookmarks(container)
resolved_base_path = Path(base_path).resolve() if base_path is not None else None
max_image_width = _get_available_page_width(doc)
diff --git a/deepsearch/openjiuwen_deepsearch/algorithm/report_style/structure.py b/deepsearch/openjiuwen_deepsearch/algorithm/report_style/structure.py
index 934b0cbb..4bb3eca2 100644
--- a/deepsearch/openjiuwen_deepsearch/algorithm/report_style/structure.py
+++ b/deepsearch/openjiuwen_deepsearch/algorithm/report_style/structure.py
@@ -231,7 +231,12 @@ def get_unsectioned_content() -> Tag:
elif _is_heading(node) and _is_abstract_heading(node):
abstract = soup.new_tag("section", attrs={"class": "report-abstract"})
abstract.append(node)
- shell.append(abstract)
+ if content is None:
+ shell.append(abstract)
+ else:
+ # Preserve source order when a preceding TOC has already created
+ # the report-content node: TOC, abstract, then report sections.
+ content.append(abstract)
current_container = abstract
elif _is_heading(node) and _heading_level(node) == cover_heading_level:
section = soup.new_tag("section", attrs={"class": "report-section"})
diff --git a/deepsearch/tests/algorithm/chart_generation/test_figure_placeholders.py b/deepsearch/tests/algorithm/chart_generation/test_figure_placeholders.py
new file mode 100644
index 00000000..faa1eb71
--- /dev/null
+++ b/deepsearch/tests/algorithm/chart_generation/test_figure_placeholders.py
@@ -0,0 +1,31 @@
+import pytest
+
+from openjiuwen_deepsearch.algorithm.chart_generation.figure_placeholders import (
+ FigurePlaceholderGenerator,
+)
+
+
+pytestmark = pytest.mark.unit
+
+
+def _report_with_sections(include_toc: bool) -> str:
+ toc = "# 目录\n\n[第一章](#chapter-1)\n\n" if include_toc else ""
+ return (
+ "# 报告标题\n\n"
+ f"{toc}"
+ "# 摘要\n\n摘要内容\n\n"
+ "# 1. 第一章\n\n## 1.1 小节\n\n第一章正文\n\n"
+ "# 2. 第二章\n\n## 2.1 小节\n\n第二章正文\n\n"
+ "# 结论\n\n结论内容\n\n"
+ "# 参考文章\n\n参考内容\n"
+ )
+
+
+@pytest.mark.parametrize("include_toc", [False, True])
+def test_split_report_by_h1_excludes_non_body_sections_and_reindexes(include_toc):
+ sections = FigurePlaceholderGenerator._split_report_by_h1(
+ _report_with_sections(include_toc)
+ )
+
+ assert [section["title"] for section in sections] == ["1. 第一章", "2. 第二章"]
+ assert [section["index"] for section in sections] == [1, 2]
diff --git a/deepsearch/tests/algorithm/report_export/test_conversion_pipeline.py b/deepsearch/tests/algorithm/report_export/test_conversion_pipeline.py
index 10a30656..d16b596c 100644
--- a/deepsearch/tests/algorithm/report_export/test_conversion_pipeline.py
+++ b/deepsearch/tests/algorithm/report_export/test_conversion_pipeline.py
@@ -7,6 +7,7 @@
import json
import zipfile
from pathlib import Path
+from xml.etree import ElementTree
from docx import Document
import pytest
@@ -106,6 +107,83 @@ def test_render_markdown_html_fragment_preserves_shared_semantics() -> None:
assert '' in fragment
+def test_add_report_chapter_ids_matches_toc_and_ignores_fenced_headings() -> None:
+ """导出层只给目录引用的真实 H1 添加章节 ID。"""
+ from openjiuwen_deepsearch.algorithm.report_export.conversion_utils import (
+ add_report_chapter_ids,
+ )
+
+ markdown_text = (
+ "# 报告\n\n"
+ "# 目录\n\n"
+ "[1. 第一章](#chapter-1)\n\n"
+ "[2. 第二章](#chapter-2)\n\n"
+ "```markdown\n# 1. 第一章\n```\n\n"
+ "# 1. 第一章\n\n正文\n\n"
+ "# 2. 第二章\n"
+ )
+
+ converted = add_report_chapter_ids(markdown_text)
+
+ assert "```markdown\n# 1. 第一章\n```" in converted
+ assert "# 1. 第一章 {#chapter-1}" in converted
+ assert "# 2. 第二章 {#chapter-2}" in converted
+ assert add_report_chapter_ids(converted) == converted
+
+
+def test_docx_export_converts_report_toc_to_internal_links(tmp_path: Path) -> None:
+ """DOCX 目录应链接到章节书签,而不是创建伪外部链接。"""
+ from openjiuwen_deepsearch.algorithm.report_export.docx_export import convert_md_to_docx
+
+ markdown_path = tmp_path / "report.md"
+ docx_path = tmp_path / "report.docx"
+ markdown_path.write_text(
+ "# 报告\n\n"
+ "# 目录\n\n"
+ "[第一章](#chapter-1)\n\n"
+ "[第二章](#chapter-2)\n\n"
+ "# 第一章\n\n正文。\n\n"
+ "# 第二章\n\n[外部链接](https://example.com)\n",
+ encoding="utf-8",
+ )
+
+ convert_md_to_docx(markdown_path, docx_path)
+
+ with zipfile.ZipFile(docx_path) as archive:
+ document = ElementTree.fromstring(archive.read("word/document.xml"))
+ relationships = archive.read("word/_rels/document.xml.rels").decode("utf-8")
+
+ word_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+ rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+ bookmark_names = {
+ node.get(f"{{{word_ns}}}name")
+ for node in document.findall(f".//{{{word_ns}}}bookmarkStart")
+ }
+ internal_links = [
+ node
+ for node in document.findall(f".//{{{word_ns}}}hyperlink")
+ if node.get(f"{{{word_ns}}}anchor")
+ ]
+
+ assert {"chapter_1", "chapter_2"}.issubset(bookmark_names)
+ assert {
+ node.get(f"{{{word_ns}}}anchor") for node in internal_links
+ } == {"chapter_1", "chapter_2"}
+ assert all(node.get(f"{{{rel_ns}}}id") is None for node in internal_links)
+ toc_paragraphs = [
+ paragraph
+ for paragraph in document.findall(f".//{{{word_ns}}}p")
+ if paragraph.find(f".//{{{word_ns}}}hyperlink[@{{{word_ns}}}anchor]") is not None
+ ]
+ assert all(
+ paragraph.find(f".//{{{word_ns}}}numPr") is None
+ for paragraph in toc_paragraphs
+ )
+ assert "#chapter-1" not in relationships
+ assert "#chapter-2" not in relationships
+ assert "https://example.com" in relationships
+
+
def test_exporters_do_not_preprocess_mermaid_again_in_renderer(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
diff --git a/deepsearch/tests/algorithm/report_export/test_html_export_variants.py b/deepsearch/tests/algorithm/report_export/test_html_export_variants.py
index 00978cfd..ad304094 100644
--- a/deepsearch/tests/algorithm/report_export/test_html_export_variants.py
+++ b/deepsearch/tests/algorithm/report_export/test_html_export_variants.py
@@ -46,3 +46,59 @@ def test_html_export_variants_share_mermaid_rendering_and_limit_styled_dom(tmp_p
assert styled.select_one(".mermaid-wrap svg.chart-svg") is not None
assert standard.select_one("main.report-shell") is None
assert styled.select_one("main.report-shell") is not None
+
+
+def test_html_export_variants_preserve_clickable_report_toc(tmp_path) -> None:
+ """普通与美化 HTML 都应保留目录链接及对应章节 ID。"""
+ from openjiuwen_deepsearch.algorithm.report_export.html_export import (
+ ConvertOptions,
+ convert_md_to_html,
+ )
+
+ source = tmp_path / "report.md"
+ source.write_text(
+ "# 报告\n\n"
+ "# 目录\n\n"
+ "[第一章](#chapter-1)\n\n"
+ "[第二章](#chapter-2)\n\n"
+ "# 第一章\n\n正文。\n\n"
+ "# 第二章\n",
+ encoding="utf-8",
+ )
+
+ variants = (
+ (tmp_path / "standard.html", None),
+ (tmp_path / "styled.html", ConvertOptions(page_variant="styled")),
+ )
+ for target, options in variants:
+ convert_md_to_html(source, target, options=options)
+ html = BeautifulSoup(target.read_text(encoding="utf-8"), "html.parser")
+ for index in (1, 2):
+ link = html.select_one(f'a[href="#chapter-{index}"]')
+ assert link is not None
+ assert link.find_parent("li") is None
+ target = html.select_one(f'h1#chapter-{index}')
+ assert target is not None
+ assert target.find_parent("p") is None
+
+
+def test_html_export_places_generated_chapter_ids_on_h1(tmp_path) -> None:
+ """生成的章节 ID 应直接位于 H1 上,不产生额外空段落。"""
+ from openjiuwen_deepsearch.algorithm.report_export.html_export import convert_md_to_html
+
+ source = tmp_path / "report.md"
+ target = tmp_path / "report.html"
+ source.write_text(
+ "# 报告\n\n"
+ "# 目录\n\n[1. 第一章](#chapter-1)\n\n"
+ "# 1. 第一章\n\n正文\n",
+ encoding="utf-8",
+ )
+
+ convert_md_to_html(source, target)
+ soup = BeautifulSoup(target.read_text(encoding="utf-8"), "html.parser")
+ heading = soup.select_one("h1#chapter-1")
+
+ assert heading is not None
+ assert soup.select_one("a#chapter-1") is None
+ assert heading.find_parent("p") is None
diff --git a/deepsearch/tests/algorithm/report_style/test_structure.py b/deepsearch/tests/algorithm/report_style/test_structure.py
index cb4a4163..5d0f91f5 100644
--- a/deepsearch/tests/algorithm/report_style/test_structure.py
+++ b/deepsearch/tests/algorithm/report_style/test_structure.py
@@ -37,6 +37,28 @@ def test_decorate_report_html_wraps_cover_abstract_sections_and_data_blocks():
assert soup.select_one(".table-wrap.report-table") is not None
+def test_decorate_report_html_keeps_abstract_after_toc_before_report_sections():
+ """目录先于摘要时,摘要位于目录之后和正文首章之前。"""
+ html = (
+ '
报告标题
目录
'
+ '
1. 市场
'
+ '
摘要
摘要内容
'
+ '
1. 市场
正文
'
+ )
+
+ result = decorate_report_html(html)
+ soup = BeautifulSoup(result, "html.parser")
+ shell = soup.select_one("main.report-shell")
+ abstract = soup.select_one("section.report-abstract")
+ content = soup.select_one("div.report-content")
+
+ sections = content.find_all(recursive=False)
+ assert sections[0].find("h1").get_text(strip=True) == "目录"
+ assert sections[1] is abstract
+ assert sections[2].find("h1").get_text(strip=True) == "1. 市场"
+ assert abstract.get_text(" ", strip=True) == "摘要 摘要内容"
+
+
def test_decorate_report_html_marks_content_figures_and_keeps_resource_attributes():
"""为内容、图表标记语义类且不改写资源属性。"""
html = (
diff --git a/deepsearch/tests/report/test_general_report.py b/deepsearch/tests/report/test_general_report.py
index 8ec2eebe..736a20bd 100644
--- a/deepsearch/tests/report/test_general_report.py
+++ b/deepsearch/tests/report/test_general_report.py
@@ -14,7 +14,7 @@
SubReportContent,
build_research_intent_prompt_context,
)
-from openjiuwen_deepsearch.common.common_constants import CHINESE
+from openjiuwen_deepsearch.common.common_constants import CHINESE, ENGLISH
@patch("openjiuwen_deepsearch.algorithm.report.report.llm_context", new_callable=MagicMock)
@@ -323,6 +323,54 @@ def test_clean_markdown_headers_still_strips_real_section_numbers():
assert "## 中美欧AI PC出货量与渗透率量化对标" in cleaned
+def test_build_table_of_contents_only_lists_real_level_one_chapters():
+ sub_reports_content = """# 1. 第一章
+
+## 1.1 二级标题
+
+```mermaid
+# Mermaid 中的文本不是章节
+```
+
+~~~markdown
+# 代码块中的文本也不是章节
+~~~
+
+# 2. 第二章 ###
+"""
+
+ table_of_contents = Reporter._build_table_of_contents(
+ sub_reports_content,
+ CHINESE,
+ )
+
+ assert table_of_contents == "# 目录\n\n[1. 第一章](#chapter-1)\n\n[2. 第二章](#chapter-2)"
+
+
+def test_build_table_of_contents_uses_english_title():
+ table_of_contents = Reporter._build_table_of_contents(
+ "# 1. Market Overview\n\n## 1.1 Details",
+ ENGLISH,
+ )
+
+ assert table_of_contents == "# Table of Contents\n\n[1. Market Overview](#chapter-1)"
+
+
+def test_extract_level_one_headings_ignores_fenced_headings():
+ content = """# 1. 第一章
+
+```markdown
+# 不是章节
+```
+
+# 2. 第二章
+"""
+
+ headings = Reporter._extract_level_one_headings(content)
+
+ assert [heading["title"] for heading in headings] == ["1. 第一章", "2. 第二章"]
+
+
@pytest.mark.asyncio
@patch("openjiuwen_deepsearch.algorithm.report.report.ainvoke_llm_with_stats", new_callable=AsyncMock)
@patch("openjiuwen_deepsearch.algorithm.report.report.llm_context", new_callable=MagicMock)
@@ -399,3 +447,14 @@ async def mock_ainvoke_llm_with_stats(llm, messages, llm_type: str = "basic", ag
success, report_str = await reporter.generate_report(current_inputs)
assert success is True
+ report_content = current_inputs["report"]
+ toc_start = report_content.index("# 目录")
+ abstract_start = report_content.index("# 摘要")
+ chapter_start = report_content.index("# 1. 企业基本情况分析")
+ assert report_content.startswith("# XX有限公司尽职调查报告\n\n# 目录")
+ assert toc_start < abstract_start < chapter_start
+ assert "[1. 企业基本情况分析](#chapter-1)" in report_content[toc_start:abstract_start]
+ assert "\n- [" not in report_content[toc_start:abstract_start]
+ assert '