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<index>\d+)\s*\)[ \t]*$", + flags=re.MULTILINE, +) +REPORT_CHAPTER_ANCHOR_RE = re.compile( + r'<a\b(?=[^>]*\bid\s*=\s*["\']chapter-\d+["\'])[^>]*>\s*</a>', + 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<title>.+?)\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 - ) - # 创建 <w:hyperlink> 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 实体(处理 &#92; → \ → \ 等双重转义) 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 '<div class="table-wrap">' 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 = ( + '<html><body><h1>报告标题</h1><h1>目录</h1>' + '<p><a href="#chapter-1">1. 市场</a></p>' + '<h1>摘要</h1><p>摘要内容</p>' + '<a id="chapter-1"></a><h1>1. 市场</h1><p>正文</p></body></html>' + ) + + 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 '<a id="chapter-' not in report_content + assert "# 1. 企业基本情况分析" in report_content + assert "1.1 基础信息" not in report_content[toc_start:abstract_start] diff --git a/deepsearch/tests/report/test_outline_toc_test_script.py b/deepsearch/tests/report/test_outline_toc_test_script.py new file mode 100644 index 00000000..ab65e830 --- /dev/null +++ b/deepsearch/tests/report/test_outline_toc_test_script.py @@ -0,0 +1,47 @@ +from openjiuwen_deepsearch.framework.openjiuwen.agent.search_context import Outline, Section +from openjiuwen_deepsearch.algorithm.report.report import Reporter + + +def _render_outline_body(outline): + blocks = [] + for section in outline.sections: + blocks.append(f"# {section.title}\n\n{section.description}") + return "\n\n".join(blocks) + + +def test_render_outline_report_uses_native_clickable_toc(): + outline = Outline( + language="zh-CN", + title="全球票房与流媒体竞争", + thought="按市场变化、平台竞争和趋势判断组织大纲。", + sections=[ + Section( + id="1", + title="1. 全球票房变化", + description="梳理主要市场的票房变化与影响因素。", + focus_dimensions=["市场规模", "区域差异"], + ), + Section( + id="2", + title="2. 流媒体平台竞争", + description="分析平台竞争格局。\n# 这不是章节标题", + format_requirements=["使用对比表呈现关键差异"], + ), + ], + ) + + body = _render_outline_body(outline) + toc = Reporter._build_table_of_contents(body, "zh-CN") + report = f"# {outline.title}\n\n{toc}\n\n## 大纲\n\n{body}\n" + + assert report.startswith("# 全球票房与流媒体竞争\n\n# 目录") + assert "## 大纲" in report + assert "[1. 全球票房变化](#chapter-1)" in report + assert "[2. 流媒体平台竞争](#chapter-2)" in report + assert "\n- [" not in report + assert "# 1. 全球票房变化" in report + assert "# 2. 流媒体平台竞争" in report + assert '<a id="chapter-' not in report + assert "# 这不是章节标题" not in [ + item["title"] for item in Reporter._extract_level_one_headings(report) + ]