deer-flow/backend/tests/test_knowledge_phase1_live.py
Xuehao Xu 29ba806fc6
feat(knowledge): add independent Wiki and entity relation retrieval tools (#7171)
* feat(knowledge): add independent Wiki and entity relation retrieval tools

* fix(knowledge): address review diagnostics and CI catalog coverage

* fix(knowledge): deduplicate graph evidence and update catalog e2e

* fix(knowledge): clarify bounded evidence and pin browser CI runners

* Update e2e-tests to run on ubuntu-latest

* Update workflow to use ubuntu-latest

* test(ci): stop pinning maintainer-controlled browser runner images

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-22 16:54:58 +08:00

167 lines
9.5 KiB
Python

"""Opt-in new Phase 1 checks using existing Wiki and indexed graph data.
No ingestion or resource mutations. Credentials are supplied by environment;
fixtures must contain attributable published entries and graph evidence.
"""
import json
import os
from pathlib import Path
import pytest
pytestmark = [pytest.mark.live, pytest.mark.asyncio]
def _required(*names):
if os.getenv("DEER_FLOW_RUN_LIVE_TESTS") != "1":
pytest.skip("live knowledge checks are opt-in")
if any(not os.getenv(name) for name in names):
pytest.fail("Set all documented live provider environment variables")
return {name: os.environ[name] for name in names}
def _evidence(result):
from deerflow.knowledge import format_result, sources_from_messages
from deerflow.knowledge.sources import forward_sources, prune_sources
content, artifact = format_result(result)
message = json.loads(json.dumps({"type": "tool", "content": content, "artifact": artifact}))
sources = sources_from_messages([message])
assert sources, "Live fixture must return attributable evidence"
for source in sources:
assert source.page is None and source.url is None
assert any(hit.source and source.title == hit.source.title and source.excerpt.rstrip("…") in hit.content for hit in result.hits)
forwarded, envelope = forward_sources(content, [source.model_dump(mode="json") for source in sources])
assert sources_from_messages([{"type": "tool", "content": forwarded, "artifact": envelope}]) == sources
assert prune_sources("removed", artifact)[1]["knowledge_sources"] == []
return sources
async def test_weknora_live_published_entries(monkeypatch):
env = _required("WEKNORA_BASE_URL", "WEKNORA_TENANT_ID", "WEKNORA_WIKI_BASE_ID", "WEKNORA_API_KEY", "WEKNORA_WIKI_QUERY")
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-weknora"))
from deerflow_extension_weknora import WeKnoraProvider
from deerflow.knowledge import KnowledgeRequestContext
provider = WeKnoraProvider({"spaces": [{"base_url": env["WEKNORA_BASE_URL"], "tenant_id": env["WEKNORA_TENANT_ID"], "api_key_env": "WEKNORA_API_KEY", "user_ids": ["live-probe"], "knowledge_base_ids": [env["WEKNORA_WIKI_BASE_ID"]]}]})
page = await provider.catalog(context=KnowledgeRequestContext("live-probe", "researcher"), page=1, page_size=100, search="")
assert len(page.items) == 1
scope = {"version": 1, "mode": "selected", "dataset_ids": [page.items[0].id]}
result = await provider.entries_search(env["WEKNORA_WIKI_QUERY"], context=KnowledgeRequestContext("live-probe", "researcher", scope))
assert all(source.provider == "weknora" for source in _evidence(result))
with pytest.raises(PermissionError):
await provider.entries_search(env["WEKNORA_WIKI_QUERY"], context=KnowledgeRequestContext("unbound", "researcher", scope))
with pytest.raises(ValueError, match="document"):
await provider.entries_search(env["WEKNORA_WIKI_QUERY"], context=KnowledgeRequestContext("live-probe", "researcher", {**scope, "document_filters": [{"dataset_id": page.items[0].id, "document_ids": ["unsupported"]}]}))
async def test_lightrag_live_relation_evidence(monkeypatch):
env = _required("LIGHTRAG_BASE_URL", "LIGHTRAG_API_KEY", "LIGHTRAG_RELATION_QUERY")
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-lightrag"))
from deerflow_extension_lightrag import LightRAGProvider
from deerflow.knowledge import KnowledgeRequestContext
provider = LightRAGProvider({"base_url": env["LIGHTRAG_BASE_URL"], "api_key_env": "LIGHTRAG_API_KEY", "user_ids": ["live-probe"], "timeout": 120})
result = await provider.relations_search(env["LIGHTRAG_RELATION_QUERY"], context=KnowledgeRequestContext("live-probe", "researcher"))
assert any("Upstream relation (one edge):" in hit.content for hit in result.hits), "Live fixture must have actual relations"
assert all(source.provider == "lightrag" for source in _evidence(result))
for scope in ({"version": 1, "mode": "disabled"}, {"version": 1, "mode": "selected", "dataset_ids": ["unsupported"]}):
with pytest.raises(ValueError):
await provider.relations_search(env["LIGHTRAG_RELATION_QUERY"], context=KnowledgeRequestContext("live-probe", "researcher", scope))
with pytest.raises(PermissionError):
await provider.relations_search(env["LIGHTRAG_RELATION_QUERY"], context=KnowledgeRequestContext("unbound", "researcher"))
async def test_live_glm_three_tools_and_citations(monkeypatch):
if os.getenv("KNOWLEDGE_RUN_MODEL_TEST") != "1":
pytest.skip("generative model validation requires a separate opt-in")
env = _required(
"E2E_LLM_MODEL",
"E2E_LLM_BASE_URL",
"E2E_LLM_API_KEY",
"KNOWLEDGE_MODEL_QUESTION",
"WEKNORA_BASE_URL",
"WEKNORA_TENANT_ID",
"WEKNORA_WIKI_BASE_ID",
"WEKNORA_KNOWLEDGE_BASE_ID",
"WEKNORA_API_KEY",
"LIGHTRAG_BASE_URL",
"LIGHTRAG_API_KEY",
)
import re
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
for package in ("weknora", "lightrag"):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / f"examples/deerflow-extension-{package}"))
from deerflow_extension_lightrag import LightRAGProvider
from deerflow_extension_weknora import WeKnoraProvider
from deerflow.agents.middlewares.knowledge_scope_middleware import KnowledgeScopeMiddleware
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.tool_config import ToolConfig
from deerflow.extensions import EXTENSION_SNAPSHOT_CONTEXT_KEY, ExtensionRegistry
from deerflow.knowledge import sources_from_messages, tools
from deerflow.knowledge.sources import forward_sources, prune_sources
from deerflow.runtime.serialization import serialize_channel_values_for_api
wiki = WeKnoraProvider(
{
"spaces": [
{
"base_url": env["WEKNORA_BASE_URL"],
"tenant_id": env["WEKNORA_TENANT_ID"],
"api_key_env": "WEKNORA_API_KEY",
"user_ids": ["live-probe"],
"knowledge_base_ids": [env["WEKNORA_WIKI_BASE_ID"], env["WEKNORA_KNOWLEDGE_BASE_ID"]],
}
]
}
)
graph = LightRAGProvider({"base_url": env["LIGHTRAG_BASE_URL"], "api_key_env": "LIGHTRAG_API_KEY", "user_ids": ["live-probe"], "timeout": 120})
registry = ExtensionRegistry()
with registry.attributed_to("live:providers"):
registry.knowledge_provider("weknora", wiki)
registry.knowledge_provider("lightrag-relations", graph)
mapping = {"knowledge_search": "weknora", "knowledge_entries_search": "weknora", "entity_relations_search": "lightrag-relations"}
config = AppConfig(sandbox=SandboxConfig(use="example.remote:Sandbox"), tools=[ToolConfig(name=name, group="knowledge", use=f"deerflow.knowledge.tools:{name}_tool", provider=provider) for name, provider in mapping.items()])
model = ChatOpenAI(model=env["E2E_LLM_MODEL"], base_url=env["E2E_LLM_BASE_URL"], api_key=env["E2E_LLM_API_KEY"], timeout=240, max_retries=0, extra_body=json.loads(os.getenv("E2E_LLM_EXTRA_BODY", "{}")))
agent = create_agent(
model,
tools=[getattr(tools, name + "_tool") for name in mapping],
middleware=[KnowledgeScopeMiddleware()],
context_schema=dict,
system_prompt="Use all three knowledge retrieval tools to answer the user's question from real evidence. Copy citation links exactly from tool output. Never invent locators or relations. State any tool failures.",
)
state = await agent.ainvoke(
{"messages": [HumanMessage(content=env["KNOWLEDGE_MODEL_QUESTION"])]},
context={"app_config": config, "user_id": "live-probe", "__knowledge_scope_execution": {"version": 1, "mode": "all"}, EXTENSION_SNAPSHOT_CONTEXT_KEY: registry.build()},
config={"recursion_limit": 16},
)
messages = state["messages"]
results = [message for message in messages if isinstance(message, ToolMessage)]
assert set(mapping) <= {message.name for message in results}
assert all(isinstance(message.content, str) and not message.content.startswith("Error:") for message in results)
for name in mapping:
assert sources_from_messages([message for message in results if message.name == name]), f"{name} must supply actual evidence"
answer = messages[-1].content
assert isinstance(answer, str)
if os.getenv("KNOWLEDGE_EXPECT_CLEAN_ANSWER") == "1":
for message in messages:
if message.type == "ai" and isinstance(message.content, str):
assert "<think>" not in message.content and "</think>" not in message.content
cited = set(re.findall(r"#knowledge-source-([a-f0-9]{32})", answer))
sources = sources_from_messages(messages)
assert cited and cited <= {source.id for source in sources}
history = json.loads(json.dumps(serialize_channel_values_for_api({"messages": messages})))
assert sources_from_messages(history["messages"]) == sources
report, envelope = forward_sources(answer, [source.model_dump(mode="json") for source in sources])
assert {source.id for source in sources_from_messages([{"type": "tool", "content": report, "artifact": envelope}])} == cited
assert prune_sources("removed", envelope)[1]["knowledge_sources"] == []