feat(kb): 添加打开知识库文档功能及相关输入模型,优化文件内容读取
This commit is contained in:
parent
fd9b770ce9
commit
3771b7e804
@ -1,5 +1,6 @@
|
|||||||
from .tools import (
|
from .tools import (
|
||||||
get_common_kb_tools,
|
get_common_kb_tools,
|
||||||
|
open_kb_document,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["get_common_kb_tools"]
|
__all__ = ["get_common_kb_tools", "open_kb_document"]
|
||||||
|
|||||||
@ -161,6 +161,16 @@ class QueryKBInput(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenKBDocumentInput(BaseModel):
|
||||||
|
"""打开知识库文档输入模型"""
|
||||||
|
|
||||||
|
resource_id: str = Field(description="知识库资源 ID,当前对应知识库 db_id")
|
||||||
|
file_id: str = Field(description="要打开的文档 ID,也就是知识库文件 file_id")
|
||||||
|
line: int | None = Field(default=None, ge=1, description="可选,1-based 起始行号")
|
||||||
|
offset: int | None = Field(default=None, ge=0, description="可选,0-based 起始偏移;line 优先于 offset")
|
||||||
|
window_size: int = Field(default=800, ge=1, le=2000, description="读取窗口行数,默认 800 行")
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_visible_knowledge_bases_for_query(runtime: ToolRuntime | None) -> list[dict[str, Any]]:
|
async def _resolve_visible_knowledge_bases_for_query(runtime: ToolRuntime | None) -> list[dict[str, Any]]:
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
return []
|
return []
|
||||||
@ -188,31 +198,54 @@ def _find_query_target(
|
|||||||
retrievers: dict[str, Any],
|
retrievers: dict[str, Any],
|
||||||
visible_kbs: list[dict[str, Any]],
|
visible_kbs: list[dict[str, Any]],
|
||||||
) -> tuple[str | None, dict[str, Any] | None, str | None]:
|
) -> tuple[str | None, dict[str, Any] | None, str | None]:
|
||||||
if visible_kbs:
|
if not visible_kbs:
|
||||||
matched_kbs = [db for db in visible_kbs if str(db.get("name") or "").strip() == kb_name]
|
return None, None, "无法获取当前会话可访问的知识库"
|
||||||
if not matched_kbs:
|
|
||||||
return None, None, f"知识库 '{kb_name}' 不存在或当前会话未启用"
|
|
||||||
if len(matched_kbs) > 1:
|
|
||||||
return None, None, f"知识库 '{kb_name}' 存在重名,请先调整名称后重试"
|
|
||||||
|
|
||||||
target_db_id = str(matched_kbs[0].get("db_id") or "")
|
matched_kbs = [db for db in visible_kbs if str(db.get("name") or "").strip() == kb_name]
|
||||||
target_info = retrievers.get(target_db_id)
|
if not matched_kbs:
|
||||||
if target_info is None:
|
return None, None, f"知识库 '{kb_name}' 不存在或当前会话未启用"
|
||||||
return None, None, f"知识库 '{kb_name}' 不存在"
|
if len(matched_kbs) > 1:
|
||||||
return target_db_id, target_info, None
|
return None, None, f"知识库 '{kb_name}' 存在重名,请先调整名称后重试"
|
||||||
|
|
||||||
for db_id, info in retrievers.items():
|
target_db_id = str(matched_kbs[0].get("db_id") or "")
|
||||||
if info["name"] == kb_name:
|
target_info = retrievers.get(target_db_id)
|
||||||
return str(db_id), info, None
|
if target_info is None:
|
||||||
|
return None, None, f"知识库 '{kb_name}' 不存在"
|
||||||
|
return target_db_id, target_info, None
|
||||||
|
|
||||||
return None, None, f"知识库 '{kb_name}' 不存在"
|
|
||||||
|
def _normalize_retrieval_result_metadata(result: Any) -> Any:
|
||||||
|
if not isinstance(result, list):
|
||||||
|
return result
|
||||||
|
|
||||||
|
for chunk in result:
|
||||||
|
if not isinstance(chunk, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata = chunk.get("metadata")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
file_id = metadata.get("file_id") or chunk.get("file_id") or chunk.get("full_doc_id")
|
||||||
|
if file_id and not metadata.get("file_id"):
|
||||||
|
metadata["file_id"] = str(file_id)
|
||||||
|
|
||||||
|
for key in ("chunk_id", "chunk_index"):
|
||||||
|
value = metadata.get(key) if metadata.get(key) is not None else chunk.get(key)
|
||||||
|
if value is not None and metadata.get(key) is None:
|
||||||
|
metadata[key] = value
|
||||||
|
|
||||||
|
chunk["metadata"] = metadata
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(args_schema=QueryKBInput)
|
@tool(args_schema=QueryKBInput)
|
||||||
async def query_kb(kb_name: str, query_text: str, file_name: str | None = None, runtime: ToolRuntime = None) -> Any:
|
async def query_kb(kb_name: str, query_text: str, file_name: str | None = None, runtime: ToolRuntime = None) -> Any:
|
||||||
"""在指定知识库中检索内容
|
"""在指定知识库中检索内容
|
||||||
|
|
||||||
当用户需要查询具体内容时使用此工具。根据关键词在知识库中检索相关文档片段。
|
当用户需要查询具体内容时使用此工具。根据关键词在知识库中检索相关文档片段;结果中的
|
||||||
|
metadata.file_id 和知识库 resource_id 可用于继续调用 open_kb_document 打开原文。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
kb_name: 知识库名称
|
kb_name: 知识库名称
|
||||||
@ -254,6 +287,8 @@ async def query_kb(kb_name: str, query_text: str, file_name: str | None = None,
|
|||||||
else:
|
else:
|
||||||
result = retriever(query_text, **kwargs)
|
result = retriever(query_text, **kwargs)
|
||||||
|
|
||||||
|
result = _normalize_retrieval_result_metadata(result)
|
||||||
|
|
||||||
if kb_type != "milvus":
|
if kb_type != "milvus":
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -275,12 +310,67 @@ async def query_kb(kb_name: str, query_text: str, file_name: str | None = None,
|
|||||||
return f"检索失败: {str(e)}"
|
return f"检索失败: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool(args_schema=OpenKBDocumentInput)
|
||||||
|
async def open_kb_document(
|
||||||
|
resource_id: str,
|
||||||
|
file_id: str,
|
||||||
|
line: int | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
window_size: int = 800,
|
||||||
|
runtime: ToolRuntime = None,
|
||||||
|
) -> dict[str, Any] | str:
|
||||||
|
"""按行窗口打开知识库文档原文
|
||||||
|
|
||||||
|
当 query_kb 返回的片段不足以回答问题,或需要查看某个文档的上下文时使用。
|
||||||
|
resource_id 对应知识库 db_id,file_id 对应检索结果 metadata.file_id。
|
||||||
|
"""
|
||||||
|
normalized_resource_id = str(resource_id or "").strip()
|
||||||
|
normalized_file_id = str(file_id or "").strip()
|
||||||
|
if not normalized_resource_id:
|
||||||
|
return "请提供 resource_id"
|
||||||
|
if not normalized_file_id:
|
||||||
|
return "请提供 file_id"
|
||||||
|
|
||||||
|
visible_kbs = await _resolve_visible_knowledge_bases_for_query(runtime)
|
||||||
|
if not visible_kbs:
|
||||||
|
return "无法获取当前会话可访问的知识库"
|
||||||
|
|
||||||
|
visible_resource_ids = {str(kb.get("db_id") or "").strip() for kb in visible_kbs}
|
||||||
|
if normalized_resource_id not in visible_resource_ids:
|
||||||
|
return f"知识库资源 '{normalized_resource_id}' 不存在或当前会话未启用"
|
||||||
|
|
||||||
|
retrievers = knowledge_base.get_retrievers()
|
||||||
|
target_info = retrievers.get(normalized_resource_id)
|
||||||
|
if target_info is None:
|
||||||
|
return f"知识库资源 '{normalized_resource_id}' 不存在"
|
||||||
|
|
||||||
|
metadata = target_info.get("metadata") if isinstance(target_info, dict) else None
|
||||||
|
kb_type = str((metadata or {}).get("kb_type") or "").strip().lower()
|
||||||
|
if kb_type == "dify":
|
||||||
|
return "Dify 知识库为外部只读检索源,当前不支持通过 Open 打开全文"
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_offset = int(line) - 1 if line is not None else int(offset or 0)
|
||||||
|
window = await knowledge_base.open_file_content(
|
||||||
|
normalized_resource_id,
|
||||||
|
normalized_file_id,
|
||||||
|
offset=start_offset,
|
||||||
|
limit=window_size,
|
||||||
|
)
|
||||||
|
return {"resource_id": normalized_resource_id, "file_id": normalized_file_id, **window}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"打开知识库文档失败: {e}")
|
||||||
|
return f"打开知识库文档失败: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
def get_common_kb_tools() -> list:
|
def get_common_kb_tools() -> list:
|
||||||
"""获取通用知识库工具列表
|
"""获取通用知识库工具列表
|
||||||
|
|
||||||
返回 3 个通用工具:
|
返回 4 个通用工具:
|
||||||
- list_kbs: 列出用户可访问的知识库
|
- list_kbs: 列出用户可访问的知识库
|
||||||
- get_mindmap: 获取指定知识库的思维导图
|
- get_mindmap: 获取指定知识库的思维导图
|
||||||
- query_kb: 在指定知识库中检索
|
- query_kb: 在指定知识库中检索
|
||||||
|
- open_kb_document: 按 file_id 分段打开知识库文档
|
||||||
"""
|
"""
|
||||||
return [list_kbs, get_mindmap, query_kb]
|
return [list_kbs, get_mindmap, query_kb, open_kb_document]
|
||||||
|
|||||||
@ -397,6 +397,43 @@ class KnowledgeBase(ABC):
|
|||||||
content_bytes = await minio_client.adownload_file(bucket_name, object_name)
|
content_bytes = await minio_client.adownload_file(bucket_name, object_name)
|
||||||
return content_bytes.decode("utf-8")
|
return content_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
def _build_open_file_window(self, content: str, *, offset: int = 0, limit: int = 800) -> dict[str, Any]:
|
||||||
|
lines = content.splitlines()
|
||||||
|
total_lines = len(lines)
|
||||||
|
start = min(max(int(offset), 0), total_lines)
|
||||||
|
window_size = min(max(int(limit), 1), 2000)
|
||||||
|
selected = lines[start : start + window_size]
|
||||||
|
end = start + len(selected)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_line": start + 1 if selected else 0,
|
||||||
|
"end_line": end,
|
||||||
|
"total_lines": total_lines,
|
||||||
|
"offset": start,
|
||||||
|
"window_size": window_size,
|
||||||
|
"has_more_before": start > 0,
|
||||||
|
"has_more_after": end < total_lines,
|
||||||
|
"next_offset": end if end < total_lines else None,
|
||||||
|
"content": "\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(selected)),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def open_file_content(self, db_id: str, file_id: str, offset: int = 0, limit: int = 800) -> dict:
|
||||||
|
"""按行窗口打开文件解析后的 Markdown 内容"""
|
||||||
|
file_meta = self.files_meta.get(file_id)
|
||||||
|
if file_meta is None:
|
||||||
|
raise Exception(f"文件不存在: {file_id}")
|
||||||
|
if file_meta.get("database_id") != db_id:
|
||||||
|
raise Exception(f"文件 {file_id} 不属于知识库 {db_id}")
|
||||||
|
if file_meta.get("is_folder"):
|
||||||
|
raise Exception(f"文件 {file_id} 是文件夹")
|
||||||
|
|
||||||
|
markdown_file = file_meta.get("markdown_file")
|
||||||
|
if not markdown_file:
|
||||||
|
raise Exception(f"文件 {file_id} 没有解析后的 Markdown 内容")
|
||||||
|
|
||||||
|
content = await self._read_markdown_from_minio(markdown_file)
|
||||||
|
return self._build_open_file_window(content, offset=offset, limit=limit)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def index_file(self, db_id: str, file_id: str, operator_id: str | None = None) -> dict:
|
async def index_file(self, db_id: str, file_id: str, operator_id: str | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -63,6 +63,10 @@ class DifyKB(KnowledgeBase):
|
|||||||
async def get_file_content(self, db_id: str, file_id: str) -> dict:
|
async def get_file_content(self, db_id: str, file_id: str) -> dict:
|
||||||
raise self._readonly_error()
|
raise self._readonly_error()
|
||||||
|
|
||||||
|
async def open_file_content(self, db_id: str, file_id: str, offset: int = 0, limit: int = 800) -> dict:
|
||||||
|
del offset, limit
|
||||||
|
raise self._readonly_error()
|
||||||
|
|
||||||
async def get_file_info(self, db_id: str, file_id: str) -> dict:
|
async def get_file_info(self, db_id: str, file_id: str) -> dict:
|
||||||
raise self._readonly_error()
|
raise self._readonly_error()
|
||||||
|
|
||||||
|
|||||||
@ -528,6 +528,36 @@ class LightRagKB(KnowledgeBase):
|
|||||||
|
|
||||||
return processed_items_info
|
return processed_items_info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attach_file_ids_to_chunks(rag: LightRAG, chunks):
|
||||||
|
if not isinstance(chunks, list):
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
text_chunks = getattr(rag, "text_chunks", None)
|
||||||
|
chunk_store = getattr(text_chunks, "_data", None)
|
||||||
|
if not isinstance(chunk_store, dict):
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
if not isinstance(chunk, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata = chunk.get("metadata")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
chunk_id = metadata.get("chunk_id") or chunk.get("chunk_id") or chunk.get("id")
|
||||||
|
if chunk_id and not metadata.get("chunk_id"):
|
||||||
|
metadata["chunk_id"] = str(chunk_id)
|
||||||
|
|
||||||
|
stored_chunk = chunk_store.get(chunk_id)
|
||||||
|
if isinstance(stored_chunk, dict) and stored_chunk.get("full_doc_id") and not metadata.get("file_id"):
|
||||||
|
metadata["file_id"] = str(stored_chunk["full_doc_id"])
|
||||||
|
|
||||||
|
chunk["metadata"] = metadata
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
async def aquery(self, query_text: str, db_id: str, agent_call: bool = False, **kwargs) -> str:
|
async def aquery(self, query_text: str, db_id: str, agent_call: bool = False, **kwargs) -> str:
|
||||||
"""异步查询知识库"""
|
"""异步查询知识库"""
|
||||||
rag = await self._get_lightrag_instance(db_id)
|
rag = await self._get_lightrag_instance(db_id)
|
||||||
@ -579,7 +609,7 @@ class LightRagKB(KnowledgeBase):
|
|||||||
data = response.get("data", {}) or {}
|
data = response.get("data", {}) or {}
|
||||||
|
|
||||||
if scope == "chunks":
|
if scope == "chunks":
|
||||||
return data.get("chunks", [])
|
return self._attach_file_ids_to_chunks(rag, data.get("chunks", []))
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
if scope in ["graph", "all"]:
|
if scope in ["graph", "all"]:
|
||||||
@ -594,7 +624,7 @@ class LightRagKB(KnowledgeBase):
|
|||||||
result["references"] = data.get("references", [])
|
result["references"] = data.get("references", [])
|
||||||
|
|
||||||
if scope == "all":
|
if scope == "all":
|
||||||
result["chunks"] = data.get("chunks", [])
|
result["chunks"] = self._attach_file_ids_to_chunks(rag, data.get("chunks", []))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -505,6 +505,11 @@ class KnowledgeBaseManager:
|
|||||||
kb_instance = await self._get_kb_for_database(db_id)
|
kb_instance = await self._get_kb_for_database(db_id)
|
||||||
return await kb_instance.get_file_content(db_id, file_id)
|
return await kb_instance.get_file_content(db_id, file_id)
|
||||||
|
|
||||||
|
async def open_file_content(self, db_id: str, file_id: str, offset: int = 0, limit: int = 800) -> dict:
|
||||||
|
"""按行窗口打开文件解析后的 Markdown 内容"""
|
||||||
|
kb_instance = await self._get_kb_for_database(db_id)
|
||||||
|
return await kb_instance.open_file_content(db_id, file_id, offset, limit)
|
||||||
|
|
||||||
async def get_file_info(self, db_id: str, file_id: str) -> dict:
|
async def get_file_info(self, db_id: str, file_id: str) -> dict:
|
||||||
"""获取文件完整信息(基本信息+内容信息)- 保持向后兼容"""
|
"""获取文件完整信息(基本信息+内容信息)- 保持向后兼容"""
|
||||||
kb_instance = await self._get_kb_for_database(db_id)
|
kb_instance = await self._get_kb_for_database(db_id)
|
||||||
|
|||||||
34
backend/test/unit/knowledge/test_lightrag_chunk_mapping.py
Normal file
34
backend/test/unit/knowledge/test_lightrag_chunk_mapping.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from yuxi.knowledge.implementations.lightrag import LightRagKB
|
||||||
|
|
||||||
|
|
||||||
|
def test_lightrag_attaches_file_id_to_chunks_from_chunk_store() -> None:
|
||||||
|
rag = SimpleNamespace(
|
||||||
|
text_chunks=SimpleNamespace(
|
||||||
|
_data={
|
||||||
|
"chunk-1": {"full_doc_id": "file-1"},
|
||||||
|
"chunk-2": {"full_doc_id": "file-2"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chunks = [
|
||||||
|
{"chunk_id": "chunk-1", "content": "one"},
|
||||||
|
{"id": "chunk-2", "content": "two", "metadata": {}},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = LightRagKB._attach_file_ids_to_chunks(rag, chunks)
|
||||||
|
|
||||||
|
assert result[0]["metadata"] == {"chunk_id": "chunk-1", "file_id": "file-1"}
|
||||||
|
assert result[1]["metadata"] == {"chunk_id": "chunk-2", "file_id": "file-2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lightrag_keeps_existing_file_id_when_mapping_chunks() -> None:
|
||||||
|
rag = SimpleNamespace(text_chunks=SimpleNamespace(_data={"chunk-1": {"full_doc_id": "file-1"}}))
|
||||||
|
chunks = [{"metadata": {"chunk_id": "chunk-1", "file_id": "existing-file"}}]
|
||||||
|
|
||||||
|
result = LightRagKB._attach_file_ids_to_chunks(rag, chunks)
|
||||||
|
|
||||||
|
assert result[0]["metadata"] == {"chunk_id": "chunk-1", "file_id": "existing-file"}
|
||||||
@ -8,26 +8,59 @@ import pytest
|
|||||||
from yuxi.agents.toolkits.kbs import tools
|
from yuxi.agents.toolkits.kbs import tools
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_callable(tool):
|
||||||
|
callback = getattr(tool, "coroutine", None)
|
||||||
|
if callback is not None:
|
||||||
|
return callback
|
||||||
|
|
||||||
|
callback = getattr(tool, "func", None)
|
||||||
|
if callback is not None:
|
||||||
|
return callback
|
||||||
|
|
||||||
|
raise AssertionError(f"{tool.name} tool has no callable entry")
|
||||||
|
|
||||||
|
|
||||||
def _query_kb_callable():
|
def _query_kb_callable():
|
||||||
callback = getattr(tools.query_kb, "coroutine", None)
|
return _tool_callable(tools.query_kb)
|
||||||
if callback is not None:
|
|
||||||
return callback
|
|
||||||
|
|
||||||
callback = getattr(tools.query_kb, "func", None)
|
|
||||||
if callback is not None:
|
|
||||||
return callback
|
|
||||||
|
|
||||||
raise AssertionError("query_kb tool has no callable entry")
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_query_kb(**kwargs):
|
def _open_kb_document_callable():
|
||||||
callback = _query_kb_callable()
|
return _tool_callable(tools.open_kb_document)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_tool(callback, **kwargs):
|
||||||
result = callback(**kwargs)
|
result = callback(**kwargs)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
return await result
|
return await result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_query_kb(**kwargs):
|
||||||
|
return await _run_tool(_query_kb_callable(), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_open_kb_document(**kwargs):
|
||||||
|
return await _run_tool(_open_kb_document_callable(), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_test_window(content: str, offset: int = 0, limit: int = 800) -> dict:
|
||||||
|
lines = content.splitlines()
|
||||||
|
start = min(max(offset, 0), len(lines))
|
||||||
|
selected = lines[start : start + limit]
|
||||||
|
end = start + len(selected)
|
||||||
|
return {
|
||||||
|
"start_line": start + 1 if selected else 0,
|
||||||
|
"end_line": end,
|
||||||
|
"total_lines": len(lines),
|
||||||
|
"offset": start,
|
||||||
|
"window_size": limit,
|
||||||
|
"has_more_before": start > 0,
|
||||||
|
"has_more_after": end < len(lines),
|
||||||
|
"next_offset": end if end < len(lines) else None,
|
||||||
|
"content": "\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(selected)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_query_kb_injects_filepath_into_chunk_metadata(monkeypatch) -> None:
|
async def test_query_kb_injects_filepath_into_chunk_metadata(monkeypatch) -> None:
|
||||||
async def _fake_retriever(query_text: str, **kwargs):
|
async def _fake_retriever(query_text: str, **kwargs):
|
||||||
@ -209,3 +242,175 @@ async def test_query_kb_uses_backend_filepath_injector(monkeypatch) -> None:
|
|||||||
|
|
||||||
assert result[0]["metadata"]["filepath"] == "/home/gem/kbs/FAQ/auth-guide.pdf"
|
assert result[0]["metadata"]["filepath"] == "/home/gem/kbs/FAQ/auth-guide.pdf"
|
||||||
assert result[0]["metadata"]["parsed_path"] == "/home/gem/kbs/FAQ/parsed/auth-guide.pdf.md"
|
assert result[0]["metadata"]["parsed_path"] == "/home/gem/kbs/FAQ/parsed/auth-guide.pdf.md"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_kb_normalizes_file_metadata_for_open(monkeypatch) -> None:
|
||||||
|
async def _fake_retriever(query_text: str, **kwargs):
|
||||||
|
assert query_text == "auth"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"content": "auth guide",
|
||||||
|
"full_doc_id": "file-1",
|
||||||
|
"chunk_id": "chunk-1",
|
||||||
|
"chunk_index": 3,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tools.knowledge_base,
|
||||||
|
"get_retrievers",
|
||||||
|
lambda: {
|
||||||
|
"db-1": {
|
||||||
|
"name": "FAQ",
|
||||||
|
"retriever": _fake_retriever,
|
||||||
|
"metadata": {"kb_type": "lightrag"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fake_visible_kbs(runtime):
|
||||||
|
return [{"db_id": "db-1", "name": "FAQ"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs)
|
||||||
|
|
||||||
|
runtime = SimpleNamespace(context=SimpleNamespace())
|
||||||
|
result = await _run_query_kb(kb_name="FAQ", query_text="auth", runtime=runtime)
|
||||||
|
|
||||||
|
assert result[0]["metadata"] == {
|
||||||
|
"file_id": "file-1",
|
||||||
|
"chunk_id": "chunk-1",
|
||||||
|
"chunk_index": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_kb_document_reads_markdown_content_by_default_window(monkeypatch) -> None:
|
||||||
|
lines = [f"line {index}" for index in range(1, 1001)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tools.knowledge_base,
|
||||||
|
"get_retrievers",
|
||||||
|
lambda: {
|
||||||
|
"db-1": {
|
||||||
|
"name": "FAQ",
|
||||||
|
"retriever": object(),
|
||||||
|
"metadata": {"kb_type": "milvus"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fake_visible_kbs(runtime):
|
||||||
|
return [{"db_id": "db-1", "name": "FAQ"}]
|
||||||
|
|
||||||
|
async def _fake_open_file_content(db_id: str, file_id: str, offset: int = 0, limit: int = 800):
|
||||||
|
assert db_id == "db-1"
|
||||||
|
assert file_id == "file-1"
|
||||||
|
return _build_test_window("\n".join(lines), offset=offset, limit=limit)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs)
|
||||||
|
monkeypatch.setattr(tools.knowledge_base, "open_file_content", _fake_open_file_content)
|
||||||
|
|
||||||
|
runtime = SimpleNamespace(context=SimpleNamespace())
|
||||||
|
result = await _run_open_kb_document(resource_id="db-1", file_id="file-1", runtime=runtime)
|
||||||
|
|
||||||
|
assert result["resource_id"] == "db-1"
|
||||||
|
assert result["file_id"] == "file-1"
|
||||||
|
assert result["start_line"] == 1
|
||||||
|
assert result["end_line"] == 800
|
||||||
|
assert result["total_lines"] == 1000
|
||||||
|
assert result["window_size"] == 800
|
||||||
|
assert result["has_more_before"] is False
|
||||||
|
assert result["has_more_after"] is True
|
||||||
|
assert result["next_offset"] == 800
|
||||||
|
assert " 1\tline 1" in result["content"]
|
||||||
|
assert " 800\tline 800" in result["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_kb_document_prefers_line_over_offset(monkeypatch) -> None:
|
||||||
|
lines = [f"line {index}" for index in range(1, 1001)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tools.knowledge_base,
|
||||||
|
"get_retrievers",
|
||||||
|
lambda: {
|
||||||
|
"db-1": {
|
||||||
|
"name": "FAQ",
|
||||||
|
"retriever": object(),
|
||||||
|
"metadata": {"kb_type": "milvus"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fake_visible_kbs(runtime):
|
||||||
|
return [{"db_id": "db-1", "name": "FAQ"}]
|
||||||
|
|
||||||
|
async def _fake_open_file_content(db_id: str, file_id: str, offset: int = 0, limit: int = 800):
|
||||||
|
assert db_id == "db-1"
|
||||||
|
assert file_id == "file-1"
|
||||||
|
return _build_test_window("\n".join(lines), offset=offset, limit=limit)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs)
|
||||||
|
monkeypatch.setattr(tools.knowledge_base, "open_file_content", _fake_open_file_content)
|
||||||
|
|
||||||
|
runtime = SimpleNamespace(context=SimpleNamespace())
|
||||||
|
result = await _run_open_kb_document(
|
||||||
|
resource_id="db-1",
|
||||||
|
file_id="file-1",
|
||||||
|
line=801,
|
||||||
|
offset=0,
|
||||||
|
window_size=10,
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["offset"] == 800
|
||||||
|
assert result["start_line"] == 801
|
||||||
|
assert result["end_line"] == 810
|
||||||
|
assert result["has_more_before"] is True
|
||||||
|
assert result["has_more_after"] is True
|
||||||
|
assert result["next_offset"] == 810
|
||||||
|
assert " 801\tline 801" in result["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_kb_document_rejects_invisible_resource(monkeypatch) -> None:
|
||||||
|
async def _fake_visible_kbs(runtime):
|
||||||
|
return [{"db_id": "db-2", "name": "FAQ"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs)
|
||||||
|
|
||||||
|
runtime = SimpleNamespace(context=SimpleNamespace())
|
||||||
|
result = await _run_open_kb_document(resource_id="db-1", file_id="file-1", runtime=runtime)
|
||||||
|
|
||||||
|
assert "不存在或当前会话未启用" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_kb_document_requires_markdown_content(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tools.knowledge_base,
|
||||||
|
"get_retrievers",
|
||||||
|
lambda: {
|
||||||
|
"db-1": {
|
||||||
|
"name": "FAQ",
|
||||||
|
"retriever": object(),
|
||||||
|
"metadata": {"kb_type": "milvus"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fake_visible_kbs(runtime):
|
||||||
|
return [{"db_id": "db-1", "name": "FAQ"}]
|
||||||
|
|
||||||
|
async def _fake_open_file_content(db_id: str, file_id: str, offset: int = 0, limit: int = 800):
|
||||||
|
del db_id, file_id, offset, limit
|
||||||
|
raise Exception("文件 file-1 没有解析后的 Markdown 内容")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs)
|
||||||
|
monkeypatch.setattr(tools.knowledge_base, "open_file_content", _fake_open_file_content)
|
||||||
|
|
||||||
|
runtime = SimpleNamespace(context=SimpleNamespace())
|
||||||
|
result = await _run_open_kb_document(resource_id="db-1", file_id="file-1", runtime=runtime)
|
||||||
|
|
||||||
|
assert "没有解析后的 Markdown 内容" in result
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user