From 1ce0b765deb69c56e4f9b3e264a81d24839819d8 Mon Sep 17 00:00:00 2001 From: "Zicheng (Axelsen) Xie" <154249566+Zicheng-Xie@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:39:33 -0400 Subject: [PATCH] fix(uploads): preserve separate bold spans in outline titles (#7150) --- README.md | 2 + .../harness/deerflow/utils/file_outline.py | 8 ++-- .../tests/test_file_outline_bold_wrappers.py | 37 +++++++++++++++++++ .../tests/test_list_uploaded_files_tool.py | 8 ++++ 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_file_outline_bold_wrappers.py diff --git a/README.md b/README.md index 5be1f5b322..d5d85934f4 100644 --- a/README.md +++ b/README.md @@ -2638,6 +2638,8 @@ removing the worker's temporary source. Automatic conversion remains off by defa Uploaded Markdown outlines recognize ATX heading syntax, clean closing markers with a linear suffix scan, and skip HTML comment blocks and fenced and indented code examples, so hashtags and code comments do not crowd out real document sections from the agent's heading preview. Indented bold examples are also excluded; PDF-style bold headings with up to three leading spaces remain supported. +Whole-title bold wrappers and adjacent PDF bold spans are cleaned; separate bold +spans joined by ordinary text retain their markers instead of losing only the outer pair. Split-bold numeric table rows, including parenthesized years, signed values, and currency-prefixed amounts, are excluded when any block after the section number is a numeric column, so they do not consume the outline's heading budget. diff --git a/backend/packages/harness/deerflow/utils/file_outline.py b/backend/packages/harness/deerflow/utils/file_outline.py index 2a0e0b4859..f00fca7601 100644 --- a/backend/packages/harness/deerflow/utils/file_outline.py +++ b/backend/packages/harness/deerflow/utils/file_outline.py @@ -73,7 +73,8 @@ def _clean_bold_title(raw: str) -> str: pymupdf4llm sometimes emits adjacent bold spans as ``**A** **B**`` instead of a single ``**A B**`` block. This helper merges those fragments and then - strips the outermost ``**...**`` wrapper so the caller gets plain text. + strips a whole-title ``**...**`` wrapper. Mixed bold/plain text keeps its + delimiters rather than losing the ends of separate spans. Examples:: @@ -83,9 +84,10 @@ def _clean_bold_title(raw: str) -> str: """ # Merge adjacent bold spans: "** **" → " " merged = re.sub(r"\*\*\s*\*\*", " ", raw).strip() - # Strip outermost **...** if the whole string is wrapped + # Strip one whole-title wrapper, not the ends of separate bold spans. if m := re.fullmatch(r"\*\*(.+?)\*\*", merged, re.DOTALL): - return m.group(1).strip() + if "**" not in m.group(1): + return m.group(1).strip() return merged diff --git a/backend/tests/test_file_outline_bold_wrappers.py b/backend/tests/test_file_outline_bold_wrappers.py new file mode 100644 index 0000000000..e8d1a94c44 --- /dev/null +++ b/backend/tests/test_file_outline_bold_wrappers.py @@ -0,0 +1,37 @@ +"""Mixed bold spans must not be mistaken for a whole-title wrapper.""" + +from pathlib import Path + +import pytest + +from deerflow.uploads.companions import register_companion +from deerflow.utils.file_outline import extract_outline_for_file + + +@pytest.mark.parametrize( + ("heading", "title"), + [ + ("**Revenue** and **Profit**", "**Revenue** and **Profit**"), + ("**收入**与**利润**", "**收入**与**利润**"), + ("**Before** / **After**", "**Before** / **After**"), + ("**Overview**", "Overview"), + ("**UNITED STATES** **SECURITIES**", "UNITED STATES SECURITIES"), + ("**A**\t**B**", "A B"), + ("Revenue and **Profit**", "Revenue and **Profit**"), + ("**Revenue** and Profit", "**Revenue** and Profit"), + ("Plain title", "Plain title"), + ], +) +@pytest.mark.parametrize("converted", [False, True]) +def test_upload_outline_only_unwraps_a_whole_bold_title(tmp_path: Path, heading: str, title: str, converted: bool) -> None: + markdown = tmp_path / "report.md" + original = f"Introduction\r\n\r\n## {heading} ###\r\n# Next section\r\n".encode() + markdown.write_bytes(original) + source = markdown + if converted: + source = tmp_path / "report.pdf" + source.write_bytes(b"synthetic source; conversion not invoked") + register_companion(source, markdown) + + assert extract_outline_for_file(source) == ([{"title": title, "line": 3}, {"title": "Next section", "line": 4}], []) + assert markdown.read_bytes() == original diff --git a/backend/tests/test_list_uploaded_files_tool.py b/backend/tests/test_list_uploaded_files_tool.py index 125a1a1bd2..27692a743f 100644 --- a/backend/tests/test_list_uploaded_files_tool.py +++ b/backend/tests/test_list_uploaded_files_tool.py @@ -83,6 +83,14 @@ class TestFormatOmittedSummary: class TestListUploadedFiles: + def test_outline_preserves_separate_bold_spans(self, tmp_path): + uploads_dir = _uploads_dir(tmp_path) + (uploads_dir / "report.md").write_text("# **Revenue** and **Profit**\n", encoding="utf-8") + + result = _list_uploaded_files_impl(include_outline=True, runtime=_runtime(), _paths=_paths(tmp_path)) + + assert result["files"][0]["outline"] == [{"title": "**Revenue** and **Profit**", "line": 1}] + def test_no_runtime_returns_empty(self): result = _list_uploaded_files_impl(runtime=None) assert result["files"] == []