feat(agent): 统一知识库检索结果文件路径注入

This commit is contained in:
Wenjie Zhang 2026-04-05 23:52:10 +08:00
parent 59dcc6330b
commit 0e5456aae3
8 changed files with 758 additions and 93 deletions

View File

@ -13,6 +13,8 @@ Don't add error handling, fallbacks, or validation for scenarios that can't happ
Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task. Reuse existing abstractions where possible and follow the DRY principle.
To ensure readability, it is necessary to add essential comments at key points, particularly to explain the functionality of a function and the design intent.
## 开发与调试工作流 (Development & Debugging Workflow)
本项目完全通过 Docker Compose 进行管理。所有开发和调试都应在运行的容器环境中进行。使用 `docker compose up -d` 命令进行构建和启动。

View File

@ -13,6 +13,8 @@ Don't add error handling, fallbacks, or validation for scenarios that can't happ
Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task. Reuse existing abstractions where possible and follow the DRY principle.
To ensure readability, it is necessary to add essential comments at key points, particularly to explain the functionality of a function and the design intent.
## 开发与调试工作流 (Development & Debugging Workflow)
本项目完全通过 Docker Compose 进行管理。所有开发和调试都应在运行的容器环境中进行。使用 `docker compose up -d` 命令进行构建和启动。

View File

@ -1,7 +1,13 @@
from deepagents.backends import CompositeBackend, StateBackend
from .composite import create_agent_composite_backend
from .knowledge_base_backend import KBS_PATH, KnowledgeBaseReadonlyBackend, resolve_visible_knowledge_bases_for_context
from .knowledge_base_backend import (
KBS_PATH,
KnowledgeBaseReadonlyBackend,
build_knowledge_base_filepath_map,
resolve_file_relative_virtual_path,
resolve_visible_knowledge_bases_for_context,
)
from .sandbox import (
IDLE_CHECK_INTERVAL,
LARGE_TOOL_RESULTS_DIR,
@ -33,6 +39,8 @@ __all__ = [
"CompositeBackend",
"KBS_PATH",
"KnowledgeBaseReadonlyBackend",
"build_knowledge_base_filepath_map",
"resolve_file_relative_virtual_path",
"StateBackend",
"SelectedSkillsReadonlyBackend",
"create_agent_composite_backend",

View File

@ -28,6 +28,26 @@ class _MaterializedFile:
modified_at: str | None = None
@dataclass(frozen=True)
class _ResolvedVirtualNode:
db_id: str
file_id: str
path: str
parent_path: str
name: str
is_folder: bool
@dataclass(frozen=True)
class _KnowledgeBaseVirtualLayout:
kb_virtual_names: dict[str, str]
files_by_db: dict[str, dict[str, dict[str, Any]]]
nodes_by_db: dict[str, list[_ResolvedVirtualNode]]
nodes_by_file_id: dict[str, _ResolvedVirtualNode]
source_filepaths: dict[str, str]
parsed_filepaths: dict[str, str]
def _normalize_virtual_path(path: str | None) -> str:
raw = str(path or "").strip() or "/"
normalized = "/" + raw.lstrip("/")
@ -132,6 +152,239 @@ def _all_files_meta() -> dict[str, dict[str, Any]]:
return aggregated
def _resolve_kb_virtual_names(visible_kbs: list[dict[str, Any]]) -> dict[str, str]:
kb_name_candidates = {
str(db.get("db_id") or db.get("name") or f"kb-{index}"): _sanitize_segment(
db.get("name"),
str(db.get("db_id") or f"kb-{index}"),
)
for index, db in enumerate(visible_kbs)
}
used_root_names: set[str] = set()
kb_virtual_names: dict[str, str] = {}
sorted_kbs = sorted(
visible_kbs,
key=lambda item: (str(item.get("name") or ""), str(item.get("db_id") or "")),
)
for db in sorted_kbs:
db_id = str(db.get("db_id") or "")
if not db_id:
continue
base_name = kb_name_candidates.get(db_id) or db_id
kb_virtual_names[db_id] = _unique_name(base_name, stable_id=db_id, used_names=used_root_names)
return kb_virtual_names
def _resolve_db_virtual_nodes(
*,
db_id: str,
kb_root: str,
records: dict[str, dict[str, Any]],
) -> list[_ResolvedVirtualNode]:
# Keep sibling deduplication local to each parent directory so the generated tree is deterministic.
children_by_parent: dict[str | None, list[tuple[str, dict[str, Any]]]] = defaultdict(list)
for file_id, meta in records.items():
parent_id = str(meta.get("parent_id") or "").strip() or None
children_by_parent[parent_id].append((file_id, meta))
resolved_nodes: list[_ResolvedVirtualNode] = []
def walk(parent_id: str | None, parent_path: str) -> None:
used_names: set[str] = set()
siblings = children_by_parent.get(parent_id, [])
sorted_siblings = sorted(
siblings,
key=lambda item: (_sanitize_segment(_candidate_name(item[1]), item[0]), item[0]),
)
for file_id, meta in sorted_siblings:
base_name = _sanitize_segment(_candidate_name(meta), file_id)
unique_name = _unique_name(base_name, stable_id=file_id, used_names=used_names)
child_path = f"{parent_path.rstrip('/')}/{unique_name}" if parent_path != "/" else f"/{unique_name}"
node = _ResolvedVirtualNode(
db_id=db_id,
file_id=file_id,
path=child_path,
parent_path=parent_path,
name=unique_name,
is_folder=bool(meta.get("is_folder")),
)
resolved_nodes.append(node)
if node.is_folder:
walk(file_id, child_path)
walk(None, kb_root)
return resolved_nodes
def _build_parsed_filepath_map(
*,
nodes_by_file_id: dict[str, _ResolvedVirtualNode],
files_by_db: dict[str, dict[str, dict[str, Any]]],
kb_virtual_names: dict[str, str],
) -> dict[str, str]:
# Parsed files mirror the source tree and only add a fixed `/parsed` segment plus `.md` suffix.
parsed_paths: dict[str, str] = {}
for file_id, node in nodes_by_file_id.items():
if node.is_folder:
continue
record = files_by_db.get(node.db_id, {}).get(file_id, {})
if not str(record.get("markdown_file") or "").strip():
continue
kb_root = f"/{kb_virtual_names[node.db_id]}"
parsed_root = f"{kb_root}/parsed"
parsed_parent = (
f"{parsed_root}{node.parent_path[len(kb_root) :]}" if node.parent_path.startswith(kb_root) else parsed_root
)
parsed_name = _sanitize_segment(f"{node.name}.md", f"{file_id}.md")
parsed_path = f"{parsed_parent.rstrip('/')}/{parsed_name}" if parsed_parent != "/" else f"/{parsed_name}"
parsed_paths[file_id] = f"{KBS_PATH}{parsed_path}"
return parsed_paths
def _resolve_virtual_layout(
*,
visible_kbs: list[dict[str, Any]] | None,
files_meta: dict[str, dict[str, Any]] | None = None,
) -> _KnowledgeBaseVirtualLayout:
# Single source of truth for the virtual KB tree.
# The readonly backend and query_kb filepath injection must stay fully aligned.
kb_virtual_names = _resolve_kb_virtual_names(list(visible_kbs or []))
if not kb_virtual_names:
return _KnowledgeBaseVirtualLayout(
kb_virtual_names={},
files_by_db={},
nodes_by_db={},
nodes_by_file_id={},
source_filepaths={},
parsed_filepaths={},
)
source_files = _all_files_meta() if files_meta is None else files_meta
files_by_db: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
for file_id, meta in source_files.items():
db_id = str(meta.get("database_id") or "")
if db_id in kb_virtual_names:
files_by_db[db_id][str(file_id)] = meta
nodes_by_db: dict[str, list[_ResolvedVirtualNode]] = {}
nodes_by_file_id: dict[str, _ResolvedVirtualNode] = {}
source_filepaths: dict[str, str] = {}
for db_id, kb_virtual_name in kb_virtual_names.items():
kb_root = f"/{kb_virtual_name}"
records = files_by_db.get(db_id, {})
nodes = _resolve_db_virtual_nodes(db_id=db_id, kb_root=kb_root, records=records)
nodes_by_db[db_id] = nodes
for node in nodes:
nodes_by_file_id[node.file_id] = node
if not node.is_folder:
source_filepaths[node.file_id] = f"{KBS_PATH}{node.path}"
return _KnowledgeBaseVirtualLayout(
kb_virtual_names=kb_virtual_names,
files_by_db=dict(files_by_db),
nodes_by_db=nodes_by_db,
nodes_by_file_id=nodes_by_file_id,
source_filepaths=source_filepaths,
parsed_filepaths=_build_parsed_filepath_map(
nodes_by_file_id=nodes_by_file_id,
files_by_db=files_by_db,
kb_virtual_names=kb_virtual_names,
),
)
def resolve_file_relative_virtual_path(
*,
file_id: str,
visible_kbs: list[dict[str, Any]] | None,
files_meta: dict[str, dict[str, Any]] | None = None,
) -> str | None:
normalized_id = str(file_id or "").strip()
if not normalized_id:
return None
layout = _resolve_virtual_layout(
visible_kbs=visible_kbs,
files_meta=files_meta,
)
node = layout.nodes_by_file_id.get(normalized_id)
if node is None or node.is_folder:
return None
return node.path
def build_knowledge_base_filepath_map(
*,
visible_kbs: list[dict[str, Any]] | None,
files_meta: dict[str, dict[str, Any]] | None = None,
) -> dict[str, str]:
layout = _resolve_virtual_layout(
visible_kbs=visible_kbs,
files_meta=files_meta,
)
return layout.source_filepaths.copy()
def _inject_kb_filepaths(
chunks: list[dict[str, Any]],
filepath_map: dict[str, str],
parsed_filepath_map: dict[str, str],
) -> list[dict[str, Any]]:
if not filepath_map and not parsed_filepath_map:
return chunks
for chunk in chunks:
if not isinstance(chunk, dict):
continue
metadata = chunk.get("metadata")
if metadata is None:
metadata = {}
if not isinstance(metadata, dict):
continue
file_id = str(metadata.get("file_id") or chunk.get("file_id") or "").strip()
if not file_id:
continue
if not metadata.get("filepath"):
filepath = filepath_map.get(file_id)
if filepath:
metadata["filepath"] = filepath
if not metadata.get("parsed_path"):
parsed_path = parsed_filepath_map.get(file_id)
if parsed_path:
metadata["parsed_path"] = parsed_path
chunk["metadata"] = metadata
return chunks
async def inject_filepaths_into_retrieval_result(
*,
retrieval_chunks: list[dict[str, Any]],
visible_kbs: list[dict[str, Any]] | None,
target_db_id: str | None,
target_kb_name: str | None = None,
) -> list[dict[str, Any]]:
scope_kbs = list(visible_kbs or [])
if not scope_kbs and target_db_id:
scope_kbs = [{"db_id": target_db_id, "name": target_kb_name or target_db_id}]
layout = _resolve_virtual_layout(visible_kbs=scope_kbs)
return _inject_kb_filepaths(
retrieval_chunks,
layout.source_filepaths,
layout.parsed_filepaths,
)
class KnowledgeBaseReadonlyBackend(FilesystemBackend):
def __init__(self, *, visible_kbs: list[dict[str, Any]] | None, cache_root: Path | str | None = None):
self._cache_root = Path(cache_root or (Path(conf.save_dir) / "knowledge_base_data" / "kb-cache")).resolve()
@ -148,37 +401,13 @@ class KnowledgeBaseReadonlyBackend(FilesystemBackend):
return bool(self._visible_kbs)
def _build_virtual_tree(self) -> None:
kb_name_candidates = {
str(db.get("db_id") or db.get("name") or f"kb-{index}"): _sanitize_segment(
db.get("name"),
str(db.get("db_id") or f"kb-{index}"),
)
for index, db in enumerate(self._visible_kbs)
}
used_root_names: set[str] = set()
kb_virtual_names: dict[str, str] = {}
sorted_kbs = sorted(
self._visible_kbs,
key=lambda item: (str(item.get("name") or ""), str(item.get("db_id") or "")),
)
for db in sorted_kbs:
db_id = str(db.get("db_id") or "")
if not db_id:
continue
base_name = kb_name_candidates.get(db_id) or db_id
kb_virtual_names[db_id] = _unique_name(base_name, stable_id=db_id, used_names=used_root_names)
layout = _resolve_virtual_layout(visible_kbs=self._visible_kbs)
files_by_db: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
for file_id, meta in _all_files_meta().items():
db_id = str(meta.get("database_id") or "")
if db_id in kb_virtual_names:
files_by_db[db_id][str(file_id)] = meta
for db_id, kb_virtual_name in kb_virtual_names.items():
for db_id, kb_virtual_name in layout.kb_virtual_names.items():
kb_root = f"/{kb_virtual_name}"
self._add_entry("/", kb_root, is_dir=True)
records = files_by_db.get(db_id, {})
self._build_source_tree(db_id=db_id, kb_root=kb_root, records=records)
records = layout.files_by_db.get(db_id, {})
self._build_source_tree(db_id=db_id, records=records, nodes=layout.nodes_by_db.get(db_id, []))
self._build_parsed_tree(db_id=db_id, kb_root=kb_root, records=records)
for path, entries in list(self._entries_by_dir.items()):
@ -186,62 +415,48 @@ class KnowledgeBaseReadonlyBackend(FilesystemBackend):
self._entries_by_dir[path] = entries
self._all_files = sorted(self._all_files, key=lambda item: str(item.get("path") or ""))
def _build_source_tree(self, *, db_id: str, kb_root: str, records: dict[str, dict[str, Any]]) -> None:
children_by_parent: dict[str | None, list[dict[str, Any]]] = defaultdict(list)
for file_id, meta in records.items():
parent_id = meta.get("parent_id")
children_by_parent[str(parent_id) if parent_id else None].append({"file_id": file_id, "meta": meta})
def _build_source_tree(
self,
*,
db_id: str,
records: dict[str, dict[str, Any]],
nodes: list[_ResolvedVirtualNode],
) -> None:
resolved_parent_paths: dict[str, str] = {}
resolved_source_names: dict[str, str] = {}
def walk(parent_id: str | None, parent_path: str) -> None:
siblings = children_by_parent.get(parent_id, [])
used_names: set[str] = set()
candidates: list[tuple[dict[str, Any], str]] = []
for item in siblings:
file_id = item["file_id"]
meta = item["meta"]
base_name = _sanitize_segment(_candidate_name(meta), file_id)
candidates.append((item, base_name))
for node in sorted(nodes, key=lambda item: item.path):
meta = records.get(node.file_id, {})
modified_at = meta.get("updated_at") or meta.get("created_at")
if node.is_folder:
self._add_entry(node.parent_path, node.path, is_dir=True, modified_at=modified_at)
continue
for item, base_name in sorted(candidates, key=lambda pair: (pair[1], pair[0]["file_id"])):
file_id = item["file_id"]
meta = item["meta"]
unique_name = _unique_name(base_name, stable_id=file_id, used_names=used_names)
child_path = f"{parent_path.rstrip('/')}/{unique_name}" if parent_path != "/" else f"/{unique_name}"
if meta.get("is_folder"):
modified_at = meta.get("updated_at") or meta.get("created_at")
self._add_entry(parent_path, child_path, is_dir=True, modified_at=modified_at)
walk(file_id, child_path)
continue
self._add_entry(
node.parent_path,
node.path,
is_dir=False,
size=int(meta.get("size") or 0),
modified_at=modified_at,
)
resolved_parent_paths[node.file_id] = node.parent_path
resolved_source_names[node.file_id] = node.name
cache_path = self._cache_root / db_id / "source" / node.file_id / node.name
self._files[node.path] = _MaterializedFile(
virtual_path=node.path,
cache_path=cache_path,
source_path=str(meta.get("path") or ""),
modified_at=modified_at,
)
self._all_files.append(
{
"path": node.path,
"is_dir": False,
"size": int(meta.get("size") or 0),
"modified_at": str(modified_at or ""),
}
)
self._add_entry(
parent_path,
child_path,
is_dir=False,
size=int(meta.get("size") or 0),
modified_at=meta.get("updated_at") or meta.get("created_at"),
)
resolved_parent_paths[file_id] = parent_path
resolved_source_names[file_id] = unique_name
cache_path = self._cache_root / db_id / "source" / file_id / unique_name
self._files[child_path] = _MaterializedFile(
virtual_path=child_path,
cache_path=cache_path,
source_path=str(meta.get("path") or ""),
modified_at=meta.get("updated_at") or meta.get("created_at"),
)
self._all_files.append(
{
"path": child_path,
"is_dir": False,
"size": int(meta.get("size") or 0),
"modified_at": str(meta.get("updated_at") or meta.get("created_at") or ""),
}
)
walk(None, kb_root)
self._resolved_parent_paths = getattr(self, "_resolved_parent_paths", {})
self._resolved_parent_paths[db_id] = resolved_parent_paths
self._resolved_source_names = getattr(self, "_resolved_source_names", {})
@ -268,8 +483,8 @@ class KnowledgeBaseReadonlyBackend(FilesystemBackend):
f"{parsed_root}{source_parent[len(kb_root) :]}" if source_parent.startswith(kb_root) else parsed_root
)
source_name = source_names.get(file_id) or _sanitize_segment(meta.get("filename"), file_id)
source_stem = PurePosixPath(source_name).stem or source_name or file_id
base_name = _sanitize_segment(f"{source_stem}.md", f"{file_id}.md")
safe_name = source_name or file_id
base_name = _sanitize_segment(f"{safe_name}.md", f"{file_id}.md")
grouped[parsed_parent].append((file_id, meta, base_name))
for parsed_parent, items in grouped.items():

View File

@ -160,6 +160,53 @@ class QueryKBInput(BaseModel):
)
async def _resolve_visible_knowledge_bases_for_query(runtime: ToolRuntime | None) -> list[dict[str, Any]]:
if runtime is None:
return []
context = getattr(runtime, "context", None)
if context is None:
return []
visible_kbs = getattr(context, "_visible_knowledge_bases", None)
if isinstance(visible_kbs, list):
return visible_kbs
try:
from yuxi.agents.backends.knowledge_base_backend import resolve_visible_knowledge_bases_for_context
return await resolve_visible_knowledge_bases_for_context(context)
except Exception as exc: # noqa: BLE001
logger.warning(f"解析会话可见知识库失败,跳过 filepath 注入: {exc}")
return []
def _find_query_target(
*,
kb_name: str,
retrievers: dict[str, Any],
visible_kbs: list[dict[str, Any]],
) -> tuple[str | None, dict[str, Any] | None, str | None]:
if visible_kbs:
matched_kbs = [db for db in visible_kbs if str(db.get("name") or "").strip() == kb_name]
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 "")
target_info = retrievers.get(target_db_id)
if target_info is None:
return None, None, f"知识库 '{kb_name}' 不存在"
return target_db_id, target_info, None
for db_id, info in retrievers.items():
if info["name"] == kb_name:
return str(db_id), info, None
return None, None, f"知识库 '{kb_name}' 不存在"
@tool(args_schema=QueryKBInput)
async def query_kb(kb_name: str, query_text: str, file_name: str | None = None, runtime: ToolRuntime = None) -> Any:
"""在指定知识库中检索内容
@ -182,15 +229,20 @@ async def query_kb(kb_name: str, query_text: str, file_name: str | None = None,
# 获取所有检索器
retrievers = knowledge_base.get_retrievers()
# 查找对应的知识库
target_info = None
for db_id, info in retrievers.items():
if info["name"] == kb_name:
target_info = info
break
visible_kbs = await _resolve_visible_knowledge_bases_for_query(runtime)
if not target_info:
return f"知识库 '{kb_name}' 不存在"
target_db_id, target_info, target_error = _find_query_target(
kb_name=kb_name,
retrievers=retrievers,
visible_kbs=visible_kbs,
)
if target_error:
return target_error
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 != "milvus":
return f"知识库 '{kb_name}' 不是 Milvus 类型,当前 query_kb 仅支持 Milvus"
try:
retriever = target_info["retriever"]
@ -203,7 +255,17 @@ async def query_kb(kb_name: str, query_text: str, file_name: str | None = None,
else:
result = retriever(query_text, **kwargs)
return result
if not isinstance(result, list):
return f"知识库 '{kb_name}' 返回结果不是 Milvus chunks 列表,当前 query_kb 仅支持 Milvus"
from yuxi.agents.backends.knowledge_base_backend import inject_filepaths_into_retrieval_result
return await inject_filepaths_into_retrieval_result(
retrieval_chunks=result,
visible_kbs=visible_kbs,
target_db_id=target_db_id,
target_kb_name=kb_name,
)
except Exception as e:
logger.error(f"检索失败: {e}")

View File

@ -4,7 +4,11 @@ from types import SimpleNamespace
import pytest
from yuxi.agents.backends.knowledge_base_backend import (
KBS_PATH,
KnowledgeBaseReadonlyBackend,
build_knowledge_base_filepath_map,
inject_filepaths_into_retrieval_result,
resolve_file_relative_virtual_path,
resolve_visible_knowledge_bases_for_context,
)
@ -78,9 +82,9 @@ def test_knowledge_base_backend_builds_virtual_tree_and_materializes_files(monke
assert any(path.startswith("/FAQ/API/auth-guide.pdf__file-pdf") for path in api_entries)
parsed_entries = {entry["path"] for entry in backend.ls_info("/FAQ/parsed/API")}
assert "/FAQ/parsed/API/auth-guide.md" in parsed_entries
assert "/FAQ/parsed/API/auth-guide.pdf.md" in parsed_entries
responses = backend.download_files(["/FAQ/API/auth-guide.pdf", "/FAQ/parsed/API/auth-guide.md"])
responses = backend.download_files(["/FAQ/API/auth-guide.pdf", "/FAQ/parsed/API/auth-guide.pdf.md"])
assert responses[0].content == b"kb-source:db-1/upload/auth-guide.pdf"
assert responses[1].content == b"kb-parsed:db-1/parsed/file-pdf.md"
@ -104,3 +108,218 @@ async def test_resolve_visible_knowledge_bases_for_context_filters_by_enabled_na
assert visible == [{"db_id": "db-2", "name": "Beta"}]
assert getattr(context, "_visible_knowledge_bases") == visible
def test_build_knowledge_base_filepath_map_matches_virtual_tree(monkeypatch, tmp_path) -> None:
visible_kbs = [
{"db_id": "db-1", "name": "FAQ"},
{"db_id": "db-2", "name": "FAQ"},
]
files_meta = {
"folder-api": {
"file_id": "folder-api",
"database_id": "db-1",
"parent_id": None,
"filename": "API",
"is_folder": True,
"created_at": "2026-03-26T00:00:00Z",
},
"file-pdf": {
"file_id": "file-pdf",
"database_id": "db-1",
"parent_id": "folder-api",
"filename": "auth-guide.pdf",
"path": "http://minio/kb-source/db-1/upload/auth-guide.pdf",
"size": 12,
"is_folder": False,
"created_at": "2026-03-26T00:00:00Z",
},
"file-pdf-2": {
"file_id": "file-pdf-2",
"database_id": "db-1",
"parent_id": "folder-api",
"filename": "auth-guide.pdf",
"path": "http://minio/kb-source/db-1/upload/auth-guide-2.pdf",
"size": 7,
"is_folder": False,
"created_at": "2026-03-26T00:00:00Z",
},
"file-db2": {
"file_id": "file-db2",
"database_id": "db-2",
"parent_id": None,
"filename": "overview.txt",
"path": "http://minio/kb-source/db-2/upload/overview.txt",
"size": 5,
"is_folder": False,
"created_at": "2026-03-26T00:00:00Z",
},
}
monkeypatch.setattr("yuxi.agents.backends.knowledge_base_backend._all_files_meta", lambda: files_meta)
filepath_map = build_knowledge_base_filepath_map(visible_kbs=visible_kbs, files_meta=files_meta)
backend = KnowledgeBaseReadonlyBackend(visible_kbs=visible_kbs, cache_root=tmp_path)
assert filepath_map["file-pdf"] == f"{KBS_PATH}/FAQ/API/auth-guide.pdf"
assert filepath_map["file-db2"] == f"{KBS_PATH}/FAQ__db-2/overview.txt"
assert filepath_map["file-pdf-2"].startswith(f"{KBS_PATH}/FAQ/API/auth-guide.pdf__")
mapped_virtual_paths = {path[len(KBS_PATH) :] for path in filepath_map.values()}
for virtual_path in mapped_virtual_paths:
assert virtual_path in backend._files
def test_resolve_file_relative_virtual_path_by_file_id(monkeypatch) -> None:
visible_kbs = [{"db_id": "db-1", "name": "食品 相关文献"}]
files_meta = {
"folder-1": {
"file_id": "folder-1",
"database_id": "db-1",
"parent_id": None,
"filename": "课程",
"is_folder": True,
"created_at": "2026-03-26T00:00:00Z",
},
"file_79c496": {
"file_id": "file_79c496",
"database_id": "db-1",
"parent_id": "folder-1",
"filename": "营养与食品课程中的思政建设研究.pdf",
"path": "http://minio/knowledgebases/db-1/uploads/file_79c496.pdf",
"is_folder": False,
"size": 100,
"created_at": "2026-03-26T00:00:00Z",
},
}
monkeypatch.setattr("yuxi.agents.backends.knowledge_base_backend._all_files_meta", lambda: files_meta)
relative_path = resolve_file_relative_virtual_path(file_id="file_79c496", visible_kbs=visible_kbs)
absolute_map = build_knowledge_base_filepath_map(visible_kbs=visible_kbs, files_meta=files_meta)
assert relative_path == "/食品 相关文献/课程/营养与食品课程中的思政建设研究.pdf"
assert absolute_map["file_79c496"] == f"{KBS_PATH}{relative_path}"
@pytest.mark.asyncio
async def test_inject_filepaths_into_retrieval_result_injects_by_file_id(monkeypatch) -> None:
retrieval_chunks = [
{
"content": "auth guide",
"metadata": {
"file_id": "file-1",
"source": "auth-guide.pdf",
},
}
]
monkeypatch.setattr(
"yuxi.agents.backends.knowledge_base_backend._resolve_virtual_layout",
lambda **kwargs: SimpleNamespace(
source_filepaths={"file-1": f"{KBS_PATH}/FAQ/auth-guide.pdf"},
parsed_filepaths={"file-1": f"{KBS_PATH}/FAQ/parsed/auth-guide.pdf.md"},
),
)
result = await inject_filepaths_into_retrieval_result(
retrieval_chunks=retrieval_chunks,
visible_kbs=[{"db_id": "db-1", "name": "FAQ"}],
target_db_id="db-1",
)
assert result[0]["metadata"]["filepath"] == f"{KBS_PATH}/FAQ/auth-guide.pdf"
assert result[0]["metadata"]["parsed_path"] == f"{KBS_PATH}/FAQ/parsed/auth-guide.pdf.md"
@pytest.mark.asyncio
async def test_inject_filepaths_into_retrieval_result_injects_parsed_path_when_markdown_exists(monkeypatch) -> None:
files_meta = {
"file-1": {
"file_id": "file-1",
"database_id": "db-1",
"parent_id": None,
"filename": "auth-guide.pdf",
"path": "http://minio/kb-source/db-1/upload/auth-guide.pdf",
"markdown_file": "http://minio/kb-parsed/db-1/parsed/file-1.md",
"is_folder": False,
"created_at": "2026-03-26T00:00:00Z",
}
}
monkeypatch.setattr("yuxi.agents.backends.knowledge_base_backend._all_files_meta", lambda: files_meta)
retrieval_chunks = [{"content": "auth guide", "metadata": {"file_id": "file-1"}}]
result = await inject_filepaths_into_retrieval_result(
retrieval_chunks=retrieval_chunks,
visible_kbs=[{"db_id": "db-1", "name": "FAQ"}],
target_db_id="db-1",
)
assert result[0]["metadata"]["filepath"] == f"{KBS_PATH}/FAQ/auth-guide.pdf"
assert result[0]["metadata"]["parsed_path"] == f"{KBS_PATH}/FAQ/parsed/auth-guide.pdf.md"
@pytest.mark.asyncio
async def test_inject_filepaths_into_retrieval_result_does_not_use_filename_fallback(monkeypatch) -> None:
retrieval_chunks = [
{
"id": "chunk-1",
"metadata": {
"source": "auth-guide.pdf",
},
}
]
monkeypatch.setattr(
"yuxi.agents.backends.knowledge_base_backend._resolve_virtual_layout",
lambda **kwargs: SimpleNamespace(
source_filepaths={
"file-1": f"{KBS_PATH}/FAQ/API/auth-guide.pdf",
"file-2": f"{KBS_PATH}/FAQ/API/another.pdf",
},
parsed_filepaths={
"file-1": f"{KBS_PATH}/FAQ/parsed/API/auth-guide.pdf.md",
"file-2": f"{KBS_PATH}/FAQ/parsed/API/another.pdf.md",
},
),
)
result = await inject_filepaths_into_retrieval_result(
retrieval_chunks=retrieval_chunks,
visible_kbs=[{"db_id": "db-1", "name": "FAQ"}],
target_db_id="db-1",
)
assert "filepath" not in result[0]["metadata"]
assert "parsed_path" not in result[0]["metadata"]
@pytest.mark.asyncio
async def test_inject_filepaths_into_retrieval_result_requires_explicit_file_id(monkeypatch) -> None:
retrieval_chunks = [
{
"content": "chunk",
"metadata": {
"source": "http://172.19.13.5:9000/knowledgebases/kb-1/parsed/file_79c496.md",
},
}
]
monkeypatch.setattr(
"yuxi.agents.backends.knowledge_base_backend._resolve_virtual_layout",
lambda **kwargs: SimpleNamespace(
source_filepaths={
"file_79c496": f"{KBS_PATH}/食品 相关文献/课程/营养与食品课程中的思政建设研究.pdf",
},
parsed_filepaths={
"file_79c496": f"{KBS_PATH}/食品 相关文献/parsed/课程/营养与食品课程中的思政建设研究.pdf.md",
},
),
)
result = await inject_filepaths_into_retrieval_result(
retrieval_chunks=retrieval_chunks,
visible_kbs=[{"db_id": "db-1", "name": "食品 相关文献"}],
target_db_id="db-1",
)
assert "filepath" not in result[0]["metadata"]
assert "parsed_path" not in result[0]["metadata"]

View File

@ -0,0 +1,156 @@
from __future__ import annotations
import inspect
from types import SimpleNamespace
import pytest
from yuxi.agents.toolkits.kbs import tools
def _query_kb_callable():
callback = getattr(tools.query_kb, "coroutine", None)
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):
callback = _query_kb_callable()
result = callback(**kwargs)
if inspect.isawaitable(result):
return await result
return result
@pytest.mark.asyncio
async def test_query_kb_injects_filepath_into_chunk_metadata(monkeypatch) -> None:
async def _fake_retriever(query_text: str, **kwargs):
assert query_text == "auth"
return [
{
"content": "auth guide",
"metadata": {
"file_id": "file-1",
"source": "auth-guide.pdf",
},
}
]
monkeypatch.setattr(
tools.knowledge_base,
"get_retrievers",
lambda: {
"db-1": {
"name": "FAQ",
"retriever": _fake_retriever,
"metadata": {"kb_type": "milvus"},
}
},
)
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)
async def _fake_inject(*, retrieval_chunks, visible_kbs, target_db_id, target_kb_name=None):
assert visible_kbs == [{"db_id": "db-1", "name": "FAQ"}]
assert target_db_id == "db-1"
retrieval_chunks[0]["metadata"]["filepath"] = "/home/gem/kbs/FAQ/API/auth-guide.pdf"
retrieval_chunks[0]["metadata"]["parsed_path"] = "/home/gem/kbs/FAQ/parsed/API/auth-guide.pdf.md"
return retrieval_chunks
monkeypatch.setattr(
"yuxi.agents.backends.knowledge_base_backend.inject_filepaths_into_retrieval_result",
_fake_inject,
)
runtime = SimpleNamespace(context=SimpleNamespace())
result = await _run_query_kb(kb_name="FAQ", query_text="auth", runtime=runtime)
assert result[0]["metadata"]["filepath"] == "/home/gem/kbs/FAQ/API/auth-guide.pdf"
assert result[0]["metadata"]["parsed_path"] == "/home/gem/kbs/FAQ/parsed/API/auth-guide.pdf.md"
@pytest.mark.asyncio
async def test_query_kb_rejects_non_milvus_knowledge_base(monkeypatch) -> None:
async def _fake_retriever(query_text: str, **kwargs):
assert query_text == "auth"
return []
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 == "知识库 'FAQ' 不是 Milvus 类型,当前 query_kb 仅支持 Milvus"
@pytest.mark.asyncio
async def test_query_kb_uses_backend_filepath_injector(monkeypatch) -> None:
async def _fake_retriever(query_text: str, **kwargs):
assert query_text == "auth"
return [
{
"content": "auth guide",
"metadata": {
"file_id": "file-1",
"source": "auth-guide.pdf",
},
}
]
monkeypatch.setattr(
tools.knowledge_base,
"get_retrievers",
lambda: {
"db-1": {
"name": "FAQ",
"retriever": _fake_retriever,
"metadata": {"kb_type": "milvus"},
}
},
)
async def _fake_visible_kbs(runtime):
return [{"db_id": "db-1", "name": "FAQ"}]
async def _fake_inject(*, retrieval_chunks, visible_kbs, target_db_id, target_kb_name=None):
assert visible_kbs == [{"db_id": "db-1", "name": "FAQ"}]
assert target_db_id == "db-1"
retrieval_chunks[0]["metadata"]["filepath"] = "/home/gem/kbs/FAQ/auth-guide.pdf"
retrieval_chunks[0]["metadata"]["parsed_path"] = "/home/gem/kbs/FAQ/parsed/auth-guide.pdf.md"
return retrieval_chunks
monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs)
monkeypatch.setattr(
"yuxi.agents.backends.knowledge_base_backend.inject_filepaths_into_retrieval_result",
_fake_inject,
)
runtime = SimpleNamespace(context=SimpleNamespace())
result = await _run_query_kb(kb_name="FAQ", query_text="auth", runtime=runtime)
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"

View File

@ -40,6 +40,7 @@
- 新增 Skills 远程安装能力Skills 管理页支持填写 `owner/repo` 或 GitHub URL后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求
- 调整部门删除语义:删除部门时不再要求用户数为 0而是将部门下用户迁移到默认部门同时清理部门级配置和部门 API Key保证测试部门、撤换部门等场景可直接删除并补充对应集成测试覆盖该链路
- 重构 MCP 运行时配置加载模型:移除 `MCP_SERVERS` 作为运行正确性前提的设计,改为每次直接从数据库读取最新 MCP 配置,并用 `server_name:config_hash` 作为本地工具缓存 key同时将内置 MCP 初始化职责收敛为仅同步数据库默认项,前端 MCP 选项改为直接使用实时资源列表,解决 `api`/`worker` 分进程下的配置不一致与缓存失效问题
- 为知识库检索工具补充 `metadata.filepath` 注入:在 `query_kb` 统一出口基于会话可见知识库构建 `file_id -> /home/gem/kbs/...` 映射并回填检索结果,注入逻辑复用知识库只读后端命名规则;并将工具调用范围收敛为 Milvus仅支持 Milvus chunks 列表且要求显式 `file_id`),不再兼容无显式 `file_id` 的推断注入,新增单测覆盖该约束
---