mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 11:36:17 +00:00
* feat(knowledge): integrate RAGFlow retrieval and management * test(knowledge): cover merged listing tool * feat(knowledge): add per-message retrieval scope * chore(docs): remove unrelated document * docs(knowledge): add interaction screenshots * feat(knowledge): simplify scope selector trigger * docs(knowledge): refresh selector screenshot * feat(knowledge): defer standalone management * docs(knowledge): show chat-only scope UI * fix(knowledge): honor scope on clarification replies * fix(knowledge): harden scoped replay validation * docs(knowledge): clarify replay scope precedence * fix(knowledge): keep provider settings on tools * fix(config): preserve tools-only knowledge settings * fix(knowledge): submit custom assistant identity * refactor(knowledge): trim PR scope changes * fix(knowledge): sanitize document scope display * feat(knowledge): enable scope selection in main chat * fix(knowledge): emphasize active scope icon without button frame * fix(knowledge): close context scrubbing and refresh e2e checks * fix(knowledge): preserve idempotent canonical retries * fix(knowledge): accept promptless conversation runs * style(knowledge): format backend regression tests * chore(knowledge): trim PR scope and fix frontend format * fix(knowledge): remove shared-scope notice * fix(knowledge): remove scope persistence notice * docs(knowledge): include main chat in catalog scope * fix(knowledge): preserve scope recovery and upgrades * fix(config): preserve LightRAG knowledge upgrades --------- Co-authored-by: foreleven <for-eleven@hotmail.com>
1068 lines
39 KiB
Python
1068 lines
39 KiB
Python
import asyncio
|
|
import logging
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import deerflow.community.ragflow.tools as ragflow_tools
|
|
from deerflow.community.ragflow.client import RAGFlowAPIError, RAGFlowConnectionError
|
|
from deerflow.community.ragflow.formatting import format_retrieval_result
|
|
from deerflow.config.tool_config import ToolConfig
|
|
from deerflow.tools.tools import get_available_tools
|
|
|
|
DATASET_ID_1 = "0123456789abcdef0123456789abcdef"
|
|
DATASET_ID_2 = "fedcba9876543210fedcba9876543210"
|
|
MISSING_DATASET_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
EMBEDDING_V2 = "text-embedding-v2@primary@Tongyi-Qianwen"
|
|
EMBEDDING_V3 = "text-embedding-v3@primary@Tongyi-Qianwen"
|
|
|
|
|
|
def _dataset(
|
|
dataset_id: str,
|
|
name: str,
|
|
*,
|
|
embedding_model: str = EMBEDDING_V3,
|
|
chunk_count: int = 1,
|
|
) -> dict:
|
|
return {
|
|
"id": dataset_id,
|
|
"name": name,
|
|
"embedding_model": embedding_model,
|
|
"chunk_count": chunk_count,
|
|
}
|
|
|
|
|
|
class FakeRAGFlowClient:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
datasets_by_id: Mapping[str, list[dict]] | None = None,
|
|
dataset_errors_by_id: Mapping[str, Exception] | None = None,
|
|
all_datasets: list[dict] | None = None,
|
|
retrieval: dict | None = None,
|
|
retrieval_by_dataset_ids: Mapping[tuple[str, ...], dict] | None = None,
|
|
retrieval_errors_by_dataset_ids: Mapping[tuple[str, ...], Exception] | None = None,
|
|
error: Exception | None = None,
|
|
documents_by_dataset_id: Mapping[str, list[dict]] | None = None,
|
|
) -> None:
|
|
self.datasets_by_id = dict(datasets_by_id or {})
|
|
self.dataset_errors_by_id = dict(dataset_errors_by_id or {})
|
|
self.all_datasets = list(all_datasets or [])
|
|
self.retrieval = retrieval or {"chunks": [], "doc_aggs": [], "total": 0}
|
|
self.retrieval_by_dataset_ids = dict(retrieval_by_dataset_ids or {})
|
|
self.retrieval_errors_by_dataset_ids = dict(retrieval_errors_by_dataset_ids or {})
|
|
self.error = error
|
|
self.documents_by_dataset_id = dict(documents_by_dataset_id or {})
|
|
self.list_calls: list[str | None] = []
|
|
self.document_list_calls: list[tuple[str, list[tuple[str, str]]]] = []
|
|
self.retrieve_calls: list[tuple[str, dict]] = []
|
|
|
|
async def list_datasets(self, *, dataset_id: str | None = None) -> list[dict]:
|
|
if self.error is not None:
|
|
raise self.error
|
|
self.list_calls.append(dataset_id)
|
|
if dataset_id is None:
|
|
return self.all_datasets
|
|
if error := self.dataset_errors_by_id.get(dataset_id):
|
|
raise error
|
|
return self.datasets_by_id.get(dataset_id, [])
|
|
|
|
async def list_documents(
|
|
self,
|
|
dataset_id: str,
|
|
*,
|
|
params: list[tuple[str, str]],
|
|
) -> dict:
|
|
self.document_list_calls.append((dataset_id, params))
|
|
requested_ids = [value for key, value in params if key == "ids"]
|
|
documents = self.documents_by_dataset_id.get(dataset_id, [])
|
|
if requested_ids:
|
|
documents = [document for document in documents if document.get("id") in requested_ids]
|
|
return {
|
|
"code": 0,
|
|
"data": {"docs": documents, "total": len(documents)},
|
|
}
|
|
|
|
async def retrieve(self, query: str, **kwargs: object) -> dict:
|
|
if self.error is not None:
|
|
raise self.error
|
|
self.retrieve_calls.append((query, kwargs))
|
|
dataset_ids = kwargs.get("dataset_ids")
|
|
key = tuple(dataset_ids) if isinstance(dataset_ids, list) else ()
|
|
if error := self.retrieval_errors_by_dataset_ids.get(key):
|
|
raise error
|
|
if key in self.retrieval_by_dataset_ids:
|
|
return self.retrieval_by_dataset_ids[key]
|
|
return self.retrieval
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_warning_deduplication() -> None:
|
|
ragflow_tools._warned.clear()
|
|
|
|
|
|
def _config(
|
|
*,
|
|
configured: bool = True,
|
|
api_key: str | None = "ragflow-secret",
|
|
base_url: str = "http://ragflow.test",
|
|
datasets: list[str] | None = None,
|
|
page_size: int = 8,
|
|
) -> SimpleNamespace:
|
|
extra: dict[str, object] = {
|
|
"base_url": base_url,
|
|
"api_key": api_key,
|
|
"timeout": 30,
|
|
"page_size": page_size,
|
|
"similarity_threshold": 0.2,
|
|
"vector_similarity_weight": 0.3,
|
|
"top_k": 256,
|
|
"max_chars_per_chunk": 800,
|
|
"max_total_chars": 8000,
|
|
}
|
|
if datasets is not None:
|
|
extra["datasets"] = datasets
|
|
search_config = ToolConfig(
|
|
name="knowledge_search",
|
|
group="knowledge",
|
|
use="deerflow.community.ragflow.tools:knowledge_search_tool",
|
|
**extra,
|
|
)
|
|
return SimpleNamespace(
|
|
get_tool_config=lambda name: search_config if configured and name == "knowledge_search" else None,
|
|
)
|
|
|
|
|
|
def _install(monkeypatch: pytest.MonkeyPatch, fake: FakeRAGFlowClient, *, config: SimpleNamespace | None = None) -> None:
|
|
monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: config or _config(datasets=[DATASET_ID_1]))
|
|
monkeypatch.setattr(ragflow_tools, "_build_client", lambda settings: fake)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_knowledge_search_resolves_configured_ids_to_current_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={
|
|
DATASET_ID_1: [_dataset(DATASET_ID_1, "HR Policies")],
|
|
DATASET_ID_2: [_dataset(DATASET_ID_2, "Engineering")],
|
|
},
|
|
retrieval={
|
|
"chunks": [
|
|
{
|
|
"dataset_id": DATASET_ID_1,
|
|
"document_id": "doc-1",
|
|
"document_keyword": "handbook.pdf",
|
|
"content": "Annual leave is based on years of service.",
|
|
"similarity": 0.874,
|
|
}
|
|
],
|
|
"doc_aggs": [{"doc_id": "doc-1", "doc_name": "handbook.pdf", "count": 1}],
|
|
"total": 1,
|
|
},
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1, DATASET_ID_2]))
|
|
|
|
result = await ragflow_tools.knowledge_search("annual leave")
|
|
|
|
assert fake.list_calls == [DATASET_ID_1, DATASET_ID_2]
|
|
assert fake.retrieve_calls == [
|
|
(
|
|
"annual leave",
|
|
{
|
|
"dataset_ids": [DATASET_ID_1, DATASET_ID_2],
|
|
"page_size": 8,
|
|
"similarity_threshold": 0.2,
|
|
"vector_similarity_weight": 0.3,
|
|
"top_k": 256,
|
|
},
|
|
)
|
|
]
|
|
assert "[1] HR Policies / handbook.pdf (score 0.87)" in result
|
|
assert "Annual leave" in result
|
|
assert "Matched documents: handbook.pdf (1 chunk)" in result
|
|
assert DATASET_ID_1 not in result
|
|
assert DATASET_ID_2 not in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_knowledge_search_uses_id_filter_and_survives_dataset_rename(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Renamed Policies")]},
|
|
retrieval={"chunks": [{"dataset_id": DATASET_ID_1, "document_keyword": "policy.pdf", "content": "Current policy."}]},
|
|
)
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert fake.list_calls == [DATASET_ID_1]
|
|
assert fake.retrieve_calls[0][1]["dataset_ids"] == [DATASET_ID_1]
|
|
assert "Renamed Policies / policy.pdf" in result
|
|
assert DATASET_ID_1 not in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_missing_bound_dataset_returns_indexed_operator_guidance(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Existing")]},
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1, MISSING_DATASET_ID]))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"):
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: The 2nd entry of knowledge_search.datasets was not found or is inaccessible; check config.yaml."
|
|
assert MISSING_DATASET_ID not in result
|
|
assert fake.list_calls == [DATASET_ID_1, MISSING_DATASET_ID]
|
|
assert fake.retrieve_calls == []
|
|
assert MISSING_DATASET_ID in caplog.text
|
|
assert "code=None" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_missing_bound_dataset_error_does_not_expose_configured_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient()
|
|
_install(monkeypatch, fake, config=_config(datasets=[MISSING_DATASET_ID]))
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert MISSING_DATASET_ID not in result
|
|
assert "[DATASET_ID]" not in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_bound_dataset_api_error_uses_normal_redacted_error_handler(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
dataset_errors_by_id={
|
|
DATASET_ID_1: RAGFlowAPIError("invalid credential ragflow-secret", code=102),
|
|
}
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1]))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"):
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: invalid credential [REDACTED]"
|
|
assert "code=102" in caplog.text
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mismatched_id_filtered_response_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_2, "Wrong dataset")]})
|
|
_install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1]))
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: The 1st entry of knowledge_search.datasets was not found or is inaccessible; check config.yaml."
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_missing_dataset_binding_lists_all_and_passes_every_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
all_datasets=[
|
|
_dataset(DATASET_ID_1, "HR Policies"),
|
|
_dataset(DATASET_ID_2, "Engineering"),
|
|
],
|
|
retrieval={"chunks": [{"dataset_id": DATASET_ID_2, "document_keyword": "guide.pdf", "content": "Build guide."}]},
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=None))
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert fake.list_calls == [None]
|
|
assert fake.retrieve_calls[0][1]["dataset_ids"] == [DATASET_ID_1, DATASET_ID_2]
|
|
assert "Engineering / guide.pdf" in result
|
|
assert DATASET_ID_1 not in result
|
|
assert DATASET_ID_2 not in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mixed_embedding_models_are_retrieved_in_parallel_groups_and_rank_interleaved(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={
|
|
DATASET_ID_1: [_dataset(DATASET_ID_1, "Legacy", embedding_model=EMBEDDING_V2)],
|
|
DATASET_ID_2: [_dataset(DATASET_ID_2, "Current", embedding_model=EMBEDDING_V3)],
|
|
},
|
|
retrieval_by_dataset_ids={
|
|
(DATASET_ID_1,): {
|
|
"chunks": [
|
|
{"dataset_id": DATASET_ID_1, "document_id": "legacy-1", "document_keyword": "legacy-1.txt", "content": "Legacy rank one.", "similarity": 0.41},
|
|
{"dataset_id": DATASET_ID_1, "document_id": "legacy-2", "document_keyword": "legacy-2.txt", "content": "Legacy rank two.", "similarity": 0.99},
|
|
],
|
|
"doc_aggs": [
|
|
{"doc_id": "legacy-1", "doc_name": "legacy-1.txt", "count": 1},
|
|
{"doc_id": "legacy-2", "doc_name": "legacy-2.txt", "count": 1},
|
|
],
|
|
"total": 2,
|
|
},
|
|
(DATASET_ID_2,): {
|
|
"chunks": [
|
|
{"dataset_id": DATASET_ID_2, "document_id": "current-1", "document_keyword": "current-1.txt", "content": "Current rank one.", "similarity": 0.87},
|
|
{"dataset_id": DATASET_ID_2, "document_id": "current-2", "document_keyword": "current-2.txt", "content": "Current rank two.", "similarity": 0.86},
|
|
],
|
|
"doc_aggs": [
|
|
{"doc_id": "current-1", "doc_name": "current-1.txt", "count": 1},
|
|
{"doc_id": "current-2", "doc_name": "current-2.txt", "count": 1},
|
|
],
|
|
"total": 2,
|
|
},
|
|
},
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1, DATASET_ID_2], page_size=3))
|
|
|
|
result = await ragflow_tools.knowledge_search("policy")
|
|
|
|
assert [call[1]["dataset_ids"] for call in fake.retrieve_calls] == [[DATASET_ID_1], [DATASET_ID_2]]
|
|
assert result.index("Legacy rank one.") < result.index("Current rank one.") < result.index("Legacy rank two.")
|
|
assert "Current rank two." not in result
|
|
assert "current-2.txt (1 chunk)" not in result
|
|
assert "(score " not in result
|
|
assert DATASET_ID_1 not in result
|
|
assert DATASET_ID_2 not in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_all_dataset_scope_skips_empty_datasets_before_grouped_retrieval(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
all_datasets=[
|
|
_dataset(DATASET_ID_1, "Empty legacy", embedding_model=EMBEDDING_V2, chunk_count=0),
|
|
_dataset(DATASET_ID_2, "Current", embedding_model=EMBEDDING_V3, chunk_count=4),
|
|
],
|
|
retrieval_by_dataset_ids={(DATASET_ID_2,): {"chunks": [{"dataset_id": DATASET_ID_2, "document_keyword": "guide.txt", "content": "Searchable."}], "doc_aggs": [], "total": 1}},
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=None))
|
|
|
|
result = await ragflow_tools.knowledge_search("searchable")
|
|
|
|
assert [call[1]["dataset_ids"] for call in fake.retrieve_calls] == [[DATASET_ID_2]]
|
|
assert "Searchable." in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_all_dataset_scope_skips_empty_dataset_without_embedding_model_and_warns(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
all_datasets=[
|
|
_dataset(DATASET_ID_1, "Unconfigured empty", embedding_model="", chunk_count=0),
|
|
_dataset(DATASET_ID_2, "Current", embedding_model=EMBEDDING_V3, chunk_count=4),
|
|
],
|
|
retrieval_by_dataset_ids={
|
|
(DATASET_ID_2,): {
|
|
"chunks": [
|
|
{
|
|
"dataset_id": DATASET_ID_2,
|
|
"document_keyword": "guide.txt",
|
|
"content": "Remaining dataset result.",
|
|
"similarity": 0.75,
|
|
}
|
|
],
|
|
"doc_aggs": [],
|
|
"total": 1,
|
|
}
|
|
},
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=None))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"):
|
|
result = await ragflow_tools.knowledge_search("searchable")
|
|
|
|
assert [call[1]["dataset_ids"] for call in fake.retrieve_calls] == [[DATASET_ID_2]]
|
|
assert "Remaining dataset result." in result
|
|
assert "(score 0.75)" in result
|
|
assert DATASET_ID_1 not in result
|
|
assert "Skipping empty RAGFlow dataset without embedding model metadata" in caplog.text
|
|
assert DATASET_ID_1 in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_all_empty_dataset_scope_returns_no_content_without_retrieval(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
all_datasets=[
|
|
_dataset(DATASET_ID_1, "Empty legacy", embedding_model=EMBEDDING_V2, chunk_count=0),
|
|
_dataset(DATASET_ID_2, "Empty current", embedding_model=EMBEDDING_V3, chunk_count=0),
|
|
]
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=None))
|
|
|
|
result = await ragflow_tools.knowledge_search("anything")
|
|
|
|
assert result == "No relevant content found."
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_grouped_retrieval_limits_concurrency_to_four(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class ConcurrencyTrackingClient(FakeRAGFlowClient):
|
|
def __init__(self) -> None:
|
|
super().__init__(all_datasets=[_dataset(f"dataset-{index}", f"Dataset {index}", embedding_model=f"embedding-{index}@provider") for index in range(5)])
|
|
self.active_retrievals = 0
|
|
self.max_active_retrievals = 0
|
|
|
|
async def retrieve(self, query: str, **kwargs: object) -> dict:
|
|
self.retrieve_calls.append((query, kwargs))
|
|
self.active_retrievals += 1
|
|
self.max_active_retrievals = max(self.max_active_retrievals, self.active_retrievals)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
return {"chunks": [], "doc_aggs": [], "total": 0}
|
|
finally:
|
|
self.active_retrievals -= 1
|
|
|
|
fake = ConcurrencyTrackingClient()
|
|
_install(monkeypatch, fake, config=_config(datasets=None))
|
|
|
|
result = await ragflow_tools.knowledge_search("anything")
|
|
|
|
assert result == "No relevant content found."
|
|
assert len(fake.retrieve_calls) == 5
|
|
assert fake.max_active_retrievals == 4
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_selected_scope_dataset_validation_is_bounded_and_parallel(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
dataset_ids = [f"dataset-{index}" for index in range(5)]
|
|
|
|
class ConcurrencyTrackingClient(FakeRAGFlowClient):
|
|
def __init__(self) -> None:
|
|
super().__init__(datasets_by_id={dataset_id: [_dataset(dataset_id, f"Dataset {index}")] for index, dataset_id in enumerate(dataset_ids)})
|
|
self.active_dataset_lists = 0
|
|
self.max_active_dataset_lists = 0
|
|
|
|
async def list_datasets(self, *, dataset_id: str | None = None) -> list[dict]:
|
|
self.list_calls.append(dataset_id)
|
|
self.active_dataset_lists += 1
|
|
self.max_active_dataset_lists = max(
|
|
self.max_active_dataset_lists,
|
|
self.active_dataset_lists,
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
return self.datasets_by_id.get(dataset_id or "", [])
|
|
finally:
|
|
self.active_dataset_lists -= 1
|
|
|
|
fake = ConcurrencyTrackingClient()
|
|
_install(monkeypatch, fake, config=_config(datasets=dataset_ids))
|
|
|
|
result = await ragflow_tools.knowledge_search(
|
|
"anything",
|
|
knowledge_scope={
|
|
"version": 1,
|
|
"mode": "selected",
|
|
"dataset_ids": dataset_ids,
|
|
},
|
|
)
|
|
|
|
assert result == "No relevant content found."
|
|
assert fake.max_active_dataset_lists == 4
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_selected_scope_document_validation_is_bounded_and_parallel(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
dataset_ids = [f"dataset-{index}" for index in range(5)]
|
|
document_ids = [f"document-{index}" for index in range(5)]
|
|
|
|
class ConcurrencyTrackingClient(FakeRAGFlowClient):
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
datasets_by_id={dataset_id: [_dataset(dataset_id, f"Dataset {index}")] for index, dataset_id in enumerate(dataset_ids)},
|
|
documents_by_dataset_id={
|
|
dataset_id: [
|
|
{
|
|
"id": document_ids[index],
|
|
"name": f"Document {index}",
|
|
"run": "DONE",
|
|
"chunk_count": 1,
|
|
}
|
|
]
|
|
for index, dataset_id in enumerate(dataset_ids)
|
|
},
|
|
)
|
|
self.active_document_lists = 0
|
|
self.max_active_document_lists = 0
|
|
|
|
async def list_documents(
|
|
self,
|
|
dataset_id: str,
|
|
*,
|
|
params: list[tuple[str, str]],
|
|
) -> dict:
|
|
self.document_list_calls.append((dataset_id, params))
|
|
self.active_document_lists += 1
|
|
self.max_active_document_lists = max(
|
|
self.max_active_document_lists,
|
|
self.active_document_lists,
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
documents = self.documents_by_dataset_id[dataset_id]
|
|
return {
|
|
"code": 0,
|
|
"data": {"docs": documents, "total": len(documents)},
|
|
}
|
|
finally:
|
|
self.active_document_lists -= 1
|
|
|
|
fake = ConcurrencyTrackingClient()
|
|
_install(monkeypatch, fake, config=_config(datasets=dataset_ids))
|
|
|
|
result = await ragflow_tools.knowledge_search(
|
|
"anything",
|
|
knowledge_scope={
|
|
"version": 1,
|
|
"mode": "selected",
|
|
"dataset_ids": dataset_ids,
|
|
"document_filters": [
|
|
{
|
|
"dataset_id": dataset_id,
|
|
"document_ids": [document_ids[index]],
|
|
}
|
|
for index, dataset_id in enumerate(dataset_ids)
|
|
],
|
|
},
|
|
)
|
|
|
|
assert result == "No relevant content found."
|
|
assert fake.max_active_document_lists == 4
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dataset_without_embedding_metadata_returns_protocol_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(all_datasets=[{"id": DATASET_ID_1, "name": "Broken", "chunk_count": 1}])
|
|
_install(monkeypatch, fake, config=_config(datasets=None))
|
|
|
|
result = await ragflow_tools.knowledge_search("anything")
|
|
|
|
assert result == "Error: RAGFlow request failed: RAGFlow returned a searchable dataset without embedding model metadata."
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_group_failure_remains_strict_and_redacts_secret_and_dataset_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
all_datasets=[
|
|
_dataset(DATASET_ID_1, "Legacy", embedding_model=EMBEDDING_V2),
|
|
_dataset(DATASET_ID_2, "Current", embedding_model=EMBEDDING_V3),
|
|
],
|
|
retrieval_by_dataset_ids={(DATASET_ID_2,): {"chunks": [], "doc_aggs": [], "total": 0}},
|
|
retrieval_errors_by_dataset_ids={(DATASET_ID_1,): RAGFlowAPIError(f"dataset {DATASET_ID_1} rejected ragflow-secret", code=102)},
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=None))
|
|
|
|
result = await ragflow_tools.knowledge_search("anything")
|
|
|
|
assert result == "Error: dataset [DATASET_ID] rejected [REDACTED]"
|
|
assert len(fake.retrieve_calls) == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_selected_scope_intersects_operator_allowlist_and_fails_closed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={
|
|
DATASET_ID_1: [_dataset(DATASET_ID_1, "Allowed")],
|
|
DATASET_ID_2: [_dataset(DATASET_ID_2, "Not allowed")],
|
|
},
|
|
)
|
|
_install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1]))
|
|
|
|
result = await ragflow_tools.knowledge_search(
|
|
"leave",
|
|
knowledge_scope={
|
|
"version": 1,
|
|
"mode": "selected",
|
|
"dataset_ids": [DATASET_ID_2],
|
|
},
|
|
)
|
|
|
|
assert result == ("Error: The selected knowledge scope is no longer available; choose the knowledge bases again.")
|
|
assert fake.list_calls == []
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_selected_document_scope_validates_membership_and_splits_groups(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={
|
|
DATASET_ID_1: [_dataset(DATASET_ID_1, "All files")],
|
|
DATASET_ID_2: [_dataset(DATASET_ID_2, "One file")],
|
|
},
|
|
documents_by_dataset_id={
|
|
DATASET_ID_2: [
|
|
{
|
|
"id": "doc-1",
|
|
"name": "guide.pdf",
|
|
"run": "DONE",
|
|
"chunk_count": 3,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
_install(
|
|
monkeypatch,
|
|
fake,
|
|
config=_config(datasets=[DATASET_ID_1, DATASET_ID_2]),
|
|
)
|
|
|
|
result = await ragflow_tools.knowledge_search(
|
|
"rice",
|
|
knowledge_scope={
|
|
"version": 1,
|
|
"mode": "selected",
|
|
"dataset_ids": [DATASET_ID_1, DATASET_ID_2],
|
|
"document_filters": [{"dataset_id": DATASET_ID_2, "document_ids": ["doc-1"]}],
|
|
},
|
|
)
|
|
|
|
assert result == "No relevant content found."
|
|
assert [call[1] for call in fake.retrieve_calls] == [
|
|
{
|
|
"dataset_ids": [DATASET_ID_1],
|
|
"page_size": 8,
|
|
"similarity_threshold": 0.2,
|
|
"vector_similarity_weight": 0.3,
|
|
"top_k": 256,
|
|
},
|
|
{
|
|
"dataset_ids": [DATASET_ID_2],
|
|
"document_ids": ["doc-1"],
|
|
"page_size": 8,
|
|
"similarity_threshold": 0.2,
|
|
"vector_similarity_weight": 0.3,
|
|
"top_k": 256,
|
|
},
|
|
]
|
|
assert fake.document_list_calls == [
|
|
(
|
|
DATASET_ID_2,
|
|
[
|
|
("page", "1"),
|
|
("page_size", "1"),
|
|
("ids", "doc-1"),
|
|
],
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize(
|
|
"documents",
|
|
[
|
|
[],
|
|
[{"id": "other", "run": "DONE", "chunk_count": 1}],
|
|
[{"id": "doc-1", "run": "RUNNING", "chunk_count": 0}],
|
|
[{"id": "doc-1", "run": "DONE", "chunk_count": 0}],
|
|
],
|
|
)
|
|
async def test_invalid_or_unsearchable_document_selection_fails_closed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
documents: list[dict],
|
|
) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={
|
|
DATASET_ID_1: [_dataset(DATASET_ID_1, "Selected")],
|
|
},
|
|
documents_by_dataset_id={DATASET_ID_1: documents},
|
|
)
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search(
|
|
"rice",
|
|
knowledge_scope={
|
|
"version": 1,
|
|
"mode": "selected",
|
|
"dataset_ids": [DATASET_ID_1],
|
|
"document_filters": [{"dataset_id": DATASET_ID_1, "document_ids": ["doc-1"]}],
|
|
},
|
|
)
|
|
|
|
assert result == ("Error: The selected knowledge scope is no longer available; choose the knowledge bases or files again.")
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_disabled_scope_rejects_direct_tool_execution(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient()
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search(
|
|
"rice",
|
|
knowledge_scope={"version": 1, "mode": "disabled"},
|
|
)
|
|
|
|
assert result == "Error: Knowledge search is disabled for this turn."
|
|
assert fake.list_calls == []
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_missing_dataset_binding_with_empty_catalog_returns_guidance(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient()
|
|
_install(monkeypatch, fake, config=_config(datasets=None))
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: No accessible RAGFlow datasets were found; configure knowledge_search.datasets or add a dataset in RAGFlow."
|
|
assert fake.list_calls == [None]
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_missing_api_key_returns_english_guidance_and_warns_only_once(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient()
|
|
_install(monkeypatch, fake, config=_config(api_key=None, datasets=[DATASET_ID_1]))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"):
|
|
first = await ragflow_tools.knowledge_search("leave")
|
|
second = await ragflow_tools.knowledge_search("benefits")
|
|
|
|
assert first == "Error: RAGFlow API key is not configured; set knowledge_search.api_key in config.yaml (prefer $RAGFLOW_API_KEY)."
|
|
assert second == first
|
|
assert caplog.text.count("RAGFlow API key is not configured") == 1
|
|
assert fake.list_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_missing_knowledge_search_config_returns_english_guidance(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient()
|
|
_install(monkeypatch, fake, config=_config(configured=False, datasets=[DATASET_ID_1]))
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: knowledge_search is not configured; add its RAGFlow settings to the tools list in config.yaml."
|
|
assert fake.list_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_api_error_is_returned_as_readable_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Policies")]},
|
|
retrieval_errors_by_dataset_ids={(DATASET_ID_1,): RAGFlowAPIError("embedding models do not match", code=102)},
|
|
)
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: embedding models do not match"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_error_path_redacts_dataset_uuid(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
dataset_id = "0123456789abcdef0123456789abcdef"
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Policies")]},
|
|
retrieval_errors_by_dataset_ids={(DATASET_ID_1,): RAGFlowAPIError(f"dataset {dataset_id} failed", code=102)},
|
|
)
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert dataset_id not in result
|
|
assert "[DATASET_ID]" in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_success_path_preserves_legitimate_uuid_and_md5_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
uuid = "123e4567-e89b-12d3-a456-426614174000"
|
|
md5 = "d41d8cd98f00b204e9800998ecf8427e"
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "HR Policies")]},
|
|
retrieval={
|
|
"chunks": [
|
|
{
|
|
"dataset_id": DATASET_ID_1,
|
|
"document_keyword": "checksums.txt",
|
|
"content": f"Trace {uuid}; checksum {md5}.",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search("trace")
|
|
|
|
assert uuid in result
|
|
assert md5 in result
|
|
assert "[DATASET_ID]" not in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_success_path_still_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(
|
|
datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "HR Policies")]},
|
|
retrieval={
|
|
"chunks": [
|
|
{
|
|
"dataset_id": DATASET_ID_1,
|
|
"document_keyword": "secret.txt",
|
|
"content": "Accidental echo: ragflow-secret",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search("secret")
|
|
|
|
assert "ragflow-secret" not in result
|
|
assert "[REDACTED]" in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_connection_error_is_english_and_does_not_leak_key(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient(error=RAGFlowConnectionError("ConnectError: refused ragflow-secret"))
|
|
_install(monkeypatch, fake)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"):
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: Unable to connect to RAGFlow (http://ragflow.test): ConnectError: refused [REDACTED]"
|
|
assert "ragflow-secret" not in result
|
|
assert "ragflow-secret" not in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize(
|
|
"base_url",
|
|
[
|
|
"http://ragflow-secret@ragflow.test",
|
|
"http://ragflow%2Dsecret@ragflow.test",
|
|
"http://user:ragflow-secret@ragflow.test",
|
|
],
|
|
)
|
|
async def test_base_url_with_plain_or_encoded_userinfo_is_rejected_without_leaking_credentials(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
base_url: str,
|
|
) -> None:
|
|
fake = FakeRAGFlowClient()
|
|
_install(monkeypatch, fake, config=_config(base_url=base_url, datasets=[DATASET_ID_1]))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"):
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml."
|
|
assert "ragflow-secret" not in result
|
|
assert "ragflow-secret" not in caplog.text
|
|
assert "ragflow%2Dsecret" not in caplog.text
|
|
assert fake.list_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_empty_query_has_english_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient()
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search(" ")
|
|
|
|
assert result == "Error: query must not be empty."
|
|
assert fake.list_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_empty_retrieval_has_explicit_english_message(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "HR Policies")]})
|
|
_install(monkeypatch, fake)
|
|
|
|
result = await ragflow_tools.knowledge_search("nothing")
|
|
|
|
assert result == "No relevant content found."
|
|
|
|
|
|
def test_formatting_uses_only_normalized_chunk_fields() -> None:
|
|
result = format_retrieval_result(
|
|
{
|
|
"chunks": [
|
|
{
|
|
"kb_id": "dataset-1",
|
|
"doc_id": "doc-legacy",
|
|
"docnm_kwd": "legacy.pdf",
|
|
"content": "abcdefghij",
|
|
"similarity": 0.5,
|
|
}
|
|
]
|
|
},
|
|
dataset_names_by_id={"dataset-1": "HR Policies"},
|
|
max_chars_per_chunk=5,
|
|
max_total_chars=1000,
|
|
)
|
|
|
|
assert "Unknown dataset / Unknown document" in result
|
|
assert "HR Policies" not in result
|
|
assert "legacy.pdf" not in result
|
|
assert "abcd…" in result
|
|
assert "abcdefghij" not in result
|
|
assert "dataset-1" not in result
|
|
|
|
|
|
def test_formatting_applies_total_response_truncation_in_english() -> None:
|
|
result = format_retrieval_result(
|
|
{
|
|
"chunks": [
|
|
{
|
|
"dataset_id": "dataset-1",
|
|
"document_keyword": f"document-{index}.txt",
|
|
"content": "content " * 20,
|
|
"similarity": 0.5,
|
|
}
|
|
for index in range(4)
|
|
]
|
|
},
|
|
dataset_names_by_id={"dataset-1": "Policies"},
|
|
max_chars_per_chunk=100,
|
|
max_total_chars=120,
|
|
)
|
|
|
|
assert len(result) <= 120
|
|
assert result.endswith("… (response truncated)")
|
|
|
|
|
|
def test_retrieval_settings_load_bound_dataset_ids_and_hide_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config(datasets=[DATASET_ID_1, DATASET_ID_2]))
|
|
|
|
config, error = ragflow_tools._settings_or_error()
|
|
|
|
assert error is None
|
|
assert config is not None
|
|
assert config.datasets == [DATASET_ID_1, DATASET_ID_2]
|
|
assert str(config.base_url).rstrip("/") == "http://ragflow.test"
|
|
assert config.page_size == 8
|
|
assert config.max_chars_per_chunk == 800
|
|
assert config.max_total_chars == 8000
|
|
assert "ragflow-secret" not in repr(config)
|
|
|
|
|
|
def test_retrieval_settings_allow_omitting_dataset_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config(datasets=None))
|
|
|
|
config, error = ragflow_tools._settings_or_error()
|
|
|
|
assert error is None
|
|
assert config is not None
|
|
assert config.datasets is None
|
|
|
|
|
|
def test_retrieval_settings_do_not_fall_back_to_knowledge_capability_config(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
legacy_global = SimpleNamespace(
|
|
enabled=True,
|
|
base_url="http://legacy-ragflow.test",
|
|
api_key="legacy-secret",
|
|
timeout=60,
|
|
)
|
|
app_config = SimpleNamespace(
|
|
knowledge_base=legacy_global,
|
|
get_tool_config=lambda _name: None,
|
|
)
|
|
monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: app_config)
|
|
|
|
settings, error = ragflow_tools._settings_or_error()
|
|
|
|
assert settings is None
|
|
assert error == "Error: knowledge_search is not configured; add its RAGFlow settings to the tools list in config.yaml."
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_explicitly_empty_dataset_allowlist_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = FakeRAGFlowClient(all_datasets=[_dataset(DATASET_ID_1, "Must remain inaccessible")])
|
|
_install(monkeypatch, fake, config=_config(datasets=[]))
|
|
|
|
result = await ragflow_tools.knowledge_search("leave")
|
|
|
|
assert result == "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml."
|
|
assert fake.list_calls == []
|
|
assert fake.retrieve_calls == []
|
|
|
|
|
|
def test_agent_exposes_search_and_name_listing_tools() -> None:
|
|
assert hasattr(ragflow_tools, "list_knowledge_bases_tool")
|
|
assert hasattr(ragflow_tools, "list_knowledge_bases")
|
|
assert ragflow_tools.knowledge_search_tool.name == "knowledge_search"
|
|
assert ragflow_tools.knowledge_search_tool.coroutine is not None
|
|
assert set(ragflow_tools.knowledge_search_tool.tool_call_schema.model_fields) == {"query"}
|
|
assert ragflow_tools.list_knowledge_bases_tool.name == "list_knowledge_bases"
|
|
assert ragflow_tools.list_knowledge_bases_tool.coroutine is not None
|
|
assert not ragflow_tools.list_knowledge_bases_tool.tool_call_schema.model_fields
|
|
|
|
|
|
def test_tool_assembly_hides_bound_dataset_ids_without_network_io(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(ragflow_tools, "_build_client", lambda settings: pytest.fail("tool assembly must not perform network IO"))
|
|
tool_config = ToolConfig(
|
|
name="knowledge_search",
|
|
group="knowledge",
|
|
use="deerflow.community.ragflow.tools:knowledge_search_tool",
|
|
base_url="http://ragflow.test",
|
|
api_key="ragflow-secret",
|
|
datasets=[DATASET_ID_1, DATASET_ID_2],
|
|
)
|
|
config = SimpleNamespace(
|
|
tools=[tool_config],
|
|
knowledge_base=SimpleNamespace(enabled=True),
|
|
sandbox=SimpleNamespace(use="example.remote:Sandbox"),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
models=[],
|
|
acp_agents={},
|
|
get_model_config=lambda name: None,
|
|
)
|
|
|
|
tools = get_available_tools(include_mcp=False, app_config=config)
|
|
assembled = next(tool for tool in tools if tool.name == "knowledge_search")
|
|
|
|
assert "If knowledge_search.datasets is omitted" in assembled.description
|
|
assert "all datasets accessible to the configured RAGFlow API key" in assembled.description
|
|
assert DATASET_ID_1 not in assembled.description
|
|
assert DATASET_ID_2 not in assembled.description
|
|
assert "ragflow-secret" not in assembled.description
|
|
assert {tool.name for tool in tools}.isdisjoint({"list_knowledge_bases"})
|
|
|
|
|
|
def test_tool_assembly_hides_configured_knowledge_provider_when_capability_is_disabled() -> None:
|
|
tool_config = ToolConfig(
|
|
name="knowledge_search",
|
|
group="knowledge",
|
|
use="deerflow.community.ragflow.tools:knowledge_search_tool",
|
|
base_url="http://ragflow.test",
|
|
api_key="ragflow-secret",
|
|
)
|
|
config = SimpleNamespace(
|
|
tools=[tool_config],
|
|
knowledge_base=SimpleNamespace(enabled=False),
|
|
sandbox=SimpleNamespace(use="example.remote:Sandbox"),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
models=[],
|
|
acp_agents={},
|
|
get_model_config=lambda _name: None,
|
|
)
|
|
|
|
tools = get_available_tools(include_mcp=False, app_config=config)
|
|
|
|
assert {tool.name for tool in tools}.isdisjoint({"knowledge_search"})
|
|
|
|
|
|
def test_ragflow_package_has_explicit_init_file() -> None:
|
|
package_dir = Path(ragflow_tools.__file__).resolve().parent
|
|
|
|
assert (package_dir / "__init__.py").is_file()
|