From e73763a7523465f349da4258415266118fcdc5f2 Mon Sep 17 00:00:00 2001 From: SweetenedSuzuka <188338189+SweetenedSuzuka@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:22:03 +0800 Subject: [PATCH 1/2] fix(kb): fall back to plain-text parser for txt/md without markitdown (#9598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #9598. Uploading .txt/.md/.markdown to a knowledge base failed with a generic "文档解析失败" error when the optional markitdown-no-magika dependency was missing, because select_parser routed plain text to MarkitdownParser and its top-level import raised ModuleNotFoundError that kb_helper masked. Modifications: - select_parser() routes .txt/.md/.markdown to the existing stdlib TextParser; .rst/.adoc/.xlsx/.docx/.xls still use MarkitdownParser - kb_helper upload_document reports a clear "缺少 markitdown-no-magika 依赖" message instead of the generic parse failure when that module is absent, and lets unrelated missing-module errors pass through - Add tests covering plain-text routing, markitdown-dependent formats, and the missing-dependency message --- astrbot/core/knowledge_base/kb_helper.py | 11 ++ astrbot/core/knowledge_base/parsers/util.py | 6 +- tests/unit/test_kb_select_parser.py | 167 ++++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_kb_select_parser.py diff --git a/astrbot/core/knowledge_base/kb_helper.py b/astrbot/core/knowledge_base/kb_helper.py index 75234840cb..008c051e4d 100644 --- a/astrbot/core/knowledge_base/kb_helper.py +++ b/astrbot/core/knowledge_base/kb_helper.py @@ -282,6 +282,17 @@ async def upload_document( parse_result = await parser.parse(file_content, file_name) except KnowledgeBaseUploadError: raise + except ModuleNotFoundError as exc: + if exc.name == "markitdown_no_magika": + raise KnowledgeBaseUploadError( + stage="parsing", + user_message=( + "文档解析失败:缺少解析该文件格式所需的依赖" + " markitdown-no-magika,请安装后重试。" + ), + details={"file_name": file_name}, + ) from exc + raise except Exception as exc: raise KnowledgeBaseUploadError( stage="parsing", diff --git a/astrbot/core/knowledge_base/parsers/util.py b/astrbot/core/knowledge_base/parsers/util.py index a98ed60ed2..efd04437e9 100644 --- a/astrbot/core/knowledge_base/parsers/util.py +++ b/astrbot/core/knowledge_base/parsers/util.py @@ -2,7 +2,11 @@ async def select_parser(ext: str) -> BaseParser: - if ext in {".md", ".txt", ".markdown", ".rst", ".adoc", ".xlsx", ".docx", ".xls"}: + if ext in {".txt", ".md", ".markdown"}: + from .text_parser import TextParser + + return TextParser() + if ext in {".rst", ".adoc", ".xlsx", ".docx", ".xls"}: from .markitdown_parser import MarkitdownParser return MarkitdownParser() diff --git a/tests/unit/test_kb_select_parser.py b/tests/unit/test_kb_select_parser.py new file mode 100644 index 0000000000..a4124d8da9 --- /dev/null +++ b/tests/unit/test_kb_select_parser.py @@ -0,0 +1,167 @@ +""" +Unit tests for knowledge base parser selection and missing-dependency errors. + +Covers: +1. .txt / .md / .markdown are routed to the stdlib TextParser (no third-party + dependency required), so plain-text uploads work without markitdown. +2. .rst / .adoc keep routing to MarkitdownParser (requires markitdown). +3. upload_document surfaces a clear missing-dependency message when + markitdown-no-magika is absent, instead of the generic parse failure. +4. A missing unrelated module (e.g. pypdf) is not mislabeled as a markitdown + problem. +""" + +import sys +import types +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from astrbot.core.exceptions import KnowledgeBaseUploadError +from astrbot.core.knowledge_base.models import KnowledgeBase +from astrbot.core.knowledge_base.parsers.text_parser import TextParser +from astrbot.core.knowledge_base.parsers.util import select_parser + + +@pytest.fixture +def stub_provider_manager_module(): + """Stub provider manager module to avoid circular imports in unit tests.""" + original_module = sys.modules.get("astrbot.core.provider.manager") + stub_module = types.ModuleType("astrbot.core.provider.manager") + + class ProviderManager: ... + + setattr(stub_module, "ProviderManager", ProviderManager) + sys.modules["astrbot.core.provider.manager"] = stub_module + + # Drop already-imported modules that transitively need ProviderManager so + # they re-import against the stub. + to_drop = [ + name + for name in list(sys.modules) + if name.startswith("astrbot.core.knowledge_base.kb_helper") + or name.startswith("astrbot.core.knowledge_base.kb_mgr") + ] + for name in to_drop: + sys.modules.pop(name, None) + + try: + yield + finally: + if original_module is not None: + sys.modules["astrbot.core.provider.manager"] = original_module + else: + sys.modules.pop("astrbot.core.provider.manager", None) + + +def _import_kb_helper(): + from astrbot.core.knowledge_base.kb_helper import KBHelper + + return KBHelper + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ext", [".txt", ".md", ".markdown"]) +async def test_select_parser_routes_plain_text_to_text_parser(ext): + parser = await select_parser(ext) + + assert isinstance(parser, TextParser) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ext", [".rst", ".adoc"]) +async def test_select_parser_keeps_markup_formats_on_markitdown(ext): + pytest.importorskip("markitdown_no_magika") + + from astrbot.core.knowledge_base.parsers.markitdown_parser import MarkitdownParser + + parser = await select_parser(ext) + + assert isinstance(parser, MarkitdownParser) + + +@pytest.mark.asyncio +async def test_text_parser_decodes_plain_text_file(): + result = await TextParser().parse("你好 world\nsecond line".encode("utf-8"), "note.txt") + assert result.media == [] + assert result.text == "你好 world\nsecond line" + + gbk_result = await TextParser().parse("中文内容".encode("gbk"), "note.txt") + assert gbk_result.text == "中文内容" + + +def _make_upload_helper(tmp_path: Path): + KBHelper = _import_kb_helper() + + helper = KBHelper.__new__(KBHelper) + helper.kb = KnowledgeBase( + kb_name="Test KB", + description="", + embedding_provider_id="emb", + ) + helper.kb_db = MagicMock() + helper.vec_db = AsyncMock() + helper.vec_db.delete_documents = AsyncMock() + helper.kb_medias_dir = tmp_path / "medias" + helper.chunker = AsyncMock() + return helper + + +@pytest.mark.asyncio +async def test_upload_document_reports_missing_markitdown_dependency( + tmp_path: Path, + stub_provider_manager_module, +) -> None: + helper = _make_upload_helper(tmp_path) + + with ( + patch( + "astrbot.core.knowledge_base.kb_helper.select_parser", + new=AsyncMock( + side_effect=ModuleNotFoundError( + "No module named 'markitdown_no_magika'", + name="markitdown_no_magika", + ), + ), + ), + patch.object(helper, "_ensure_vec_db", new=AsyncMock()), + pytest.raises(KnowledgeBaseUploadError) as exc_info, + ): + await helper.upload_document( + file_name="guide.docx", + file_content=b"fake", + file_type="docx", + ) + + assert exc_info.value.stage == "parsing" + assert "markitdown-no-magika" in exc_info.value.user_message + + +@pytest.mark.asyncio +async def test_upload_document_passes_through_unrelated_missing_module( + tmp_path: Path, + stub_provider_manager_module, +) -> None: + helper = _make_upload_helper(tmp_path) + + with ( + patch( + "astrbot.core.knowledge_base.kb_helper.select_parser", + new=AsyncMock( + side_effect=ModuleNotFoundError( + "No module named 'pypdf'", + name="pypdf", + ), + ), + ), + patch.object(helper, "_ensure_vec_db", new=AsyncMock()), + pytest.raises(ModuleNotFoundError) as exc_info, + ): + await helper.upload_document( + file_name="guide.pdf", + file_content=b"fake", + file_type="pdf", + ) + + assert exc_info.value.name == "pypdf" From 667e7b42d88fef40a059690c9a7a2f25e084a398 Mon Sep 17 00:00:00 2001 From: SweetenedSuzuka <188338189+SweetenedSuzuka@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:31:16 +0800 Subject: [PATCH 2/2] style(kb): drop redundant utf-8 encode arg in select-parser test (#9598) Satisfy ruff UP012. --- tests/unit/test_kb_select_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_kb_select_parser.py b/tests/unit/test_kb_select_parser.py index a4124d8da9..2c37844cc0 100644 --- a/tests/unit/test_kb_select_parser.py +++ b/tests/unit/test_kb_select_parser.py @@ -83,7 +83,7 @@ async def test_select_parser_keeps_markup_formats_on_markitdown(ext): @pytest.mark.asyncio async def test_text_parser_decodes_plain_text_file(): - result = await TextParser().parse("你好 world\nsecond line".encode("utf-8"), "note.txt") + result = await TextParser().parse("你好 world\nsecond line".encode(), "note.txt") assert result.media == [] assert result.text == "你好 world\nsecond line"