From 9d4472cb2d0108869d688a4ac731e539d41b919e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=B0=95=E6=B0=99?= <2014440212@qq.com>
Date: Thu, 2 Apr 2026 16:16:22 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=E7=9F=A5=E8=AF=86?=
=?UTF-8?q?=E5=BA=93=E7=9A=84=E5=88=9D=E5=A7=8B=E5=8C=96=E9=94=99=E8=AF=AF?=
=?UTF-8?q?=E5=A4=84=E7=90=86=20(#7243)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: 改进 KnowledgeBaseManager 和 KBHelper 中的初始化错误处理
* fix: 改进知识库初始化和重排序错误处理,增强日志记录
* fix: 改进知识库模块初始化和检索错误处理
* fix(ui): handle kb init errors in list cards
display a dedicated error state for knowledge base cards that fail
initialization, including a visible badge and error details
prevent navigation and edit actions for failed cards while keeping
delete available, and hide normal stats/description for error items
add list.initError locale strings for en-US, ru-RU, and zh-CN
* fix(kb): avoid replacing helper on init failure
Initialize a new KB helper before swapping instances so a failed re-init
does not break the active knowledge base service.
If initialization fails, restore in-memory KB settings and keep the
existing helper and previous init error state.
Also clear stale init_error after successful vector DB initialization to
prevent outdated error reporting.
* test(kb): add kb manager resilience tests
cover initialization failure and recovery scenarios to guard
against regressions in kb error handling
include reference assets under refs for test validation
---------
Co-authored-by: RC-CHN <1051989940@qq.com>
---
astrbot/core/knowledge_base/kb_helper.py | 21 +-
astrbot/core/knowledge_base/kb_mgr.py | 74 ++++-
.../core/knowledge_base/retrieval/manager.py | 23 +-
astrbot/dashboard/routes/knowledge_base.py | 7 +-
.../en-US/features/knowledge-base/index.json | 3 +-
.../ru-RU/features/knowledge-base/index.json | 3 +-
.../zh-CN/features/knowledge-base/index.json | 3 +-
dashboard/src/views/knowledge-base/KBList.vue | 90 +++++-
tests/unit/test_kb_manager_resilience.py | 305 ++++++++++++++++++
9 files changed, 498 insertions(+), 31 deletions(-)
create mode 100644 tests/unit/test_kb_manager_resilience.py
diff --git a/astrbot/core/knowledge_base/kb_helper.py b/astrbot/core/knowledge_base/kb_helper.py
index 1e9127d72..cb74cb2ba 100644
--- a/astrbot/core/knowledge_base/kb_helper.py
+++ b/astrbot/core/knowledge_base/kb_helper.py
@@ -108,6 +108,7 @@ Text chunk to process:
class KBHelper:
vec_db: BaseVecDB
kb: KnowledgeBase
+ init_error: str | None
def __init__(
self,
@@ -122,6 +123,7 @@ class KBHelper:
self.prov_mgr = provider_manager
self.kb_root_dir = kb_root_dir
self.chunker = chunker
+ self.init_error = None
self.kb_dir = Path(self.kb_root_dir) / self.kb.kb_id
self.kb_medias_dir = Path(self.kb_dir) / "medias" / self.kb.kb_id
@@ -148,13 +150,14 @@ class KBHelper:
async def get_rp(self) -> RerankProvider | None:
if not self.kb.rerank_provider_id:
return None
- rp: RerankProvider = await self.prov_mgr.get_provider_by_id(
+ rp: RerankProvider | None = await self.prov_mgr.get_provider_by_id(
self.kb.rerank_provider_id,
) # type: ignore
if not rp:
- raise ValueError(
- f"无法找到 ID 为 {self.kb.rerank_provider_id} 的 Rerank Provider",
+ logger.warning(
+ f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 的 Rerank Provider({self.kb.rerank_provider_id}) 不可用,将跳过重排序。",
)
+ return None
return rp
async def _ensure_vec_db(self) -> FaissVecDB:
@@ -162,7 +165,13 @@ class KBHelper:
raise ValueError(f"知识库 {self.kb.kb_name} 未配置 Embedding Provider")
ep = await self.get_ep()
- rp = await self.get_rp()
+ rp: RerankProvider | None = None
+ try:
+ rp = await self.get_rp()
+ except Exception as e:
+ logger.warning(
+ f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 初始化重排序能力失败,将跳过重排序: {e}",
+ )
vec_db = FaissVecDB(
doc_store_path=str(self.kb_dir / "doc.db"),
@@ -172,6 +181,8 @@ class KBHelper:
)
await vec_db.initialize()
self.vec_db = vec_db
+ # Clear stale init_error once initialization succeeds.
+ self.init_error = None
return vec_db
async def delete_vec_db(self) -> None:
@@ -183,7 +194,7 @@ class KBHelper:
shutil.rmtree(self.kb_dir)
async def terminate(self) -> None:
- if self.vec_db:
+ if hasattr(self, "vec_db") and self.vec_db:
await self.vec_db.close()
async def upload_document(
diff --git a/astrbot/core/knowledge_base/kb_mgr.py b/astrbot/core/knowledge_base/kb_mgr.py
index f26409e56..8dea163cb 100644
--- a/astrbot/core/knowledge_base/kb_mgr.py
+++ b/astrbot/core/knowledge_base/kb_mgr.py
@@ -1,4 +1,3 @@
-import traceback
from pathlib import Path
from astrbot.core import logger
@@ -56,8 +55,7 @@ class KnowledgeBaseManager:
logger.error(f"知识库模块导入失败: {e}")
logger.warning("请确保已安装所需依赖: pypdf, aiofiles, Pillow, rank-bm25")
except Exception as e:
- logger.error(f"知识库模块初始化失败: {e}")
- logger.error(traceback.format_exc())
+ logger.error(f"知识库模块初始化失败: {e}", exc_info=True)
async def _init_kb_database(self) -> None:
self.kb_db = KBSQLiteDatabase(DB_PATH.as_posix())
@@ -76,7 +74,14 @@ class KnowledgeBaseManager:
kb_root_dir=FILES_PATH,
chunker=CHUNKER,
)
- await kb_helper.initialize()
+ try:
+ await kb_helper.initialize()
+ except Exception as e:
+ kb_helper.init_error = str(e)
+ logger.error(
+ f"知识库 {record.kb_name}({record.kb_id}) 初始化失败: {e}",
+ exc_info=True,
+ )
self.kb_insts[record.kb_id] = kb_helper
async def create_kb(
@@ -179,6 +184,20 @@ class KnowledgeBaseManager:
return None
kb = kb_helper.kb
+ previous_state = {
+ "kb_name": kb.kb_name,
+ "description": kb.description,
+ "emoji": kb.emoji,
+ "embedding_provider_id": kb.embedding_provider_id,
+ "rerank_provider_id": kb.rerank_provider_id,
+ "chunk_size": kb.chunk_size,
+ "chunk_overlap": kb.chunk_overlap,
+ "top_k_dense": kb.top_k_dense,
+ "top_k_sparse": kb.top_k_sparse,
+ "top_m_final": kb.top_m_final,
+ }
+ previous_init_error = kb_helper.init_error
+
if kb_name is not None:
kb.kb_name = kb_name
if description is not None:
@@ -198,12 +217,47 @@ class KnowledgeBaseManager:
kb.top_k_sparse = top_k_sparse
if top_m_final is not None:
kb.top_m_final = top_m_final
+
+ # Build a new helper first. Keep current vec_db alive until new init succeeds.
+ new_helper = KBHelper(
+ kb_db=self.kb_db,
+ kb=kb,
+ provider_manager=self.provider_manager,
+ kb_root_dir=FILES_PATH,
+ chunker=CHUNKER,
+ )
+
+ try:
+ await new_helper.initialize()
+ except Exception as e:
+ # Roll back in-memory settings and keep current helper available.
+ kb.kb_name = previous_state["kb_name"]
+ kb.description = previous_state["description"]
+ kb.emoji = previous_state["emoji"]
+ kb.embedding_provider_id = previous_state["embedding_provider_id"]
+ kb.rerank_provider_id = previous_state["rerank_provider_id"]
+ kb.chunk_size = previous_state["chunk_size"]
+ kb.chunk_overlap = previous_state["chunk_overlap"]
+ kb.top_k_dense = previous_state["top_k_dense"]
+ kb.top_k_sparse = previous_state["top_k_sparse"]
+ kb.top_m_final = previous_state["top_m_final"]
+ kb_helper.init_error = previous_init_error
+ logger.error(
+ f"知识库 {kb.kb_name}({kb.kb_id}) 重新初始化失败,继续使用旧实例: {e}",
+ exc_info=True,
+ )
+ return kb_helper
+
async with self.kb_db.get_db() as session:
session.add(kb)
await session.commit()
await session.refresh(kb)
- return kb_helper
+ old_helper = kb_helper
+ self.kb_insts[kb_id] = new_helper
+ await old_helper.terminate()
+ new_helper.init_error = None
+ return new_helper
async def retrieve(
self,
@@ -215,11 +269,21 @@ class KnowledgeBaseManager:
"""从指定知识库中检索相关内容"""
kb_ids = []
kb_id_helper_map = {}
+ unavailable_kbs = []
for kb_name in kb_names:
if kb_helper := await self.get_kb_by_name(kb_name):
+ if kb_helper.init_error:
+ unavailable_kbs.append((kb_name, kb_helper.init_error))
+ logger.warning(f"知识库 {kb_name} 不可用: {kb_helper.init_error}")
+ continue
kb_ids.append(kb_helper.kb.kb_id)
kb_id_helper_map[kb_helper.kb.kb_id] = kb_helper
+ # all requested KBs are unavailable
+ if not kb_ids and unavailable_kbs:
+ errors = "; ".join(f"{n}: {e}" for n, e in unavailable_kbs)
+ raise ValueError(f"所有请求的知识库均不可用: {errors}")
+
if not kb_ids:
return {}
diff --git a/astrbot/core/knowledge_base/retrieval/manager.py b/astrbot/core/knowledge_base/retrieval/manager.py
index 1244e18af..e5bdb6e65 100644
--- a/astrbot/core/knowledge_base/retrieval/manager.py
+++ b/astrbot/core/knowledge_base/retrieval/manager.py
@@ -184,12 +184,15 @@ class RetrievalManager:
first_rerank = vec_db.rerank_provider
break
if first_rerank and retrieval_results:
- retrieval_results = await self._rerank(
- query=query,
- results=retrieval_results,
- top_k=top_m_final,
- rerank_provider=first_rerank,
- )
+ try:
+ retrieval_results = await self._rerank(
+ query=query,
+ results=retrieval_results,
+ top_k=top_m_final,
+ rerank_provider=first_rerank,
+ )
+ except Exception as e:
+ logger.warning(f"Rerank 执行失败,已跳过重排序并使用融合结果: {e}")
return retrieval_results[:top_m_final]
@@ -229,10 +232,10 @@ class RetrievalManager:
all_results.extend(vec_results)
except Exception as e:
- from astrbot.core import logger
-
- logger.warning(f"知识库 {kb_id} 稠密检索失败: {e}")
- continue
+ logger.error(f"知识库 {kb_id} 稠密检索失败: {e}", exc_info=True)
+ if len(kb_ids) == 1:
+ raise RuntimeError(f"知识库 {kb_id} 稠密检索失败: {e}") from e
+ # multi-KB: skip the faulty KB and continue
# 按相似度排序并返回 top_k
all_results.sort(key=lambda x: x.similarity, reverse=True)
diff --git a/astrbot/dashboard/routes/knowledge_base.py b/astrbot/dashboard/routes/knowledge_base.py
index f0ac5d43d..b1b154238 100644
--- a/astrbot/dashboard/routes/knowledge_base.py
+++ b/astrbot/dashboard/routes/knowledge_base.py
@@ -314,7 +314,12 @@ class KnowledgeBaseRoute(Route):
# 转换为字典列表
kb_list = []
for kb in kbs:
- kb_list.append(kb.model_dump())
+ kb_dict = kb.model_dump()
+ # include init_error from KBHelper if present
+ kb_helper = await kb_manager.get_kb(kb.kb_id)
+ if kb_helper and kb_helper.init_error:
+ kb_dict["init_error"] = kb_helper.init_error
+ kb_list.append(kb_dict)
return (
Response()
diff --git a/dashboard/src/i18n/locales/en-US/features/knowledge-base/index.json b/dashboard/src/i18n/locales/en-US/features/knowledge-base/index.json
index aa82a3e05..ecdef6054 100644
--- a/dashboard/src/i18n/locales/en-US/features/knowledge-base/index.json
+++ b/dashboard/src/i18n/locales/en-US/features/knowledge-base/index.json
@@ -10,7 +10,8 @@
"loading": "Loading...",
"documents": "Documents",
"chunks": "Chunks",
- "sessionConfig": "Session Config"
+ "sessionConfig": "Session Config",
+ "initError": "Initialization Failed"
},
"card": {
"edit": "Edit",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/index.json b/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/index.json
index 17fef3cc1..c22f6bac2 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/index.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/index.json
@@ -10,7 +10,8 @@
"loading": "Загрузка...",
"documents": "док.",
"chunks": "фрагм.",
- "sessionConfig": "Профиль"
+ "sessionConfig": "Профиль",
+ "initError": "Ошибка инициализации"
},
"card": {
"edit": "Изменить",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/index.json b/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/index.json
index 107594f10..e6f736825 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/index.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/index.json
@@ -10,7 +10,8 @@
"loading": "正在加载...",
"documents": "文档",
"chunks": "分块",
- "sessionConfig": "会话配置"
+ "sessionConfig": "会话配置",
+ "initError": "初始化失败"
},
"card": {
"edit": "编辑",
diff --git a/dashboard/src/views/knowledge-base/KBList.vue b/dashboard/src/views/knowledge-base/KBList.vue
index 509aaa557..9428c7ff9 100644
--- a/dashboard/src/views/knowledge-base/KBList.vue
+++ b/dashboard/src/views/knowledge-base/KBList.vue
@@ -27,14 +27,27 @@
-
-
+
+
+
+
{{ kb.emoji || '📚' }}
{{ kb.kb_name }}
-
{{ kb.description || '暂无描述' }}
+
{{ kb.description || '暂无描述' }}
-
+
+
+
+ mdi-close-circle
+ {{ t('list.initError') }}
+
+
{{ kb.init_error }}
+
+
+
mdi-file-document
{{ kb.doc_count || 0 }} {{ t('list.documents') }}
@@ -45,8 +58,8 @@
-
@@ -478,6 +491,34 @@ onMounted(() => {
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
}
+/* Error state card styles */
+.kb-card-error {
+ cursor: not-allowed;
+ border: 1px solid rgba(var(--v-theme-error), 0.3);
+ background-color: rgba(var(--v-theme-error), 0.02) !important;
+ overflow: visible; /* Allow badge to overflow */
+}
+
+.kb-card-error:hover {
+ transform: none;
+ box-shadow: 0 4px 12px rgba(var(--v-theme-error), 0.1) !important;
+ border-color: rgba(var(--v-theme-error), 0.5);
+}
+
+.kb-card-error .kb-emoji {
+ opacity: 0.7;
+ filter: grayscale(0.5);
+}
+
+.kb-card-error .kb-name {
+ color: rgba(var(--v-theme-on-surface), 0.7);
+}
+
+.kb-error-badge {
+ z-index: 10;
+ opacity: 0.9;
+}
+
.kb-card-content {
padding: 24px;
display: flex;
@@ -488,6 +529,11 @@ onMounted(() => {
position: relative;
}
+.kb-card-content-error {
+ justify-content: center;
+ gap: 8px;
+}
+
.kb-emoji {
font-size: 56px;
margin-bottom: 8px;
@@ -518,6 +564,36 @@ onMounted(() => {
justify-content: center;
}
+.kb-error-panel {
+ width: 100%;
+ text-align: left;
+ background: rgba(var(--v-theme-error), 0.08);
+ border: 1px solid rgba(var(--v-theme-error), 0.18);
+ border-radius: 10px;
+ padding: 10px 12px;
+}
+
+.kb-error-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: rgb(var(--v-theme-error));
+ margin-bottom: 4px;
+}
+
+.kb-error-detail {
+ font-size: 0.78rem;
+ line-height: 1.35;
+ color: rgba(var(--v-theme-on-surface), 0.82);
+ word-break: break-word;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
.stat-item {
display: flex;
align-items: center;
diff --git a/tests/unit/test_kb_manager_resilience.py b/tests/unit/test_kb_manager_resilience.py
new file mode 100644
index 000000000..f048def2a
--- /dev/null
+++ b/tests/unit/test_kb_manager_resilience.py
@@ -0,0 +1,305 @@
+"""
+Unit tests for knowledge base manager resilience behavior.
+
+Tests the following scenarios:
+1. update_kb preserves old instance when re-initialization fails
+2. update_kb switches instance only after new instance initializes successfully
+3. _ensure_vec_db clears stale init_error after successful initialization
+
+These tests use lazy imports and mocks to avoid circular import issues
+in the astrbot core module chain.
+"""
+
+import sys
+import types
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@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
+
+ 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)
+
+
+@pytest.fixture
+def mock_provider_manager():
+ """Create a mock ProviderManager."""
+ manager = MagicMock()
+ manager.get_provider_by_id = AsyncMock()
+ manager.acm = MagicMock()
+ manager.acm.default_conf = {}
+ return manager
+
+
+@pytest.fixture
+def mock_kb_db():
+ """Create a mock KBSQLiteDatabase."""
+ db = MagicMock()
+ db.get_db = MagicMock()
+ db.list_kbs = AsyncMock(return_value=[])
+ db.get_kb_by_id = AsyncMock()
+ return db
+
+
+@pytest.fixture
+def mock_knowledge_base():
+ """Create a mock KnowledgeBase instance."""
+ # Use lazy import to avoid circular import
+ from astrbot.core.knowledge_base.models import KnowledgeBase
+
+ kb = KnowledgeBase(
+ kb_name="test_kb",
+ description="Test knowledge base",
+ emoji="📚",
+ embedding_provider_id="test-embedding-provider",
+ rerank_provider_id=None,
+ chunk_size=512,
+ chunk_overlap=50,
+ top_k_dense=50,
+ top_k_sparse=50,
+ top_m_final=5,
+ )
+ return kb
+
+
+@pytest.fixture
+def mock_embedding_provider():
+ """Create a mock EmbeddingProvider."""
+ provider = MagicMock()
+ provider.get_embeddings_batch = AsyncMock(return_value=[[0.1, 0.2, 0.3]])
+ return provider
+
+
+@pytest.mark.asyncio
+async def test_update_kb_preserves_old_instance_when_reinit_fails(
+ stub_provider_manager_module,
+ mock_provider_manager,
+ mock_kb_db,
+ mock_knowledge_base,
+ mock_embedding_provider,
+):
+ """
+ Test that update_kb preserves the old KBHelper instance when
+ re-initialization fails, ensuring the knowledge base remains available.
+ """
+ # Lazy import to avoid circular import
+ from astrbot.core.knowledge_base.kb_helper import KBHelper
+ from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
+
+ # Setup: create an existing KBHelper with working vec_db
+ mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
+
+ # Create KBHelper using __new__ to avoid __init__ side effects
+ old_helper = KBHelper.__new__(KBHelper)
+ old_helper.kb = mock_knowledge_base
+ old_helper.prov_mgr = mock_provider_manager
+ old_helper.kb_db = mock_kb_db
+ old_helper.kb_root_dir = "/tmp/test_kb"
+ old_helper.chunker = MagicMock()
+ old_helper.init_error = None
+ old_helper.vec_db = MagicMock() # Simulate existing working vec_db
+ old_helper.terminate = AsyncMock()
+
+ # Create KBManager and inject the existing helper
+ kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
+ kb_mgr.provider_manager = mock_provider_manager
+ kb_mgr.kb_db = mock_kb_db
+ kb_mgr.kb_insts = {mock_knowledge_base.kb_id: old_helper}
+ kb_mgr.retrieval_manager = MagicMock()
+
+ # Mock KBHelper creation to simulate initialization failure
+ with patch.object(KBHelper, "initialize", new_callable=AsyncMock) as mock_init:
+ # First call (for new_helper) should fail
+ mock_init.side_effect = Exception("Embedding provider unavailable")
+
+ # Execute update_kb with a different embedding provider
+ result = await kb_mgr.update_kb(
+ kb_id=mock_knowledge_base.kb_id,
+ kb_name="updated_kb",
+ embedding_provider_id="new-embedding-provider",
+ )
+
+ # Verify: the old helper should be returned, not a new one
+ assert result is not None
+ assert result is old_helper
+ assert kb_mgr.kb_insts[mock_knowledge_base.kb_id] is old_helper
+
+ # Verify: old helper's vec_db should still be available
+ assert hasattr(result, "vec_db")
+ assert result.vec_db is not None
+
+ # Verify: failure does not replace the existing helper state
+ assert result.init_error is None
+ assert result.kb.kb_name == "test_kb"
+ assert result.kb.embedding_provider_id == "test-embedding-provider"
+
+
+@pytest.mark.asyncio
+async def test_update_kb_switches_instance_only_after_new_reinit_success(
+ stub_provider_manager_module,
+ mock_provider_manager,
+ mock_kb_db,
+ mock_knowledge_base,
+ mock_embedding_provider,
+):
+ """
+ Test that update_kb only switches to the new KBHelper instance
+ after the new instance successfully initializes.
+ """
+ # Lazy import to avoid circular import
+ from astrbot.core.knowledge_base.kb_helper import KBHelper
+ from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
+
+ # Setup: create an existing KBHelper
+ mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
+
+ old_helper = KBHelper.__new__(KBHelper)
+ old_helper.kb = mock_knowledge_base
+ old_helper.prov_mgr = mock_provider_manager
+ old_helper.kb_db = mock_kb_db
+ old_helper.kb_root_dir = "/tmp/test_kb"
+ old_helper.chunker = MagicMock()
+ old_helper.init_error = None
+ old_helper.vec_db = MagicMock()
+ old_helper.terminate = AsyncMock()
+
+ kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
+ kb_mgr.provider_manager = mock_provider_manager
+ kb_mgr.kb_db = mock_kb_db
+ kb_mgr.kb_insts = {mock_knowledge_base.kb_id: old_helper}
+ kb_mgr.retrieval_manager = MagicMock()
+
+ # Mock session context for database operations
+ mock_session = MagicMock()
+ mock_session.add = MagicMock()
+ mock_session.commit = AsyncMock()
+ mock_session.refresh = AsyncMock()
+
+ mock_db_context = MagicMock()
+ mock_db_context.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_db_context.__aexit__ = AsyncMock()
+ mock_kb_db.get_db.return_value = mock_db_context
+
+ # Mock KBHelper.initialize to succeed
+ with patch.object(KBHelper, "initialize", new_callable=AsyncMock) as mock_init:
+ mock_init.return_value = None
+
+ # Execute update_kb
+ result = await kb_mgr.update_kb(
+ kb_id=mock_knowledge_base.kb_id,
+ kb_name="updated_kb",
+ embedding_provider_id="new-embedding-provider",
+ )
+
+ # Verify: a new helper should be returned
+ assert result is not None
+ assert result is not old_helper
+ assert result.init_error is None
+ assert kb_mgr.kb_insts[mock_knowledge_base.kb_id] is result
+
+ # Verify: old helper should be terminated
+ old_helper.terminate.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_ensure_vec_db_clears_stale_init_error(
+ stub_provider_manager_module,
+ mock_provider_manager,
+ mock_kb_db,
+ mock_knowledge_base,
+ mock_embedding_provider,
+):
+ """
+ Test that _ensure_vec_db clears the init_error attribute
+ after successful initialization, removing stale error state.
+ """
+ # Lazy import to avoid circular import
+ from astrbot.core.knowledge_base.kb_helper import KBHelper
+
+ # Setup: create KBHelper with stale init_error
+ mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
+
+ helper = KBHelper.__new__(KBHelper)
+ helper.kb = mock_knowledge_base
+ helper.prov_mgr = mock_provider_manager
+ helper.kb_db = mock_kb_db
+ helper.kb_root_dir = "/tmp/test_kb"
+ helper.chunker = MagicMock()
+ helper.init_error = "Previous initialization failed"
+ helper.kb_dir = Path("/tmp/test_kb") / mock_knowledge_base.kb_id
+ helper.kb_medias_dir = helper.kb_dir / "medias" / mock_knowledge_base.kb_id
+ helper.kb_files_dir = helper.kb_dir / "files" / mock_knowledge_base.kb_id
+
+ # Mock FaissVecDB initialization
+ mock_vec_db = MagicMock()
+ mock_vec_db.initialize = AsyncMock()
+ mock_vec_db.close = AsyncMock()
+
+ with patch(
+ "astrbot.core.knowledge_base.kb_helper.FaissVecDB",
+ return_value=mock_vec_db,
+ ):
+ # Execute _ensure_vec_db
+ await helper._ensure_vec_db()
+
+ # Verify: init_error should be cleared
+ assert helper.init_error is None
+ assert helper.vec_db is mock_vec_db
+
+
+@pytest.mark.asyncio
+async def test_ensure_vec_db_sets_init_error_on_failure(
+ stub_provider_manager_module,
+ mock_provider_manager,
+ mock_kb_db,
+ mock_knowledge_base,
+):
+ """
+ Test that _ensure_vec_db does NOT clear init_error when
+ initialization fails, preserving the error state.
+ """
+ # Lazy import to avoid circular import
+ from astrbot.core.knowledge_base.kb_helper import KBHelper
+
+ # Setup: provider unavailable
+ mock_provider_manager.get_provider_by_id.return_value = None
+
+ helper = KBHelper.__new__(KBHelper)
+ helper.kb = mock_knowledge_base
+ helper.prov_mgr = mock_provider_manager
+ helper.kb_db = mock_kb_db
+ helper.kb_root_dir = "/tmp/test_kb"
+ helper.chunker = MagicMock()
+ helper.init_error = "Previous initialization failed"
+ helper.kb_dir = Path("/tmp/test_kb") / mock_knowledge_base.kb_id
+ helper.kb_medias_dir = helper.kb_dir / "medias" / mock_knowledge_base.kb_id
+ helper.kb_files_dir = helper.kb_dir / "files" / mock_knowledge_base.kb_id
+
+ # Execute _ensure_vec_db - should raise exception
+ try:
+ await helper._ensure_vec_db()
+ pytest.fail("Expected exception but none was raised")
+ except ValueError as e:
+ # Verify: exception should be raised
+ assert "无法找到" in str(e) or "未配置" in str(e)
+
+ # Verify: init_error should NOT be cleared (still has previous error)
+ # Note: _ensure_vec_db doesn't set init_error; that's done by the caller
+ assert helper.init_error is not None