mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
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>
This commit is contained in:
parent
f5907405a0
commit
29ba806fc6
12
README.md
12
README.md
@ -1500,6 +1500,18 @@ Parsing remains strict and invalid skills stay unavailable to agents.
|
||||
|
||||
#### Read-only knowledge sources
|
||||
|
||||
Three retrieval tools can be configured independently in `tools[]`: document
|
||||
`knowledge_search`, published `knowledge_entries_search` through the
|
||||
[WeKnora plugin](examples/deerflow-extension-weknora/README.md), and
|
||||
`entity_relations_search` through the optional
|
||||
[LightRAG plugin](examples/deerflow-extension-lightrag/README.md). Remove a tool
|
||||
entry to disable it. They share scope admission, source validation, budgets and
|
||||
citation rendering. Wiki queries that return no pages retry up to four individual
|
||||
keywords within the same authorized base. Unsupported filters fail closed;
|
||||
multiple providers with different resource namespaces support only all/disabled scope. The new adapters
|
||||
have offline coverage and [local live validation](examples/deerflow-extension-lightrag/VALIDATION.md)
|
||||
with bge-m3 and GLM-5.3; see that record for coverage limits.
|
||||
|
||||
Configure `knowledge_search` with either
|
||||
`deerflow.community.ragflow.tools:knowledge_search_tool` or
|
||||
`deerflow.community.lightrag.tools:knowledge_search_tool` in `config.yaml`.
|
||||
|
||||
@ -165,7 +165,8 @@ Read the nearest scoped `AGENTS.md` for subsystem ownership.
|
||||
|
||||
## Architecture
|
||||
|
||||
Read-only knowledge: [contract](docs/KNOWLEDGE.md).
|
||||
Read-only knowledge: [contract](docs/KNOWLEDGE.md) covers independent retrieval
|
||||
tools, scope intersection and extension-owned providers.
|
||||
|
||||
### Harness / App Split
|
||||
|
||||
|
||||
@ -14,8 +14,9 @@ all six read-only operations against the v0.8.3 API.
|
||||
An operator-installed extension calls
|
||||
`registry.knowledge_provider(name, provider)` during `install()`. The method
|
||||
returns `False` on older hosts and is supported by extension-api **0.2.7**.
|
||||
The provider implements the existing `RetrievalProvider.search(query)` and
|
||||
advertises `KnowledgeCapabilities`. Registration validates each advertised
|
||||
The provider advertises `KnowledgeCapabilities` and implements its retrieval
|
||||
operations: the existing `RetrievalProvider.search(query)`, or one of the
|
||||
optional entry/relation methods described below. Registration validates each advertised
|
||||
operation, rejects duplicate/reserved names, and participates in source
|
||||
attribution and positional install rollback. Connection settings, response
|
||||
mapping and credentials belong to that extension's `plugins[].config`.
|
||||
@ -24,7 +25,7 @@ The contract package remains dependency-free; providers use the published
|
||||
|
||||
| Operation | Optional interface | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `search` | `RetrievalProvider.search` | Required; returns `RetrievalResult` |
|
||||
| `search` | `RetrievalProvider.search` | Document retrieval; returns `RetrievalResult` |
|
||||
| `sources` | Existing result/source models | Advertises attributable evidence |
|
||||
| `catalog` | `RetrievalCatalogProvider.catalog` | Paginated accessible datasets |
|
||||
| `documents` | `RetrievalDocumentsProvider.documents` | Paginated documents in an accessible dataset |
|
||||
@ -279,3 +280,66 @@ actual registry, Gateway catalog, ToolNode, shared evidence and replay path.
|
||||
Live validation needs an indexed instance and is not implied by offline passes.
|
||||
The extension's [validation record](../../examples/deerflow-extension-weknora/VALIDATION.md)
|
||||
documents the local v0.8.3 / Ollama bge-m3 run.
|
||||
|
||||
## New Phase 1: three independently configurable retrieval tools
|
||||
|
||||
This is the follow-up plan in #6269 comments 6281785410 and 6282389387, distinct
|
||||
from the completed original three phases. Existing `tools`, `tool_groups`,
|
||||
`tools[].provider` and the registry remain the only configuration mechanism;
|
||||
there is no `retrieval_channels` or per-tool `enabled` switch.
|
||||
|
||||
| Tool | Optional provider method | Filter capability names |
|
||||
| --- | --- | --- |
|
||||
| `knowledge_search` | existing `search` / `search_scoped` | `dataset_filter`, `document_filter` |
|
||||
| `knowledge_entries_search` | `entries_search(query, *, context)` | `entries_dataset_filter`, `entries_document_filter` |
|
||||
| `entity_relations_search` | `relations_search(query, *, context)` | `relations_dataset_filter`, `relations_document_filter` |
|
||||
|
||||
Tool paths are `deerflow.knowledge.tools:<tool_name>_tool`. All methods return
|
||||
`RetrievalResult`; optional methods always receive `KnowledgeRequestContext`,
|
||||
including all-scope requests. Registration checks advertised async methods.
|
||||
An entry-only or relation-only provider needs no document `search` method.
|
||||
Add/remove any combination of the three tool entries, including all absent.
|
||||
Tool group and normal authorization admission remain unchanged.
|
||||
|
||||
Core contains the generic dispatch and operation-level capability projection;
|
||||
provider API calls and mapping stay in packaged extensions. The catalog and
|
||||
admission expose the **intersection** of configured operations' scope support.
|
||||
Different registered providers cannot share selected IDs: combinations across
|
||||
providers support all/disabled only. This conservative rule also applies during
|
||||
runtime revalidation, resume and regeneration; it never drops filters or retries
|
||||
unrestricted. Single-operation deployments do not need document search enabled.
|
||||
The disabled-scope middleware hides and blocks all three tools.
|
||||
|
||||
The WeKnora adapter advertises entries plus base filtering, using published Wiki
|
||||
pages on bases reporting `capabilities.wiki`. It rejects document filtering:
|
||||
its Wiki API cannot constrain a page to selected documents. It has no entity
|
||||
relation HTTP adapter; the pinned release's graph query is an internal Agent
|
||||
Tool and `/wiki/graph` is a page-link graph.
|
||||
|
||||
The optional LightRAG plugin implements entity/relationship retrieval against
|
||||
`/query/data`, with explicit user/agent credential bindings. It maps returned
|
||||
one-edge paths and joins their `source_id` values to actual retrieved chunks.
|
||||
It does not invent multi-hop paths or treat graph summaries as document quotes.
|
||||
Graph facts are unattributed text hits; their corresponding document chunks
|
||||
are separate attributable hits. The v1 source wire model, ID hashing, evidence
|
||||
validation, budgets, ordinary delegation and replay readers are unchanged.
|
||||
No graph UI or new graph-source schema is introduced in this phase.
|
||||
|
||||
The shared trusted context currently carries user, executing agent and scope;
|
||||
connections and upstream tenancy remain operator-owned plugin configuration.
|
||||
Later space/authorization/trace work must extend that contract deliberately,
|
||||
not infer user authority from labels or treat synthetic tests as a live trace.
|
||||
|
||||
Tests: `test_knowledge_retrieval_tools.py` covers all eight configurations,
|
||||
real ToolNode calls, shared sources, replay, delegation and budget pruning;
|
||||
provider tests exercise API-shaped synthetic responses, access denial,
|
||||
unsupported filters and evidence joins. The opt-in `test_knowledge_phase1_live.py` also passed against real OrbStack
|
||||
WeKnora/LightRAG services with bge-m3 and a GLM-5.3 three-tool agent loop.
|
||||
See the [live validation record](../../examples/deerflow-extension-lightrag/VALIDATION.md)
|
||||
for exact coverage, reproduction and limits; this is not browser-chat acceptance.
|
||||
|
||||
Configured generic retrieval tools must resolve to a registered provider advertising
|
||||
that tool's operation. Capability resolution rejects an invalid pair and logs the
|
||||
tool name, provider registration name and missing operation for the operator;
|
||||
it does not silently intersect it into an empty capability set. Model-visible
|
||||
errors remain generic so provider details are not exposed in tool results.
|
||||
|
||||
@ -59,7 +59,7 @@ No match: skip inspection, log ID. Tests: `test_read_mark_tool_call_correlation.
|
||||
|
||||
1. **InputSanitizationMiddleware** - First, so it is the outermost `wrap_model_call` wrapper; every inner middleware (including LLM retries) sees sanitized messages. `additional_kwargs.original_user_content` is server-owned provenance: Gateway strips caller-supplied values for non-internal run requests, trusted IM calls may carry the string they captured before adding transport/file context, and the middleware replaces any non-string value before wrapping. Uploads and sanitization retain first-writer-wins only for validated strings. Caller markers are marked `untrusted_input`, never stripped; scope is every turn.
|
||||
|
||||
**KnowledgeScopeMiddleware** follows input sanitization: it exposes only Gateway-admitted execution scope, removes scope/display data from model messages, and blocks `knowledge_search` when disabled without reading storage or RAGFlow.
|
||||
**KnowledgeScopeMiddleware** follows input sanitization: it exposes only Gateway-admitted execution scope, removes scope/display data from model messages, and blocks `knowledge_search`, `knowledge_entries_search` and `entity_relations_search` when disabled without reading storage or RAGFlow.
|
||||
2. **ToolOutputBudgetMiddleware** - Budgets model-bound text/JSON with limits/exemptions; externalizes to thread outputs (`tool_output.storage_subdir`, default `.tool-results`, `TOOL_RESULTS_DIRNAME`) with synopsis/`read_file` refs. Preserve mixed-result media. Restore blob refs across hosts; hash/write failures fall back inline. Internal files bypass workspace/delivery scans. Publication/elision/bash exit marker: [contract](TOOL_OUTPUT_PERSISTENCE.md). Elision: `tool_call_args`; retain newest `keep_recent_writes` writes. Controls: `elide_superseded_writes`, `superseded_write_min_chars`.
|
||||
Tests: `tests/test_tool_output_synopsis_budget.py`.
|
||||
3. **ToolResultSanitizationMiddleware** - Neutralizes framework/injection tags (e.g. `<system-reminder>`) and boundary markers in *remote-content* tool results (`web_fetch`/`web_search`/`image_search`/`web_capture`) so attacker-controlled fetched pages cannot forge trusted framework context. Mirrors `InputSanitizationMiddleware`'s user-input guardrail for the other untrusted-content entry point; sits inner of `ToolOutputBudgetMiddleware`. Local tool output (bash/read_file) is left untouched. Scope is a name-based allowlist for the first-party web tools, plus every MCP-sourced tool via its `deerflow_mcp` metadata tag, so an MCP server naming its fetcher `fetch_url` is still covered
|
||||
|
||||
@ -22,7 +22,7 @@ from deerflow.knowledge_scope import (
|
||||
)
|
||||
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
|
||||
|
||||
_KNOWLEDGE_SEARCH_TOOL_NAME = "knowledge_search"
|
||||
_KNOWLEDGE_SEARCH_TOOL_NAMES = frozenset({"knowledge_search", "knowledge_entries_search", "entity_relations_search"})
|
||||
|
||||
|
||||
def _runtime_context(value: object) -> dict[str, Any] | None:
|
||||
@ -80,14 +80,14 @@ class KnowledgeScopeMiddleware(AgentMiddleware[AgentState]):
|
||||
tools = list(request.tools)
|
||||
scope = _scope_from_runtime(request.runtime)
|
||||
if scope is not None and scope["mode"] == "disabled":
|
||||
tools = [tool for tool in tools if getattr(tool, "name", None) != _KNOWLEDGE_SEARCH_TOOL_NAME]
|
||||
tools = [tool for tool in tools if getattr(tool, "name", None) not in _KNOWLEDGE_SEARCH_TOOL_NAMES]
|
||||
if messages == list(request.messages) and tools == list(request.tools):
|
||||
return request
|
||||
return request.override(messages=messages, tools=tools)
|
||||
|
||||
@staticmethod
|
||||
def _disabled_tool_message(request: ToolCallRequest) -> ToolMessage | None:
|
||||
if str(request.tool_call.get("name") or "") != _KNOWLEDGE_SEARCH_TOOL_NAME:
|
||||
if str(request.tool_call.get("name") or "") not in _KNOWLEDGE_SEARCH_TOOL_NAMES:
|
||||
return None
|
||||
scope = _scope_from_runtime(getattr(request, "runtime", None))
|
||||
if scope is None or scope["mode"] != "disabled":
|
||||
@ -95,7 +95,7 @@ class KnowledgeScopeMiddleware(AgentMiddleware[AgentState]):
|
||||
return ToolMessage(
|
||||
content="Error: Knowledge search is disabled for this turn.",
|
||||
tool_call_id=str(request.tool_call.get("id") or "missing_tool_call_id"),
|
||||
name=_KNOWLEDGE_SEARCH_TOOL_NAME,
|
||||
name=str(request.tool_call.get("name")),
|
||||
status="error",
|
||||
)
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
"""Read-only knowledge contracts with optional catalog and filter capabilities."""
|
||||
|
||||
from .contract import (
|
||||
EntityRelationsProvider,
|
||||
KnowledgeCapabilities,
|
||||
KnowledgeEntriesProvider,
|
||||
KnowledgeRequestContext,
|
||||
KnowledgeSource,
|
||||
RetrievalCatalogItem,
|
||||
@ -16,6 +18,8 @@ from .contract import (
|
||||
from .sources import format_result, sources_from_messages
|
||||
|
||||
__all__ = [
|
||||
"EntityRelationsProvider",
|
||||
"KnowledgeEntriesProvider",
|
||||
"KnowledgeCapabilities",
|
||||
"KnowledgeRequestContext",
|
||||
"KnowledgeSource",
|
||||
|
||||
@ -16,7 +16,11 @@ class KnowledgeCapabilities(BaseModel):
|
||||
Search-only providers need no catalog, scope, or management implementation.
|
||||
"""
|
||||
|
||||
operations: frozenset[Literal["search", "sources", "catalog", "documents", "dataset_filter", "document_filter"]] = frozenset({"search"})
|
||||
operations: frozenset[
|
||||
Literal[
|
||||
"search", "sources", "catalog", "documents", "dataset_filter", "document_filter", "entries_search", "entries_dataset_filter", "entries_document_filter", "relations_search", "relations_dataset_filter", "relations_document_filter"
|
||||
]
|
||||
] = frozenset({"search"})
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
@ -121,3 +125,15 @@ class RetrievalCatalogProvider(Protocol):
|
||||
|
||||
class RetrievalDocumentsProvider(Protocol):
|
||||
async def documents(self, dataset_id: str, *, context: KnowledgeRequestContext, page: int, page_size: int, search: str) -> RetrievalCatalogPage: ...
|
||||
|
||||
|
||||
class KnowledgeEntriesProvider(Protocol):
|
||||
"""Optional entry retrieval; filters are advertised independently of search."""
|
||||
|
||||
async def entries_search(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult: ...
|
||||
|
||||
|
||||
class EntityRelationsProvider(Protocol):
|
||||
"""Return only upstream entities/edges/path evidence, never inferred edges."""
|
||||
|
||||
async def relations_search(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult: ...
|
||||
|
||||
@ -8,6 +8,7 @@ through the operator-controlled extension registry and the generic core tool.
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from deerflow.knowledge_scope import execution_scope
|
||||
@ -18,41 +19,115 @@ RAGFLOW_KNOWLEDGE_TOOL = "deerflow.community.ragflow.tools:knowledge_search_tool
|
||||
LIGHTRAG_KNOWLEDGE_TOOL = "deerflow.community.lightrag.tools:knowledge_search_tool"
|
||||
GENERIC_KNOWLEDGE_TOOL = "deerflow.knowledge.tools:knowledge_search_tool"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KNOWLEDGE_TOOL_OPERATIONS = {
|
||||
"knowledge_search": "search",
|
||||
"knowledge_entries_search": "entries_search",
|
||||
"entity_relations_search": "relations_search",
|
||||
}
|
||||
GENERIC_KNOWLEDGE_TOOLS = {name: f"deerflow.knowledge.tools:{name}_tool" for name in KNOWLEDGE_TOOL_OPERATIONS}
|
||||
|
||||
|
||||
def operation_capabilities(capabilities: KnowledgeCapabilities, operation: str) -> KnowledgeCapabilities:
|
||||
if operation == "search":
|
||||
return KnowledgeCapabilities(operations=capabilities.operations & {"search", "sources", "catalog", "documents", "dataset_filter", "document_filter"})
|
||||
if operation not in {"entries_search", "relations_search"}:
|
||||
raise ValueError("Unknown knowledge retrieval operation")
|
||||
prefix = operation.removesuffix("search")
|
||||
operations = {name for name in ("sources", "catalog", "documents") if capabilities.supports(name)}
|
||||
if capabilities.supports(operation):
|
||||
operations.add("search")
|
||||
for name in ("dataset_filter", "document_filter"):
|
||||
if capabilities.supports(prefix + name):
|
||||
operations.add(name)
|
||||
return KnowledgeCapabilities(operations=operations)
|
||||
|
||||
|
||||
def validate_provider(provider: Any) -> None:
|
||||
capabilities = getattr(provider, "capabilities", None)
|
||||
if not isinstance(capabilities, KnowledgeCapabilities) or not capabilities.supports("search"):
|
||||
raise ValueError("Knowledge providers require KnowledgeCapabilities with search")
|
||||
methods = {"search"}
|
||||
if not isinstance(capabilities, KnowledgeCapabilities) or not any(capabilities.supports(op) for op in KNOWLEDGE_TOOL_OPERATIONS.values()):
|
||||
raise ValueError("Knowledge providers require KnowledgeCapabilities with a search operation")
|
||||
methods = {"search"} if capabilities.supports("search") else set()
|
||||
if capabilities.supports("catalog"):
|
||||
methods.add("catalog")
|
||||
if capabilities.supports("documents"):
|
||||
methods.add("documents")
|
||||
if capabilities.supports("dataset_filter") or capabilities.supports("document_filter"):
|
||||
methods.add("search_scoped")
|
||||
for operation in ("entries_search", "relations_search"):
|
||||
if capabilities.supports(operation):
|
||||
methods.add(operation)
|
||||
prefix = operation.removesuffix("search")
|
||||
if any(capabilities.supports(prefix + name) for name in ("dataset_filter", "document_filter")) and not capabilities.supports(operation):
|
||||
raise ValueError("Filter capability requires its retrieval operation")
|
||||
for method in methods:
|
||||
if not inspect.iscoroutinefunction(getattr(provider, method, None)):
|
||||
raise ValueError(f"Knowledge provider capability requires async {method}")
|
||||
|
||||
|
||||
def get_knowledge_capabilities(app_config: Any, *, extensions: Any = None) -> KnowledgeCapabilities:
|
||||
def _configured_tools(app_config: Any) -> list[tuple[str, Any]]:
|
||||
get_tool = getattr(app_config, "get_tool_config", None)
|
||||
tool = get_tool("knowledge_search") if callable(get_tool) else None
|
||||
use = getattr(tool, "use", None)
|
||||
if use == RAGFLOW_KNOWLEDGE_TOOL:
|
||||
from deerflow.community.ragflow.formatting import CAPABILITIES
|
||||
if not callable(get_tool):
|
||||
return []
|
||||
result = []
|
||||
for name in KNOWLEDGE_TOOL_OPERATIONS:
|
||||
tool = get_tool(name)
|
||||
if tool is None:
|
||||
continue
|
||||
expected = GENERIC_KNOWLEDGE_TOOLS[name]
|
||||
if getattr(tool, "use", None) == expected or (name == "knowledge_search" and getattr(tool, "use", None) in {RAGFLOW_KNOWLEDGE_TOOL, LIGHTRAG_KNOWLEDGE_TOOL}):
|
||||
result.append((name, tool))
|
||||
return result
|
||||
|
||||
return CAPABILITIES
|
||||
if use == LIGHTRAG_KNOWLEDGE_TOOL:
|
||||
from deerflow.community.lightrag.formatting import CAPABILITIES
|
||||
|
||||
return CAPABILITIES
|
||||
provider = _registered_provider(tool, extensions=extensions)
|
||||
return provider.capabilities if provider is not None else KnowledgeCapabilities(operations=frozenset())
|
||||
def get_knowledge_capabilities(app_config: Any, *, extensions: Any = None) -> KnowledgeCapabilities:
|
||||
"""One scope must be enforceable by every configured retrieval operation.
|
||||
|
||||
Different provider registrations have no shared resource-ID namespace;
|
||||
their combination supports all/disabled only. Never reinterpret selected IDs.
|
||||
"""
|
||||
configured = _configured_tools(app_config)
|
||||
if not configured:
|
||||
return KnowledgeCapabilities(operations=frozenset())
|
||||
projected = []
|
||||
identities = set()
|
||||
for name, tool in configured:
|
||||
use = getattr(tool, "use", None)
|
||||
if use == RAGFLOW_KNOWLEDGE_TOOL:
|
||||
from deerflow.community.ragflow.formatting import CAPABILITIES
|
||||
|
||||
capabilities = CAPABILITIES
|
||||
identities.add(use)
|
||||
elif use == LIGHTRAG_KNOWLEDGE_TOOL:
|
||||
from deerflow.community.lightrag.formatting import CAPABILITIES
|
||||
|
||||
capabilities = CAPABILITIES
|
||||
identities.add(use)
|
||||
else:
|
||||
provider = _registered_provider(tool, extensions=extensions)
|
||||
provider_name = (getattr(tool, "model_extra", None) or {}).get("provider")
|
||||
operation = KNOWLEDGE_TOOL_OPERATIONS[name]
|
||||
if provider is None or not provider.capabilities.supports(operation):
|
||||
logger.error(
|
||||
"Invalid knowledge tool configuration: tool=%r provider=%r operation=%r (%s)",
|
||||
name,
|
||||
provider_name,
|
||||
operation,
|
||||
"not registered" if provider is None else "operation not advertised",
|
||||
)
|
||||
raise ValueError("Invalid knowledge tool configuration; check operator logs")
|
||||
capabilities = operation_capabilities(provider.capabilities, operation)
|
||||
identities.add((getattr(tool, "model_extra", None) or {}).get("provider"))
|
||||
projected.append(set(capabilities.operations))
|
||||
operations = set.intersection(*projected)
|
||||
if len(identities) > 1:
|
||||
operations -= {"catalog", "documents", "dataset_filter", "document_filter"}
|
||||
return KnowledgeCapabilities(operations=operations)
|
||||
|
||||
|
||||
def _registered_provider(tool: Any, *, extensions: Any = None) -> RetrievalProvider | None:
|
||||
if getattr(tool, "use", None) != GENERIC_KNOWLEDGE_TOOL:
|
||||
if getattr(tool, "use", None) not in GENERIC_KNOWLEDGE_TOOLS.values():
|
||||
return None
|
||||
from deerflow.extensions import get_loaded_extensions
|
||||
|
||||
@ -64,9 +139,9 @@ def _registered_provider(tool: Any, *, extensions: Any = None) -> RetrievalProvi
|
||||
return None
|
||||
|
||||
|
||||
def resolve_knowledge_provider(app_config: Any, *, extensions: Any = None) -> RetrievalProvider:
|
||||
get_tool = getattr(app_config, "get_tool_config", None)
|
||||
tool = get_tool("knowledge_search") if callable(get_tool) else None
|
||||
def resolve_knowledge_provider(app_config: Any, *, extensions: Any = None, tool_name: str | None = None) -> RetrievalProvider:
|
||||
configured = _configured_tools(app_config)
|
||||
tool = next((tool for name, tool in configured if tool_name is None or name == tool_name), None)
|
||||
if getattr(tool, "use", None) == RAGFLOW_KNOWLEDGE_TOOL:
|
||||
from deerflow.community.ragflow.provider import RAGFlowKnowledgeProvider
|
||||
|
||||
@ -89,16 +164,21 @@ def validate_scope_capabilities(scope: object, capabilities: KnowledgeCapabiliti
|
||||
return canonical
|
||||
|
||||
|
||||
async def search_provider(provider: RetrievalProvider, query: str, *, scope: object | None, user_id: str, agent_name: str | None) -> RetrievalResult:
|
||||
canonical = validate_scope_capabilities(scope, provider.capabilities) if scope is not None else None
|
||||
async def search_provider(provider: RetrievalProvider, query: str, *, scope: object | None, user_id: str, agent_name: str | None, operation: str = "search") -> RetrievalResult:
|
||||
capabilities = operation_capabilities(provider.capabilities, operation)
|
||||
if not capabilities.supports("search"):
|
||||
raise ValueError("Knowledge retrieval operation is unavailable")
|
||||
canonical = validate_scope_capabilities(scope, capabilities) if scope is not None else None
|
||||
if canonical is not None and canonical["mode"] == "disabled":
|
||||
raise ValueError("Knowledge search is disabled for this turn")
|
||||
context = KnowledgeRequestContext(user_id=user_id, agent_name=agent_name, scope=canonical)
|
||||
contextual_search = getattr(provider, "search_scoped", None)
|
||||
contextual_search = getattr(provider, "search_scoped" if operation == "search" else operation, None)
|
||||
if callable(contextual_search):
|
||||
result = await contextual_search(query, context=context)
|
||||
elif canonical is not None and canonical["mode"] == "selected":
|
||||
raise ValueError("Knowledge provider cannot apply the selected scope")
|
||||
elif operation != "search":
|
||||
raise ValueError("Knowledge retrieval operation is unavailable")
|
||||
else:
|
||||
result = await provider.search(query)
|
||||
# Validate provider values before they can become model-visible evidence.
|
||||
|
||||
@ -8,7 +8,7 @@ from langchain_core.tools import StructuredTool
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.extensions import resolve_run_extensions
|
||||
from deerflow.knowledge.providers import resolve_knowledge_provider, search_provider
|
||||
from deerflow.knowledge.providers import KNOWLEDGE_TOOL_OPERATIONS, get_knowledge_capabilities, resolve_knowledge_provider, search_provider, validate_scope_capabilities
|
||||
from deerflow.knowledge.sources import format_result
|
||||
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_RUNTIME_KEY, execution_scope
|
||||
from deerflow.runtime.user_context import resolve_runtime_user_id
|
||||
@ -32,11 +32,13 @@ def _execution_agent_name(context: Mapping, config: object = None) -> str:
|
||||
return name
|
||||
|
||||
|
||||
async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict | None]:
|
||||
async def execute_knowledge_search(query: str, runtime: Runtime, *, tool_name: str = "knowledge_search") -> tuple[str, dict | None]:
|
||||
"""Retrieve evidence from the configured knowledge provider.
|
||||
|
||||
Args:
|
||||
query: Specific question or search terms for private documents.
|
||||
query: Specific question or search terms for the configured retrieval tool.
|
||||
runtime: Trusted execution context and run configuration.
|
||||
tool_name: Configured document, Wiki entry, or entity relation tool.
|
||||
"""
|
||||
if not query.strip():
|
||||
return "Error: query must not be empty.", None
|
||||
@ -49,12 +51,16 @@ async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[st
|
||||
app_config = context.get("app_config")
|
||||
if not isinstance(app_config, AppConfig):
|
||||
app_config = get_app_config()
|
||||
provider = resolve_knowledge_provider(app_config, extensions=resolve_run_extensions(context))
|
||||
extensions = resolve_run_extensions(context)
|
||||
if scope is not None:
|
||||
validate_scope_capabilities(scope, get_knowledge_capabilities(app_config, extensions=extensions))
|
||||
provider = resolve_knowledge_provider(app_config, extensions=extensions, tool_name=tool_name)
|
||||
result = await search_provider(
|
||||
provider,
|
||||
query.strip(),
|
||||
scope=scope,
|
||||
user_id=resolve_runtime_user_id(runtime),
|
||||
operation=KNOWLEDGE_TOOL_OPERATIONS[tool_name],
|
||||
agent_name=_execution_agent_name(context, getattr(runtime, "config", None)),
|
||||
)
|
||||
return format_result(result)
|
||||
@ -66,10 +72,27 @@ async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[st
|
||||
return "Error: Knowledge retrieval failed; check the provider and selected scope.", None
|
||||
|
||||
|
||||
knowledge_search_tool = StructuredTool.from_function(
|
||||
coroutine=_knowledge_search_entrypoint,
|
||||
name="knowledge_search",
|
||||
response_format="content_and_artifact",
|
||||
description="Search accessible knowledge within this turn's scope. Cite only supplied [citation:Title](#knowledge-source-ID) links exactly; never invent source locators.",
|
||||
parse_docstring=True,
|
||||
)
|
||||
async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict | None]:
|
||||
return await execute_knowledge_search(query, runtime)
|
||||
|
||||
|
||||
async def _entries_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict | None]:
|
||||
return await execute_knowledge_search(query, runtime, tool_name="knowledge_entries_search")
|
||||
|
||||
|
||||
async def _relations_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict | None]:
|
||||
return await execute_knowledge_search(query, runtime, tool_name="entity_relations_search")
|
||||
|
||||
|
||||
def _tool(name, coroutine, description):
|
||||
return StructuredTool.from_function(
|
||||
coroutine=coroutine,
|
||||
name=name,
|
||||
response_format="content_and_artifact",
|
||||
description=description + " Within this turn's scope. Cite only supplied [citation:Title](#knowledge-source-ID) links exactly; never invent source locators or relations.",
|
||||
)
|
||||
|
||||
|
||||
knowledge_search_tool = _tool("knowledge_search", _knowledge_search_entrypoint, "Search accessible document evidence.")
|
||||
knowledge_entries_search_tool = _tool("knowledge_entries_search", _entries_search_entrypoint, "Search accessible published Wiki and knowledge entries.")
|
||||
entity_relations_search_tool = _tool("entity_relations_search", _relations_search_entrypoint, "Search upstream entities and relations. Returned paths may be incomplete; absence does not disprove a relation.")
|
||||
|
||||
@ -38,3 +38,30 @@ async def test_weknora_ssl_setup_is_off_loop(weknora_provider, monkeypatch):
|
||||
monkeypatch.setattr(httpx.AsyncClient, "send", send)
|
||||
catalog = await weknora_provider.catalog(context=KnowledgeRequestContext("alice", "lead_agent"), page=1, page_size=20, search="")
|
||||
assert catalog.items[0].name == "Synthetic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lightrag_relation_ssl_setup_is_off_loop(monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[3] / "examples/deerflow-extension-lightrag"))
|
||||
from deerflow_extension_lightrag import LightRAGProvider
|
||||
|
||||
from deerflow.knowledge import KnowledgeRequestContext
|
||||
|
||||
monkeypatch.setenv("LR_BLOCKING_KEY", "synthetic")
|
||||
provider = LightRAGProvider({"base_url": "https://lightrag.invalid", "api_key_env": "LR_BLOCKING_KEY", "user_ids": ["alice"]})
|
||||
|
||||
async def send(self, request, **kwargs):
|
||||
return httpx.Response(200, request=request, json={"status": "success", "data": {"entities": [], "relationships": [], "chunks": []}})
|
||||
|
||||
original = ssl.create_default_context
|
||||
|
||||
def ssl_context(*args, **kwargs):
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return original(*args, **kwargs)
|
||||
raise AssertionError("SSL trust loading must run off the event loop")
|
||||
|
||||
monkeypatch.setattr(ssl, "create_default_context", ssl_context)
|
||||
monkeypatch.setattr(httpx.AsyncClient, "send", send)
|
||||
assert not (await provider.relations_search("q", context=KnowledgeRequestContext("alice"))).hits
|
||||
|
||||
@ -59,6 +59,7 @@ EXPECTED_GUIDANCE_PATHS = {
|
||||
"examples/deerflow-extension-agent-teams/AGENTS.md",
|
||||
"examples/deerflow-extension-workflows/AGENTS.md",
|
||||
"examples/deerflow-extension-weknora/AGENTS.md",
|
||||
"examples/deerflow-extension-lightrag/AGENTS.md",
|
||||
}
|
||||
|
||||
|
||||
|
||||
21
backend/tests/test_browser_ci_runner.py
Normal file
21
backend/tests/test_browser_ci_runner.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Browser CI installs its dependencies before executing browser tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("workflow", "job"), [("e2e-tests.yml", "e2e-tests"), ("replay-e2e.yml", "fullstack-replay-render")])
|
||||
def test_browser_ci_installs_chromium_before_running_tests(workflow, job):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
config = yaml.safe_load((root / ".github/workflows" / workflow).read_text(encoding="utf-8"))
|
||||
browser_job = config["jobs"][job]
|
||||
# Runner images are maintained by CI owners; do not freeze a temporary
|
||||
# OS compatibility workaround in an unrelated backend unit test.
|
||||
steps = browser_job["steps"]
|
||||
installs = [index for index, step in enumerate(steps) if "playwright install chromium --with-deps" in step.get("run", "")]
|
||||
tests = [index for index, step in enumerate(steps) if "pnpm exec playwright test" in step.get("run", "")]
|
||||
assert installs, "Browser dependencies must be installed"
|
||||
assert tests, "The browser job must execute tests"
|
||||
assert max(installs) < min(tests), "Install Chromium before running browser tests"
|
||||
166
backend/tests/test_knowledge_phase1_live.py
Normal file
166
backend/tests/test_knowledge_phase1_live.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""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"] == []
|
||||
143
backend/tests/test_knowledge_retrieval_tools.py
Normal file
143
backend/tests/test_knowledge_retrieval_tools.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""Synthetic providers exercise the three configurable retrieval operations."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.knowledge import KnowledgeCapabilities, RetrievalResult
|
||||
from deerflow.knowledge.providers import search_provider, validate_provider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("operation", ["entries_search", "relations_search"])
|
||||
async def test_optional_operation_is_contextual_and_does_not_fallback(operation):
|
||||
method = AsyncMock(return_value=RetrievalResult())
|
||||
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", operation}), search=AsyncMock(), **{operation: method})
|
||||
validate_provider(provider)
|
||||
result = await search_provider(provider, "q", scope={"version": 1, "mode": "all"}, user_id="alice", agent_name="researcher", operation=operation)
|
||||
assert result.hits == []
|
||||
assert method.await_args.kwargs["context"].user_id == "alice"
|
||||
assert method.await_args.kwargs["context"].agent_name == "researcher"
|
||||
provider.search.assert_not_called()
|
||||
with pytest.raises(ValueError, match="dataset_filter"):
|
||||
await search_provider(provider, "q", scope={"version": 1, "mode": "selected", "dataset_ids": ["kb"]}, user_id="alice", agent_name="researcher", operation=operation)
|
||||
assert method.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optional_operation_has_its_own_filter_capabilities():
|
||||
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", "dataset_filter", "document_filter", "entries_search", "entries_dataset_filter"}), entries_search=AsyncMock(return_value=RetrievalResult()))
|
||||
await search_provider(provider, "q", scope={"version": 1, "mode": "selected", "dataset_ids": ["kb"]}, user_id="alice", agent_name=None, operation="entries_search")
|
||||
with pytest.raises(ValueError, match="document_filter"):
|
||||
await search_provider(provider, "q", scope={"version": 1, "mode": "selected", "dataset_ids": ["kb"], "document_filters": [{"dataset_id": "kb", "document_ids": ["doc"]}]}, user_id="alice", agent_name=None, operation="entries_search")
|
||||
assert provider.entries_search.await_count == 1
|
||||
|
||||
|
||||
def test_registration_checks_optional_methods():
|
||||
with pytest.raises(ValueError, match="entries_search"):
|
||||
validate_provider(SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", "entries_search"}), search=AsyncMock()))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mask", range(8))
|
||||
async def test_all_tool_combinations_use_config_and_shared_evidence(mask):
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
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 KnowledgeSource, RetrievalHit, sources_from_messages, tools
|
||||
from deerflow.knowledge.providers import KNOWLEDGE_TOOL_OPERATIONS, get_knowledge_capabilities
|
||||
from deerflow.knowledge.sources import forward_sources, prune_sources
|
||||
from deerflow.runtime.serialization import serialize_channel_values_for_api
|
||||
|
||||
names = [name for i, name in enumerate(KNOWLEDGE_TOOL_OPERATIONS) if mask & (1 << i)]
|
||||
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", "sources", "entries_search", "relations_search"}))
|
||||
for operation in KNOWLEDGE_TOOL_OPERATIONS.values():
|
||||
source = KnowledgeSource(provider="synthetic", title=operation, excerpt=f"Returned {operation} evidence")
|
||||
setattr(provider, operation, AsyncMock(return_value=RetrievalResult(hits=[RetrievalHit(content=source.excerpt, source=source)])))
|
||||
registry = ExtensionRegistry()
|
||||
with registry.attributed_to("synthetic:install"):
|
||||
registry.knowledge_provider("synthetic", provider)
|
||||
loaded = registry.build()
|
||||
config = AppConfig(sandbox=SandboxConfig(use="example.remote:Sandbox"), tools=[ToolConfig(name=name, group="knowledge", use=f"deerflow.knowledge.tools:{name}_tool", provider="synthetic") for name in names])
|
||||
assert get_knowledge_capabilities(config, extensions=loaded).supports("search") == bool(names)
|
||||
if not names:
|
||||
return
|
||||
builder = StateGraph(MessagesState, context_schema=dict)
|
||||
builder.add_node("tools", ToolNode([getattr(tools, name + "_tool") for name in names]))
|
||||
builder.add_edge(START, "tools")
|
||||
builder.add_edge("tools", END)
|
||||
result = await builder.compile().ainvoke(
|
||||
{"messages": [AIMessage(content="", tool_calls=[{"id": name, "name": name, "args": {"query": "q"}} for name in names])]},
|
||||
context={"app_config": config, "user_id": "alice", "is_subagent": True, "agent_id": "researcher", "__knowledge_scope_execution": {"version": 1, "mode": "all"}, EXTENSION_SNAPSHOT_CONTEXT_KEY: loaded},
|
||||
)
|
||||
sources = sources_from_messages(result["messages"])
|
||||
assert len(sources) == len(names)
|
||||
persisted = serialize_channel_values_for_api(result)
|
||||
assert sources_from_messages(persisted["messages"]) == sources
|
||||
report = "\n".join(message.content for message in result["messages"][1:])
|
||||
forwarded, artifact = forward_sources(report, [s.model_dump(mode="json") for s in sources])
|
||||
assert sources_from_messages([{"type": "tool", "content": forwarded, "artifact": artifact}]) == sources
|
||||
assert prune_sources("removed", artifact)[1]["knowledge_sources"] == []
|
||||
for name in names:
|
||||
method = getattr(provider, KNOWLEDGE_TOOL_OPERATIONS[name])
|
||||
if name != "knowledge_search":
|
||||
assert method.await_args.kwargs["context"].agent_name == "researcher"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["knowledge_search", "knowledge_entries_search", "entity_relations_search"])
|
||||
def test_disabled_scope_blocks_each_tool(name):
|
||||
from deerflow.agents.middlewares.knowledge_scope_middleware import KnowledgeScopeMiddleware
|
||||
|
||||
request = SimpleNamespace(tool_call={"name": name, "id": "call"}, runtime=SimpleNamespace(context={"__knowledge_scope_execution": {"version": 1, "mode": "disabled"}}))
|
||||
message = KnowledgeScopeMiddleware._disabled_tool_message(request)
|
||||
assert message.name == name and message.status == "error"
|
||||
|
||||
|
||||
def test_combined_scope_is_intersection_and_rejects_mixed_namespaces():
|
||||
from deerflow.extensions import ExtensionRegistry
|
||||
from deerflow.knowledge.providers import get_knowledge_capabilities
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
capabilities = KnowledgeCapabilities(operations={"search", "catalog", "documents", "dataset_filter", "document_filter", "entries_search", "entries_dataset_filter"})
|
||||
provider = SimpleNamespace(capabilities=capabilities, search=AsyncMock(), search_scoped=AsyncMock(), entries_search=AsyncMock(), catalog=AsyncMock(), documents=AsyncMock())
|
||||
with registry.attributed_to("test:install"):
|
||||
registry.knowledge_provider("one", provider)
|
||||
registry.knowledge_provider("two", provider)
|
||||
entries = {name: SimpleNamespace(use=f"deerflow.knowledge.tools:{name}_tool", model_extra={"provider": "one"}) for name in ["knowledge_search", "knowledge_entries_search"]}
|
||||
config = SimpleNamespace(get_tool_config=entries.get)
|
||||
caps = get_knowledge_capabilities(config, extensions=registry.build())
|
||||
assert caps.supports("dataset_filter") and not caps.supports("document_filter")
|
||||
entries["knowledge_entries_search"].model_extra["provider"] = "two"
|
||||
caps = get_knowledge_capabilities(config, extensions=registry.build())
|
||||
assert caps.supports("search") and not caps.supports("dataset_filter")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("registered", [True, False])
|
||||
def test_incompatible_tool_reports_pair_without_silently_emptying_capabilities(caplog, registered):
|
||||
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 ExtensionRegistry
|
||||
from deerflow.knowledge.providers import get_knowledge_capabilities
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
if registered:
|
||||
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search"}), search=AsyncMock())
|
||||
with registry.attributed_to("test:install"):
|
||||
registry.knowledge_provider("documents-only", provider)
|
||||
names = ["knowledge_search", "knowledge_entries_search"]
|
||||
config = AppConfig(
|
||||
sandbox=SandboxConfig(use="example:Sandbox"),
|
||||
tools=[ToolConfig(name=name, group="knowledge", use=f"deerflow.knowledge.tools:{name}_tool", provider="documents-only") for name in names],
|
||||
)
|
||||
with pytest.raises(ValueError, match="Invalid knowledge tool configuration"):
|
||||
get_knowledge_capabilities(config, extensions=registry.build())
|
||||
assert "documents-only" in caplog.text
|
||||
assert ("knowledge_entries_search" if registered else "knowledge_search") in caplog.text
|
||||
assert ("entries_search" if registered else "not registered") in caplog.text
|
||||
149
backend/tests/test_lightrag_relations_extension.py
Normal file
149
backend/tests/test_lightrag_relations_extension.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""Synthetic LightRAG v1.5.7 graph evidence; no live service required."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from deerflow.knowledge import KnowledgeRequestContext, format_result, sources_from_messages
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph(monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-lightrag"))
|
||||
from deerflow_extension_lightrag import LightRAGProvider
|
||||
|
||||
monkeypatch.setenv("LR_TEST_KEY", "synthetic-key")
|
||||
state = {
|
||||
"entities": [{"entity_name": "Cedar", "description": "A tree"}],
|
||||
"relationships": [{"src_id": "Cedar", "tgt_id": "Forest", "description": "grows in", "source_id": "chunk-1"}],
|
||||
"chunks": [{"chunk_id": "chunk-1", "file_path": "trees.md", "content": "Cedar grows in forests."}],
|
||||
"references": [],
|
||||
}
|
||||
calls = []
|
||||
|
||||
def handle(request):
|
||||
calls.append(request)
|
||||
assert request.url.path == "/query/data"
|
||||
assert request.headers["X-API-Key"] == "synthetic-key"
|
||||
assert json.loads(request.content)["mode"] == "hybrid"
|
||||
return httpx.Response(200, json={"status": "success", "data": state})
|
||||
|
||||
return LightRAGProvider({"base_url": "https://lr.example", "api_key_env": "LR_TEST_KEY", "user_ids": ["alice"]}, transport=httpx.MockTransport(handle)), calls, state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relationship_only_cites_joined_real_chunks(graph):
|
||||
provider, _, state = graph
|
||||
result = await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
|
||||
content, artifact = format_result(result)
|
||||
assert "Cedar — Forest" in content
|
||||
sources = sources_from_messages([{"type": "tool", "name": "entity_relations_search", "content": content, "artifact": artifact}])
|
||||
assert len(sources) == 1 and sources[0].excerpt == "Cedar grows in forests."
|
||||
assert sources[0].page is None and sources[0].url is None
|
||||
state["relationships"][0]["source_id"] = "missing"
|
||||
content, artifact = format_result(await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice")))
|
||||
assert "Cedar — Forest" in content
|
||||
assert sources_from_messages([{"type": "tool", "content": content, "artifact": artifact}]) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_rejects_scope_and_identity_before_network(graph):
|
||||
provider, calls, _ = graph
|
||||
with pytest.raises(PermissionError):
|
||||
await provider.relations_search("q", context=KnowledgeRequestContext("mallory"))
|
||||
for scope in ({"version": 1, "mode": "disabled"}, {"version": 1, "mode": "selected", "dataset_ids": ["kb"]}):
|
||||
with pytest.raises(ValueError):
|
||||
await provider.relations_search("q", context=KnowledgeRequestContext("alice", scope=scope))
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_does_not_infer_edges_or_cite_unrelated_chunks(graph):
|
||||
provider, _, state = graph
|
||||
state["relationships"] = []
|
||||
result = await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
|
||||
assert all(hit.source is None for hit in result.hits)
|
||||
assert all("one edge" not in hit.content for hit in result.hits)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_redacts_secrets_and_rejects_malformed_relations(graph):
|
||||
provider, _, state = graph
|
||||
state["relationships"][0]["description"] = "synthetic-key"
|
||||
state["chunks"][0]["content"] = "synthetic-key evidence"
|
||||
result = await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
|
||||
assert "synthetic-key" not in result.model_dump_json()
|
||||
state["relationships"] = {"invalid": "shape"}
|
||||
with pytest.raises(ValueError):
|
||||
await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [301, 401, 403, 500])
|
||||
async def test_graph_upstream_failure_never_retries(graph, status):
|
||||
provider, _, _ = graph
|
||||
requests = []
|
||||
|
||||
def handle(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(status, headers={"location": "https://other.invalid"}, text="synthetic-key")
|
||||
|
||||
provider._transport = httpx.MockTransport(handle)
|
||||
with pytest.raises((PermissionError, RuntimeError)) as exc:
|
||||
await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
|
||||
assert len(requests) == 1
|
||||
assert "synthetic-key" not in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_chunk_is_emitted_once_with_each_edge_attributed_within_budget(graph):
|
||||
provider, _, state = graph
|
||||
state["chunks"][0]["content"] = "Shared evidence. " * 60
|
||||
state["relationships"] = [{"src_id": f"Node{i}", "tgt_id": "Forest", "description": "grows in", "source_id": "chunk-1<SEP>chunk-1"} for i in range(20)]
|
||||
result = await provider.relations_search("forest", context=KnowledgeRequestContext("alice"))
|
||||
assert len([hit for hit in result.hits if hit.source]) == 1
|
||||
content, artifact = format_result(result)
|
||||
for i in range(20):
|
||||
assert f"Node{i} — Forest" in content
|
||||
assert content.count("Evidence: result [1].") == 20
|
||||
assert len(artifact["knowledge_sources"]) == 1
|
||||
assert len(content) <= 8000
|
||||
short, short_artifact = format_result(result, max_total_chars=1100)
|
||||
if "Evidence: result [1]." in short:
|
||||
assert short_artifact["knowledge_sources"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edges_reference_only_their_own_chunks_and_reuse_shared_positions(graph):
|
||||
provider, _, state = graph
|
||||
state["chunks"].append({"chunk_id": "chunk-2", "file_path": "second.md", "content": "Second evidence."})
|
||||
state["relationships"] = [
|
||||
{"src_id": "A", "tgt_id": "B", "source_id": "chunk-1"},
|
||||
{"src_id": "B", "tgt_id": "C", "source_id": "chunk-2<SEP>chunk-1"},
|
||||
{"src_id": "C", "tgt_id": "D", "source_id": "missing"},
|
||||
]
|
||||
result = await provider.relations_search("q", context=KnowledgeRequestContext("alice"))
|
||||
assert result.hits[1].content.startswith("Evidence: result [1].")
|
||||
assert result.hits[3].content.startswith("Evidence: result [3], result [1].")
|
||||
assert "No retrieved document evidence" in result.hits[4].content
|
||||
assert len([hit for hit in result.hits if hit.source]) == 2
|
||||
for budget in [0, 50, 200, 500, 8000]:
|
||||
content, _ = format_result(result, max_total_chars=budget)
|
||||
for ref in re.findall(r"result \[(\d+)\]", content):
|
||||
assert f"[{ref}] [citation:" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hit_budget_distinguishes_omitted_evidence_from_missing_evidence(graph):
|
||||
provider, _, state = graph
|
||||
state["chunks"] = [{"chunk_id": f"c{i}", "file_path": "evidence.md", "content": f"Evidence {i}."} for i in range(50)]
|
||||
state["relationships"] = [{"src_id": f"A{i}", "tgt_id": "B", "source_id": f"c{i}"} for i in range(50)]
|
||||
result = await provider.relations_search("q", context=KnowledgeRequestContext("alice"))
|
||||
assert len(result.hits) == 100
|
||||
tail_edge = result.hits[-2].content
|
||||
assert "A49 — B" in tail_edge
|
||||
assert "Evidence omitted for this edge: bounded view." in tail_edge
|
||||
assert "No retrieved document evidence" not in tail_edge
|
||||
@ -96,6 +96,9 @@ async def test_knowledge_provider_receives_real_subagent_execution_identity(clas
|
||||
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", "dataset_filter"}), search_scoped=AsyncMock(return_value=RetrievalResult()))
|
||||
monkeypatch.setattr(tools, "get_app_config", _default_app_config)
|
||||
monkeypatch.setattr(tools, "resolve_knowledge_provider", lambda *args, **kwargs: provider)
|
||||
# This identity-focused fixture supplies the configured scope intersection
|
||||
# alongside its stub provider; real configuration is covered by ToolNode tests.
|
||||
monkeypatch.setattr(tools, "get_knowledge_capabilities", lambda *args, **kwargs: provider.capabilities)
|
||||
scope = {"version": 1, "mode": "selected", "dataset_ids": ["private"]}
|
||||
|
||||
async def capturing_astream(state, *, context, **kwargs):
|
||||
|
||||
@ -26,11 +26,13 @@ def adapter(monkeypatch):
|
||||
if state.get("status"):
|
||||
return httpx.Response(state["status"], text="synthetic-secret upstream body")
|
||||
if request.url.path == "/api/v1/knowledge-bases":
|
||||
data = state.get("bases", [{"id": "kb-1", "name": "Synthetic base"}])
|
||||
data = state.get("bases", [{"id": "kb-1", "name": "Synthetic base", "capabilities": {"wiki": True}}])
|
||||
elif request.url.path == "/api/v1/knowledge-bases/kb-1/knowledge":
|
||||
return httpx.Response(200, json={"success": True, "data": [state["document"]], **state.get("pagination", {"total": 1})})
|
||||
elif request.url.path == "/api/v1/knowledge/doc-1":
|
||||
data = state["document"]
|
||||
elif request.url.path == "/api/v1/knowledgebase/kb-1/wiki/search":
|
||||
return httpx.Response(200, json={"pages": state.get("pages", [])})
|
||||
elif request.url.path == "/api/v1/knowledge-search":
|
||||
return httpx.Response(403, json={"success": False, "error": "KB-restricted keys reject global knowledge_ids"})
|
||||
elif request.url.path == "/api/v1/knowledge-bases/kb-1/hybrid-search":
|
||||
@ -398,3 +400,105 @@ async def test_retrieval_response_size_limit_without_retry(adapter):
|
||||
await provider.search_scoped("Cedar", context=ctx)
|
||||
assert len(posts) == 1
|
||||
assert not any("/knowledge/" in r.url.path for r in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_published_entries_use_real_wiki_evidence(adapter):
|
||||
provider, calls, state, *_ = adapter
|
||||
ctx = await selection(provider, docs=False)
|
||||
state["pages"] = [{"knowledge_base_id": "kb-1", "slug": "cedar", "title": "Cedar entry", "status": "published", "summary": "Cedar entry evidence."}]
|
||||
result = await provider.entries_search("Cedar", context=ctx)
|
||||
assert result.hits[0].source.title == "Cedar entry"
|
||||
assert result.hits[0].source.excerpt == "Cedar entry evidence."
|
||||
assert calls[-1].url.params["q"] == "Cedar"
|
||||
content, artifact = format_result(result)
|
||||
assert len(sources_from_messages([{"type": "tool", "name": "knowledge_entries_search", "content": content, "artifact": artifact}])) == 1
|
||||
state["pages"][0]["status"] = "draft"
|
||||
assert not (await provider.entries_search("Cedar", context=ctx)).hits
|
||||
state["pages"][0]["knowledge_base_id"] = "foreign"
|
||||
with pytest.raises(PermissionError):
|
||||
await provider.entries_search("Cedar", context=ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entries_reject_document_filter_before_network(adapter):
|
||||
provider, calls, *_ = adapter
|
||||
ctx = await selection(provider)
|
||||
calls.clear()
|
||||
with pytest.raises(ValueError, match="document"):
|
||||
await provider.entries_search("Cedar", context=ctx)
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entries_skip_non_wiki_bases_and_reject_unbound_identity(adapter):
|
||||
provider, calls, state, *_ = adapter
|
||||
state["bases"] = [{"id": "kb-1", "name": "Document only", "capabilities": {"wiki": False}}]
|
||||
assert not (await provider.entries_search("q", context=KnowledgeRequestContext("alice", "lead_agent"))).hits
|
||||
assert not any("/wiki/" in str(request.url) for request in calls)
|
||||
calls.clear()
|
||||
with pytest.raises(PermissionError):
|
||||
await provider.entries_search("q", context=KnowledgeRequestContext("mallory", "lead_agent"))
|
||||
assert not calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_empty_phrase_falls_back_to_bounded_keywords_with_same_scope(adapter):
|
||||
provider, calls, state, _, transport = adapter
|
||||
ctx = await selection(provider, docs=False)
|
||||
seen = []
|
||||
page = {"knowledge_base_id": "kb-1", "slug": "cedar", "title": "青杉", "status": "published", "summary": "林岚负责青杉计划"}
|
||||
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/wiki/search"):
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={"pages": [page] if request.url.params["q"] in {"青杉", "林岚"} else []})
|
||||
return await transport.handle_async_request(request)
|
||||
|
||||
provider._transport = httpx.MockTransport(handler)
|
||||
result = await provider.entries_search("青杉 计划 负责人 林岚 星河实验室 青杉", context=ctx)
|
||||
assert [r.url.params["q"] for r in seen] == ["青杉 计划 负责人 林岚 星河实验室 青杉", "青杉", "计划", "负责人", "林岚"]
|
||||
assert all(r.url.path == "/api/v1/knowledgebase/kb-1/wiki/search" for r in seen)
|
||||
assert all(r.headers["X-API-Key"] == "synthetic-secret" for r in seen)
|
||||
assert len(result.hits) == 1
|
||||
assert result.hits[0].source.excerpt == "林岚负责青杉计划"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure", ["foreign", "malformed", "http"])
|
||||
async def test_wiki_keyword_fallback_preserves_fail_closed_errors(adapter, failure):
|
||||
provider, _, _, _, transport = adapter
|
||||
seen = []
|
||||
|
||||
async def handler(request):
|
||||
if not request.url.path.endswith("/wiki/search"):
|
||||
return await transport.handle_async_request(request)
|
||||
seen.append(request.url.params["q"])
|
||||
if len(seen) == 1:
|
||||
return httpx.Response(200, json={"pages": []})
|
||||
if failure == "http":
|
||||
return httpx.Response(403)
|
||||
if failure == "malformed":
|
||||
return httpx.Response(200, json={"pages": "bad"})
|
||||
return httpx.Response(200, json={"pages": [{"knowledge_base_id": "foreign", "status": "published"}]})
|
||||
|
||||
provider._transport = httpx.MockTransport(handler)
|
||||
with pytest.raises((PermissionError, RuntimeError)):
|
||||
await provider.entries_search("青杉 林岚", context=KnowledgeRequestContext("alice", "lead_agent"))
|
||||
assert seen == ["青杉 林岚", "青杉"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_nonempty_phrase_does_not_fan_out(adapter):
|
||||
provider, calls, state, *_ = adapter
|
||||
state["pages"] = [{"knowledge_base_id": "kb-1", "status": "published", "title": "青杉", "summary": "evidence"}]
|
||||
assert (await provider.entries_search("青杉 林岚", context=KnowledgeRequestContext("alice", "lead_agent"))).hits
|
||||
assert len([r for r in calls if r.url.path.endswith("/wiki/search")]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("query", "expected"), [("青杉", ["青杉"]), ("a b", ["a b"]), ("青杉 青杉 林岚", ["青杉 青杉 林岚", "青杉", "林岚"])])
|
||||
async def test_wiki_empty_fallback_never_repeats_queries_or_sends_short_terms(adapter, query, expected):
|
||||
provider, calls, *_ = adapter
|
||||
assert not (await provider.entries_search(query, context=KnowledgeRequestContext("alice", "lead_agent"))).hits
|
||||
assert [r.url.params["q"] for r in calls if r.url.path.endswith("/wiki/search")] == expected
|
||||
|
||||
19
examples/deerflow-extension-lightrag/AGENTS.md
Normal file
19
examples/deerflow-extension-lightrag/AGENTS.md
Normal file
@ -0,0 +1,19 @@
|
||||
# LightRAG retrieval extension
|
||||
|
||||
Keep HTTP calls, connection bindings and response mapping in this package.
|
||||
Use the core contextual tool dispatcher and unchanged source envelope.
|
||||
Entity/edge summaries are not document excerpts. Join source_id to returned
|
||||
chunk_id only; never infer edges or multi-hop paths, or use file_path alone as
|
||||
evidence. Reject selected scope, missing bindings, HTTP errors and redirects
|
||||
without retrying with broader credentials. Offload SSL client construction.
|
||||
Tests: backend/tests/test_lightrag_relations_extension.py and the shared
|
||||
three-tool suite. Their responses are synthetic. Separate opt-in checks in
|
||||
backend/tests/test_knowledge_phase1_live.py require existing indexed fixtures;
|
||||
the GLM loop requires KNOWLEDGE_RUN_MODEL_TEST=1. See VALIDATION.md for live
|
||||
results and limits. Never let live tests upload data or silently enable API usage.
|
||||
|
||||
Shared graph evidence chunks are emitted once per returned chunk ID, before the
|
||||
first referencing edge. Every edge names its evidence result positions; prefix
|
||||
truncation therefore cannot leave a retained edge pointing to omitted evidence.
|
||||
|
||||
At the hit limit, distinguish omitted evidence from absent retrieved evidence.
|
||||
22
examples/deerflow-extension-lightrag/LICENSE
Normal file
22
examples/deerflow-extension-lightrag/LICENSE
Normal file
@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
Copyright (c) 2025-2026 DeerFlow Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
85
examples/deerflow-extension-lightrag/README.md
Normal file
85
examples/deerflow-extension-lightrag/README.md
Normal file
@ -0,0 +1,85 @@
|
||||
# LightRAG entity relation retrieval
|
||||
|
||||
Optional read-only plugin for #6269's **new Phase 1**. Provides document retrieval
|
||||
and entity relations through the existing tool configuration, scope admission,
|
||||
shared evidence formatter and citation readers. No management or frontend plugin
|
||||
is required. Requires a host with the new `relations_search` capability.
|
||||
|
||||
The response mapping follows LightRAG v1.5.7 commit
|
||||
[`28ff1b05f2ac3f3e6fa14dd2cd33656579bd0c9c`](https://github.com/HKUDS/LightRAG/blob/28ff1b05f2ac3f3e6fa14dd2cd33656579bd0c9c/lightrag/utils.py)
|
||||
(`convert_to_user_format`), using `POST /query/data` in `hybrid` mode.
|
||||
|
||||
Install from the repository root:
|
||||
|
||||
```sh
|
||||
make extension-install SOURCE=/absolute/path/to/examples/deerflow-extension-lightrag
|
||||
```
|
||||
|
||||
Merge into the installed plugin configuration and restart Gateway:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
- use: deerflow_extension_lightrag:install
|
||||
enabled: true
|
||||
required: true
|
||||
config:
|
||||
base_url: https://lightrag.example.com
|
||||
api_key_env: LIGHTRAG_API_KEY
|
||||
user_ids: ["YOUR_DEERFLOW_USER_ID"]
|
||||
# Optional exact current executors, including allowed subagents:
|
||||
agent_names: ["lead_agent", "researcher"]
|
||||
top_k: 20
|
||||
timeout: 30
|
||||
knowledge_base:
|
||||
enabled: true
|
||||
tool_groups:
|
||||
- name: knowledge
|
||||
tools:
|
||||
- name: entity_relations_search
|
||||
group: knowledge
|
||||
use: deerflow.knowledge.tools:entity_relations_search_tool
|
||||
provider: lightrag-relations
|
||||
# Optional document search using the same authorized connection:
|
||||
- name: knowledge_search
|
||||
group: knowledge
|
||||
use: deerflow.knowledge.tools:knowledge_search_tool
|
||||
provider: lightrag-relations
|
||||
```
|
||||
|
||||
Set `LIGHTRAG_API_KEY` in the Gateway environment. This plugin requires an explicit
|
||||
credential and exact user binding; it never falls back to an anonymous or more
|
||||
privileged connection. Bound users share the credential's upstream visibility;
|
||||
this is not upstream user impersonation. Upstream authorization remains final.
|
||||
|
||||
Remove a tool entry to disable that operation. The legacy built-in LightRAG
|
||||
document tool remains compatible and does not require this plugin.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Returns upstream entities and **one-edge paths** (`src_id`, `tgt_id`, description).
|
||||
It does not compute multi-hop paths, infer new edges, or claim graph completeness.
|
||||
- Each edge's `source_id` is split using LightRAG's `<SEP>` delimiter and joined to
|
||||
retrieved `chunks[].chunk_id`. Only those actual chunks can receive document
|
||||
citations, using their file title or the returned reference mapping.
|
||||
- Edge descriptions and entity summaries remain unattributed graph facts. A
|
||||
returned edge without matching retrieved chunks is explicitly marked as lacking
|
||||
document evidence. Merely sharing `file_path` does not establish evidence.
|
||||
- Uses the existing `KnowledgeSource` envelope unchanged. Graph descriptions and
|
||||
document excerpts are separate hits, so a graph summary cannot become a quotation.
|
||||
- Scope is limited to `all` (the bound workspace) or `disabled`. Dataset/document
|
||||
selections are rejected before requests; no unrestricted retry. Combining
|
||||
different registered providers also disables the shared selector because their
|
||||
resource IDs have no common namespace.
|
||||
- Requests do not follow redirects, have a timeout and a 4 MiB response cap.
|
||||
Core bounds displayed results and citations; returned graphs are partial views.
|
||||
|
||||
Offline synthetic tests cover HTTP requests, permissions, evidence joins,
|
||||
malformed payloads, secret redaction, combinations, delegation, replay and budgets.
|
||||
A real OrbStack deployment with bge-m3 and GLM-5.3 also passed provider and
|
||||
three-tool agent checks; see the [validation record](VALIDATION.md) for versions,
|
||||
reproduction steps and limits. Offline passes alone are not live acceptance.
|
||||
Deployment verification must inspect both relation facts and original evidence.
|
||||
|
||||
Shared graph evidence chunks are emitted once per returned chunk ID, before the
|
||||
first referencing edge. Every edge names its evidence result positions; prefix
|
||||
truncation therefore cannot leave a retained edge pointing to omitted evidence.
|
||||
234
examples/deerflow-extension-lightrag/VALIDATION.md
Normal file
234
examples/deerflow-extension-lightrag/VALIDATION.md
Normal file
@ -0,0 +1,234 @@
|
||||
# Local Phase 1 integration validation — 2026-10-20
|
||||
|
||||
The new Phase 1 retrieval chain was exercised against real local services on
|
||||
Apple Silicon macOS with OrbStack. The corpus contains explicitly synthetic
|
||||
Chinese people, organizations, project relations and policy text. GLM-5.3
|
||||
received only these synthetic fixtures and integration prompts.
|
||||
|
||||
## Deployment
|
||||
|
||||
- LightRAG core **1.5.7**, API **0344**; official `ghcr.io/hkuds/lightrag:v1.5.7`
|
||||
arm64 image pinned to
|
||||
`sha256:04716d2f10557342e033395c6490903ed0849a41ac5f89008736a7383e8bad7a`.
|
||||
API published at `127.0.0.1:19621`, with an explicit API key.
|
||||
- WeKnora **v0.8.3**, reusing the document-only local validation stack described
|
||||
in [its validation record](../deerflow-extension-weknora/VALIDATION.md).
|
||||
API: `127.0.0.1:18082`; upstream UI: `127.0.0.1:18081`.
|
||||
- Native Ollama **0.40.4**, local **bge-m3**, 1024 embedding dimensions.
|
||||
Both container connectivity and a two-input embedding request were verified.
|
||||
- GLM **glm-5.3**, OpenAI-compatible endpoint configured in local `.env.local`.
|
||||
Provider credentials and resource IDs are outside the repository. LightRAG
|
||||
connects to native embeddings through `host.docker.internal:11434`.
|
||||
- LightRAG uses its file-backed stores: NetworkX graph, vector and JSON stores
|
||||
persisted in dedicated named volumes. No prebuilt mock graph was loaded.
|
||||
|
||||
## Synthetic data and processing
|
||||
|
||||
A new published WeKnora Wiki entry states that Lin Lan (林岚) leads the Cedar
|
||||
project (青杉计划), funded by Star River Laboratory (星河实验室). A dedicated
|
||||
`retrieve`-only key is restricted to this Wiki base and the existing synthetic
|
||||
document base. The original document fixtures were retained.
|
||||
|
||||
LightRAG ingested `synthetic-cedar-relations.md` through `POST /documents/text`.
|
||||
GLM extracted **5 entities and 5 relations**; bge-m3 embedded them and the one
|
||||
original text chunk. Upstream status reached `processed` with `chunks_count=1`.
|
||||
Actual relations include the project lead, funding and employment connections.
|
||||
|
||||
The initial extraction with default GLM reasoning remained in processing for
|
||||
several minutes. The test pipeline was cancelled and the container recreated
|
||||
with `OPENAI_LLM_EXTRA_BODY='{"thinking":{"type":"disabled"}}'`,
|
||||
`OPENAI_LLM_MAX_TOKENS=6000` and `ENTITY_EXTRACTION_USE_JSON=true`. Recovery of
|
||||
the same document completed successfully. No provider adapter code or source
|
||||
mapping was changed to make the live responses pass. These settings are local
|
||||
validation choices, not new DeerFlow defaults.
|
||||
|
||||
## Observed results
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Existing WeKnora document retrieval live regression | 1 passed |
|
||||
| Published Wiki retrieval with restricted key and selected base | Passed |
|
||||
| Wiki unbound user and unsupported document filtering | Rejected |
|
||||
| Real LightRAG relation retrieval from the generated graph | Passed |
|
||||
| Relation `source_id` joins to actual returned original chunks | Attributable document evidence returned |
|
||||
| LightRAG unbound user, selected scope and disabled scope | Rejected before retrieval |
|
||||
| Real GLM document + Wiki tool loop | Both tools invoked; 3 validated cited sources |
|
||||
| Real GLM document + Wiki + relation tool loop | All three tools invoked and each supplied attributable evidence |
|
||||
| Final answer source handles | Every cited handle belongs to actual tool evidence |
|
||||
| JSON history serialization and replay | Source records preserved |
|
||||
| Shared source forwarding and pruning | Cited evidence retained; removed evidence drops sources |
|
||||
| New opt-in live test module including GLM | **3 passed in 23.67 seconds** |
|
||||
| Focused offline provider/tool tests with live opt-ins absent | **81 passed, 3 skipped** |
|
||||
|
||||
The generative test executes a real LangChain agent loop using DeerFlow's
|
||||
StructuredTools, extension snapshot, trusted runtime context, shared source
|
||||
formatter and `KnowledgeScopeMiddleware`. Tool calls are generated by GLM,
|
||||
not inserted as predetermined AIMessage calls.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Set `DEER_FLOW_RUN_LIVE_TESTS=1` and supply:
|
||||
|
||||
- Wiki: `WEKNORA_BASE_URL`, `WEKNORA_TENANT_ID`, `WEKNORA_WIKI_BASE_ID`,
|
||||
`WEKNORA_API_KEY`, `WEKNORA_WIKI_QUERY`.
|
||||
- Graph: `LIGHTRAG_BASE_URL`, `LIGHTRAG_API_KEY`, `LIGHTRAG_RELATION_QUERY`.
|
||||
- Optional generative check: `KNOWLEDGE_RUN_MODEL_TEST=1`, `E2E_LLM_MODEL`,
|
||||
`E2E_LLM_BASE_URL`, `E2E_LLM_API_KEY`, `WEKNORA_KNOWLEDGE_BASE_ID` and
|
||||
`KNOWLEDGE_MODEL_QUESTION`. The question should cover attributable facts in
|
||||
all three existing fixtures. The WeKnora key must read both configured bases.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
uv run --no-sync --project backend pytest \
|
||||
backend/tests/test_knowledge_phase1_live.py -m live -q --override-ini=addopts=
|
||||
```
|
||||
|
||||
Tests are read-only and require already indexed fixtures; they do not upload,
|
||||
create pages, or change upstream credentials. The generative check has a
|
||||
separate opt-in and incurs model API usage. Without it, only the provider checks
|
||||
run. No credentials or raw API responses should be added to this record.
|
||||
|
||||
## Real DeerFlow browser acceptance
|
||||
|
||||
On 2026-10-20 the current checkout was started with the actual Gateway, Next.js
|
||||
frontend and an OrbStack nginx ingress at loopback port 2026. An isolated local
|
||||
runtime home, SQLite persistence and the local auth-disabled synthetic `default`
|
||||
user were used. Both providers bind that exact user. The browser submitted real
|
||||
chat messages through the normal streaming runtime; no browser API mocks were used.
|
||||
|
||||
Session: `a598bdcd-1acc-49e8-adcd-70f95b704c00`.
|
||||
|
||||
| Browser check | Observed result |
|
||||
| --- | --- |
|
||||
| First real chat | All three requested tools called; completed in 26 seconds |
|
||||
| Document facts | 680 RMB/night and 15 calendar days, correct clickable document citation |
|
||||
| Relation facts | 林岚 leads 青杉计划; 星河实验室 funds it; citation opens the actual original chunk |
|
||||
| Initial Wiki query | Model expanded query to `青杉 计划 负责人 资助机构 林岚 星河实验室`; zero hits, honestly reported |
|
||||
| Follow-up Wiki query | Exact `青杉` returned the published entry; completed in 9 seconds |
|
||||
| Wiki citation | Opens provider/base and summary containing `CEDAR-WIKI-2026` |
|
||||
| Browser refresh | Document, Wiki and relation citation buttons all reopen their original evidence |
|
||||
| Persisted Gateway state | Actual tool calls and source artifacts retained (document 2, relation 5, Wiki follow-up 1) |
|
||||
|
||||
Core retrieval, citation clicks and browser history replay passed. This is **not
|
||||
an unqualified chat-UX pass**: this GLM endpoint emitted English reasoning text
|
||||
and a closing `</think>` in ordinary message content, visible in the browser,
|
||||
even with the local `thinking.type=disabled` setting. Wiki keyword expansion also
|
||||
needed a user follow-up to get a hit. Neither behavior was hidden or mocked away.
|
||||
The mixed-provider configuration does not advertise catalog/dataset selection,
|
||||
so the UI hides its knowledge scope selector; disabled scope was not exercised
|
||||
through this browser session (the earlier tool-level checks still apply).
|
||||
|
||||
Local reproduction and evidence (outside version control):
|
||||
`start-browser.py`,
|
||||
`browser-state.json`, and `browser-{document,wiki,graph}-replay.jpg`.
|
||||
The launcher uses private fixture credentials and `.env.local`; do not commit them.
|
||||
To reproduce the interaction, ask all three tools for the Cedar reimbursement,
|
||||
lead and funding facts, follow up with an exact Wiki query `青杉`, open each source,
|
||||
refresh the chat URL and reopen each source.
|
||||
|
||||
## Browser fixes and re-acceptance
|
||||
|
||||
The two chat issues above were reproduced and fixed on 2026-10-20:
|
||||
|
||||
- WeKnora's lexical Wiki endpoint returned no pages for the complete query
|
||||
`青杉 计划 负责人 资助机构 林岚 星河实验室`. The extension now retries an
|
||||
empty successful result with up to four distinct whitespace-separated keywords
|
||||
of at least two characters, keeping the same authorized KB and credentials.
|
||||
Duplicate title/excerpt pairs are merged. Schema, authorization and HTTP errors
|
||||
still fail immediately; nonempty original results do not trigger fallback.
|
||||
- The local GLM-compatible proxy ignores the native `thinking.type=disabled`
|
||||
switch. Direct synthetic probes reproduced reasoning embedded in content with
|
||||
that payload. The same endpoint returned clean content with the chat-template
|
||||
switch below. The local browser launcher and model live-test runner now use it;
|
||||
no frontend text stripping or global change to official GLM profiles was made.
|
||||
|
||||
```yaml
|
||||
# For the tested proxy's glm-5.3 deployment, not a universal GLM default.
|
||||
extra_body:
|
||||
chat_template_kwargs:
|
||||
enable_thinking: false
|
||||
```
|
||||
|
||||
New browser session: `81bf7e33-d0a2-4cd4-9dfe-f0d8b2cd1ee5`.
|
||||
The first turn explicitly used the complete previously failing Wiki query,
|
||||
called all three tools, and returned all four correct facts with **three actual
|
||||
source citations**, in 26 seconds. The Wiki source opened its published summary.
|
||||
After refresh the citation reopened with the same evidence. A second turn used
|
||||
that same long query and returned a clean one-sentence answer with a real Wiki
|
||||
citation in 5 seconds. Neither turn displayed English reasoning or think tags.
|
||||
Persisted Gateway messages independently confirm the long query, Wiki evidence
|
||||
in both turns, clean assistant content, and all three cited source handles.
|
||||
|
||||
- Wiki adapter regressions: **65 passed**, including bounded retries, deduplication,
|
||||
nonempty-result preservation, single/short terms, and fail-closed errors.
|
||||
- Provider/model factory focused run: **243 passed, 3 skipped** (before the last
|
||||
three additional keyword edge cases).
|
||||
- Strict blocking-I/O suite after the fix: **264 passed, 1 skipped**.
|
||||
- Full backend regression: **34,543 passed, 669 skipped, 1 failed** across four
|
||||
shards. The failure is the previously reproduced Node reporter acceptance
|
||||
case `failing-todo-failure-name-color-spec`; it also fails in isolation. A TUI
|
||||
timing failure observed during the baseline passed both isolation and rerun.
|
||||
- Real provider + model live checks: **3 passed in 26.79 seconds**, using the long
|
||||
Wiki query and clean-answer assertion.
|
||||
- Live model test accepts optional JSON `E2E_LLM_EXTRA_BODY`; set
|
||||
`KNOWLEDGE_EXPECT_CLEAN_ANSWER=1` to reject inline think tags in assistant content.
|
||||
|
||||
Local evidence: `browser-fixed-state.json`, `browser-fixed-final.jpg`,
|
||||
`browser-fixed-replay.jpg`, `probe-thinking.py`, and the corrected
|
||||
`start-browser.py`/`browser-config.yaml` under the local validation directory.
|
||||
The two previously reported issues pass this re-acceptance. Historical messages
|
||||
from the earlier session are retained as originally generated.
|
||||
|
||||
## Limits
|
||||
|
||||
This validates provider retrieval, real model/tool/evidence execution and local
|
||||
browser chat replay. It is not a concurrency/load test or production
|
||||
authorization audit. Source forwarding is tested through the shared helper;
|
||||
no second generative subagent was launched in this live run.
|
||||
|
||||
Relation evidence consists of upstream one-edge paths. Multi-hop discovery,
|
||||
graph completeness, WeKnora entity-graph HTTP access, graph visualization and
|
||||
management pipelines remain outside this delivery. Upstream extraction during
|
||||
fixture preparation does not implement DeerFlow's planned Phase 2 pipeline.
|
||||
|
||||
## Shared-evidence budget review follow-up
|
||||
|
||||
The adapter now emits each referenced chunk ID once, before its first edge,
|
||||
and labels every edge with the corresponding result positions. A regression
|
||||
with 20 edges sharing a long chunk preserves all 20 edges under the shared
|
||||
8,000-character budget with one source record. A second regression covers
|
||||
partially shared chunks, missing IDs and prefix truncation without dangling
|
||||
references. Graph descriptions remain unattributed; only original chunk text
|
||||
receives a source citation.
|
||||
|
||||
Validation after this change: relation adapter tests **10 passed**; real
|
||||
WeKnora/LightRAG/GLM checks **3 passed in 22.80 seconds** after restarting the
|
||||
local provider services; blocking-I/O **266 passed, 1 skipped**. Extension
|
||||
catalog E2E **10 passed**, including English/Chinese LightRAG descriptions and
|
||||
catalog discovery with the runtime API unavailable. The old E2E expectation
|
||||
of 10 bundled extensions was updated to 11.
|
||||
|
||||
## Hit-budget boundary follow-up — 2026-10-21
|
||||
|
||||
The last retained edge now says `Evidence omitted for this edge: bounded view.`
|
||||
when the 98-hit threshold prevented its first valid chunk from being emitted.
|
||||
Missing/unmatched chunk IDs retain the distinct no-retrieved-evidence wording.
|
||||
A 50-edge/50-chunk regression fails before the change and passes afterwards.
|
||||
The repeated test-loop `re` import was moved to module scope.
|
||||
|
||||
The preceding remote head passed all four backend shards, frontend unit tests,
|
||||
blocking-I/O, lint, package checks and guidance validation. Both browser CI jobs
|
||||
failed before tests because ubuntu-latest rolled to Ubuntu 26.04, which the
|
||||
locked Playwright 1.59.1 cannot install Chromium dependencies on. Those two
|
||||
jobs now explicitly use Ubuntu 24.04, with a workflow regression checking both
|
||||
runner selection and preservation of the Chromium dependency-install step.
|
||||
|
||||
## Runner regression correction — 2026-10-22
|
||||
|
||||
Maintainers restored both browser jobs to `ubuntu-latest`; all four E2E shards
|
||||
and full-stack replay passed remotely at `7e7710d03`. The earlier backend
|
||||
regression still pinned Ubuntu 24.04 and was the sole failure in backend shards
|
||||
1 and 3. It now checks Chromium dependency installation precedes browser test
|
||||
execution, without imposing a runner version. The maintainer-selected runner
|
||||
and E2E sharding remain unchanged.
|
||||
@ -0,0 +1,13 @@
|
||||
"""Optional LightRAG document and entity relation retrieval plugin."""
|
||||
|
||||
from deerflow_extension_api import extension
|
||||
|
||||
from .provider import LightRAGProvider
|
||||
|
||||
__all__ = ["LightRAGProvider", "install"]
|
||||
|
||||
|
||||
@extension(api="0.2.7", name="lightrag-relations")
|
||||
def install(registry, config):
|
||||
if not registry.knowledge_provider("lightrag-relations", LightRAGProvider(dict(config))):
|
||||
raise RuntimeError("This host does not support knowledge providers")
|
||||
@ -0,0 +1,150 @@
|
||||
"""LightRAG v1.5.7 /query/data adapter; graph summaries are not document evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from deerflow.community.lightrag.formatting import normalize_retrieval_result
|
||||
from deerflow.community.provider_credentials import reject_url_userinfo
|
||||
from deerflow.knowledge import KnowledgeCapabilities, KnowledgeRequestContext, RetrievalHit, RetrievalResult
|
||||
from deerflow.knowledge.providers import validate_scope_capabilities
|
||||
from deerflow.knowledge.sources import redact_strings
|
||||
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
base_url: AnyHttpUrl
|
||||
api_key_env: str = Field(min_length=1)
|
||||
user_ids: list[str] = Field(min_length=1, max_length=1000)
|
||||
agent_names: list[str] | None = None
|
||||
timeout: float = Field(default=30, gt=0, le=120)
|
||||
top_k: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def validate_url(cls, value):
|
||||
return reject_url_userinfo(value)
|
||||
|
||||
|
||||
def _rows(data: dict, key: str) -> list[dict]:
|
||||
value = data.get(key, [])
|
||||
if not isinstance(value, list) or len(value) > 1000 or any(not isinstance(item, dict) for item in value):
|
||||
raise ValueError("Invalid LightRAG retrieval rows")
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return value.strip()[:10_000] if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def normalize_relations(data: dict) -> RetrievalResult:
|
||||
"""Keep returned one-edge paths; join source_id only to actual chunk IDs.
|
||||
|
||||
LightRAG stores multiple IDs with <SEP>. Never treat file_path on an edge
|
||||
as a document quotation or infer extra edges from co-occurring entities.
|
||||
"""
|
||||
chunks = {}
|
||||
for chunk in _rows(data, "chunks"):
|
||||
chunk_id = chunk.get("chunk_id")
|
||||
if isinstance(chunk_id, str) and chunk_id:
|
||||
if chunk_id in chunks:
|
||||
raise ValueError("Ambiguous LightRAG chunk identity")
|
||||
chunks[chunk_id] = chunk
|
||||
references = _rows(data, "references")
|
||||
hits = []
|
||||
evidence_positions = {}
|
||||
for relation in _rows(data, "relationships"):
|
||||
source, target = _text(relation.get("src_id")), _text(relation.get("tgt_id"))
|
||||
if not source or not target:
|
||||
continue
|
||||
description = _text(relation.get("description"))
|
||||
path = f"Upstream relation (one edge): {source} — {target}\n{description}"
|
||||
ids = relation.get("source_id")
|
||||
evidence_ids = [key for key in dict.fromkeys(ids.split("<SEP>")) if key in chunks] if isinstance(ids, str) else []
|
||||
positions = []
|
||||
evidence_omitted = False
|
||||
for key in evidence_ids:
|
||||
if key not in evidence_positions:
|
||||
normalized = normalize_retrieval_result({"chunks": [chunks[key]], "references": references})
|
||||
if not normalized.hits:
|
||||
continue
|
||||
# Reserve one hit for the edge and one for the bounded-view note.
|
||||
if len(hits) >= 98:
|
||||
evidence_omitted = True
|
||||
break
|
||||
hits.append(normalized.hits[0])
|
||||
evidence_positions[key] = len(hits)
|
||||
positions.append(evidence_positions[key])
|
||||
if positions:
|
||||
# Evidence precedes its users, so prefix budgeting cannot leave an
|
||||
# emitted edge referring to a chunk omitted by the shared formatter.
|
||||
evidence_refs = ", ".join(f"result [{position}]" for position in positions)
|
||||
hits.append(RetrievalHit(content=f"Evidence: {evidence_refs}.\n{path}"))
|
||||
elif evidence_omitted:
|
||||
hits.append(RetrievalHit(content=path + "\nEvidence omitted for this edge: bounded view."))
|
||||
else:
|
||||
hits.append(RetrievalHit(content=path + "\nNo retrieved document evidence for this edge."))
|
||||
if len(hits) >= 99:
|
||||
break
|
||||
for entity in _rows(data, "entities"):
|
||||
if len(hits) >= 99:
|
||||
break
|
||||
name = _text(entity.get("entity_name"))
|
||||
if name:
|
||||
hits.append(RetrievalHit(content=f"Upstream entity: {name}\n{_text(entity.get('description'))}"))
|
||||
if hits:
|
||||
hits = hits[:99]
|
||||
hits.append(RetrievalHit(content="Graph retrieval is a bounded view of upstream data, not a complete graph or proof that other relations are absent."))
|
||||
return RetrievalResult(hits=hits)
|
||||
|
||||
|
||||
class LightRAGProvider:
|
||||
capabilities = KnowledgeCapabilities(operations={"search", "sources", "relations_search"})
|
||||
|
||||
def __init__(self, config: dict, *, transport: httpx.AsyncBaseTransport | None = None):
|
||||
self.settings = Settings.model_validate(config)
|
||||
self._transport = transport
|
||||
|
||||
async def search(self, query: str) -> RetrievalResult:
|
||||
raise PermissionError("LightRAG requires a trusted request context")
|
||||
|
||||
async def _data(self, query: str, context: KnowledgeRequestContext) -> dict:
|
||||
if context.user_id not in self.settings.user_ids or (self.settings.agent_names is not None and context.agent_name not in self.settings.agent_names):
|
||||
raise PermissionError("No LightRAG binding for this identity")
|
||||
if context.scope is not None:
|
||||
scope = validate_scope_capabilities(context.scope, self.capabilities)
|
||||
if scope["mode"] == "disabled":
|
||||
raise ValueError("Knowledge retrieval is disabled")
|
||||
if not query.strip():
|
||||
raise ValueError("Empty query")
|
||||
key = os.getenv(self.settings.api_key_env, "").strip()
|
||||
if not key or not key.isascii() or any(ord(char) < 33 or ord(char) == 127 for char in key):
|
||||
raise ValueError("Missing or invalid LightRAG credential")
|
||||
try:
|
||||
async with await asyncio.to_thread(httpx.AsyncClient, base_url=str(self.settings.base_url).rstrip("/"), headers={"X-API-Key": key}, timeout=self.settings.timeout, follow_redirects=False, transport=self._transport) as client:
|
||||
async with client.stream("POST", "/query/data", json={"query": query.strip(), "mode": "hybrid", "top_k": self.settings.top_k, "chunk_top_k": self.settings.top_k}) as response:
|
||||
if response.status_code in {401, 403}:
|
||||
raise PermissionError("LightRAG denied access")
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError("LightRAG retrieval failed")
|
||||
raw = bytearray()
|
||||
async for part in response.aiter_bytes():
|
||||
raw.extend(part)
|
||||
if len(raw) > 4 * 1024 * 1024:
|
||||
raise RuntimeError("LightRAG response is too large")
|
||||
payload = json.loads(raw)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
raise RuntimeError("LightRAG retrieval failed") from None
|
||||
if not isinstance(payload, dict) or payload.get("status") != "success" or not isinstance(payload.get("data"), dict):
|
||||
raise RuntimeError("Invalid LightRAG retrieval response")
|
||||
return redact_strings(payload["data"], key)
|
||||
|
||||
async def search_scoped(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult:
|
||||
return normalize_retrieval_result(await self._data(query, context))
|
||||
|
||||
async def relations_search(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult:
|
||||
return normalize_relations(await self._data(query, context))
|
||||
25
examples/deerflow-extension-lightrag/pyproject.toml
Normal file
25
examples/deerflow-extension-lightrag/pyproject.toml
Normal file
@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "deerflow-extension-lightrag"
|
||||
version = "0.1.0"
|
||||
description = "Read-only LightRAG document and entity relation provider for DeerFlow"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "MIT"
|
||||
dependencies = ["deerflow-extension-api>=0.2.7,<0.3", "deerflow-harness>=2.1,<3", "httpx>=0.27,<1", "pydantic>=2,<3"]
|
||||
|
||||
[project.entry-points."deerflow.extensions"]
|
||||
lightrag = "deerflow_extension_lightrag:install"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["deerflow_extension_lightrag"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 240
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
@ -18,3 +18,13 @@ Dataset IDs derive from endpoint/tenant/native base, not credentials or labels.
|
||||
Tests live in `backend/tests/test_weknora_extension*.py`; live tests require
|
||||
explicit opt-in and an already indexed instance. Do not fabricate a live result.
|
||||
See README for version references, configuration and the authorization model.
|
||||
|
||||
Optional entries_search uses KB-bound Wiki search and published pages only.
|
||||
Check capabilities.wiki and returned base membership; reject document filters
|
||||
before HTTP. Wiki responses use a separate pages envelope. Never advertise
|
||||
entity relations based on the Wiki page-link graph.
|
||||
|
||||
Wiki lexical search retries only successful empty pages with up to four distinct
|
||||
whitespace-separated keywords of at least two characters. Keep retries in the
|
||||
same authorized base, validate every response, deduplicate title/excerpt pairs,
|
||||
and never retry provider/auth/schema errors. Nonempty phrase results stay intact.
|
||||
|
||||
@ -179,7 +179,44 @@ against v0.8.3's `internal/handler/knowledgebase.go`, `internal/handler/session/
|
||||
|
||||
This package registers the current `RetrievalProvider` contract rather than
|
||||
importing the older branch's provider discovery or native citation format.
|
||||
Uploads, deletion, processing management, Wiki/Graph, a knowledge management
|
||||
Uploads, deletion, processing management, Wiki/Graph management, a knowledge management
|
||||
page and new DeerFlow ACL persistence remain outside the read-only delivery.
|
||||
Shared-contract durable batch source forwarding retains the existing core
|
||||
limitation; ordinary subagent forwarding uses the shared evidence path.
|
||||
|
||||
## New Phase 1: published knowledge entries
|
||||
|
||||
On a host containing #6269's new Phase 1, add this independent tool entry:
|
||||
|
||||
```yaml
|
||||
- name: knowledge_entries_search
|
||||
group: knowledge
|
||||
use: deerflow.knowledge.tools:knowledge_entries_search_tool
|
||||
provider: weknora
|
||||
```
|
||||
|
||||
Keep or remove `knowledge_search` independently. Both use the same plugin
|
||||
connection bindings and current executor identity. Entries use v0.8.3's
|
||||
`GET /api/v1/knowledgebase/{kb_id}/wiki/search?q=...&limit=...`, reusing the
|
||||
published-page approach from superbuzzy's original `search_wiki()` while
|
||||
returning the current shared evidence contract. The endpoint returns `{pages}`
|
||||
rather than the document API's `{success,data}` envelope. Only Wiki-enabled
|
||||
bases and published pages are included; returned base membership is checked.
|
||||
|
||||
Wiki search supports dataset filtering but not document filtering: a generated
|
||||
page may combine several documents. The shared selector therefore offers only
|
||||
base selection when both document and entry tools are configured. Recovered
|
||||
unsupported document selections fail closed, with no unfiltered retry.
|
||||
|
||||
The pinned WeKnora API exposes Wiki links at `/wiki/graph`, not a public entity
|
||||
relation retrieval endpoint. This package does not advertise `relations_search`
|
||||
or substitute that page graph for entity relations. Use a provider with real
|
||||
relation evidence, such as the optional [LightRAG plugin](../deerflow-extension-lightrag/README.md).
|
||||
New Wiki retrieval has also passed real OrbStack/GLM integration checks; see the
|
||||
2026-10-20 follow-up in [the validation record](VALIDATION.md).
|
||||
|
||||
Wiki search first sends the original query. If that returns no pages, it tries
|
||||
at most four distinct whitespace-separated keywords (at least two characters),
|
||||
within the same authorized knowledge base, and merges duplicate evidence. This
|
||||
helps model-generated keyword combinations match the upstream lexical search;
|
||||
it does not perform unrestricted search or retry authorization/provider errors.
|
||||
|
||||
@ -94,3 +94,20 @@ the restricted-key live test and the two-document isolation probes pass after it
|
||||
- Opt-in live test against this deployment: **1 passed**, including the real
|
||||
ToolNode, replay, forwarding and pruning checks above.
|
||||
- Ruff lint/format, extension wheel packaging and agent-guidance checks passed.
|
||||
|
||||
## New Phase 1 follow-up — 2026-10-20
|
||||
|
||||
The existing OrbStack deployment and bge-m3 model were restarted, retaining
|
||||
original documents; the original live document test passed again. A separate
|
||||
synthetic Wiki base and published page were created, with a dedicated
|
||||
`retrieve`-only key restricted to the test Wiki and document bases.
|
||||
|
||||
`test_knowledge_phase1_live.py` verified real published-page search, selected
|
||||
base constraints, unbound-user denial, unsupported document-filter rejection,
|
||||
source replay, shared forwarding and pruning. GLM-5.3 then generated actual
|
||||
calls to the document and Wiki tools, producing three valid cited sources.
|
||||
A subsequent three-tool run also included a real LightRAG graph and passed.
|
||||
See the [combined validation record](../deerflow-extension-lightrag/VALIDATION.md)
|
||||
for versions, reproduction instructions, results and precise coverage limits.
|
||||
This follow-up does not claim a WeKnora entity-graph endpoint or browser-chat
|
||||
acceptance.
|
||||
|
||||
@ -37,6 +37,7 @@ class _Dataset:
|
||||
binding: _Binding
|
||||
native_id: str
|
||||
name: str
|
||||
wiki_enabled: bool = False
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
@ -65,7 +66,7 @@ def _pagination(page: int, page_size: int) -> None:
|
||||
|
||||
|
||||
class WeKnoraProvider:
|
||||
capabilities = KnowledgeCapabilities(operations={"search", "sources", "catalog", "documents", "dataset_filter", "document_filter"})
|
||||
capabilities = KnowledgeCapabilities(operations={"search", "sources", "catalog", "documents", "dataset_filter", "document_filter", "entries_search", "entries_dataset_filter"})
|
||||
|
||||
def __init__(self, config: dict[str, Any], *, transport: httpx.AsyncBaseTransport | None = None):
|
||||
self.settings = Settings.model_validate(config)
|
||||
@ -84,7 +85,7 @@ class WeKnoraProvider:
|
||||
raise PermissionError("No WeKnora space is bound to this user and executing agent")
|
||||
return bindings
|
||||
|
||||
async def _request(self, binding: _Binding, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||
async def _request(self, binding: _Binding, method: str, path: str, *, envelope: bool = True, **kwargs: Any) -> dict[str, Any]:
|
||||
# Client per request prevents tenant/credential mutation across users and
|
||||
# event loops. SSL trust loading runs off-loop. Never follow redirects
|
||||
# carrying privileged credentials.
|
||||
@ -113,7 +114,11 @@ class WeKnoraProvider:
|
||||
except (httpx.HTTPError, ValueError):
|
||||
# No raw body, URL, request headers or credential-bearing cause.
|
||||
raise RuntimeError("WeKnora request failed or returned invalid JSON") from None
|
||||
if not isinstance(payload, dict) or payload.get("success") is not True or "data" not in payload:
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("Invalid WeKnora response")
|
||||
if not envelope:
|
||||
return payload
|
||||
if payload.get("success") is not True or "data" not in payload:
|
||||
raise RuntimeError("WeKnora returned an unsuccessful response")
|
||||
return payload
|
||||
|
||||
@ -130,7 +135,9 @@ class WeKnoraProvider:
|
||||
base_id = native_id(base.get("id"))
|
||||
if allowed is not None and base_id not in allowed:
|
||||
continue
|
||||
dataset = _Dataset(binding, base_id, binding.text(base.get("name"), 500) or "Unnamed knowledge base")
|
||||
base_capabilities = base.get("capabilities")
|
||||
wiki_enabled = isinstance(base_capabilities, dict) and base_capabilities.get("wiki") is True
|
||||
dataset = _Dataset(binding, base_id, binding.text(base.get("name"), 500) or "Unnamed knowledge base", wiki_enabled)
|
||||
if dataset.id in result:
|
||||
raise RuntimeError("Duplicate WeKnora knowledge base")
|
||||
result[dataset.id] = dataset
|
||||
@ -232,3 +239,56 @@ class WeKnoraProvider:
|
||||
score = score if type(score) in {float, int} and math.isfinite(score) else None
|
||||
hits.append(RetrievalHit(content=text, source=source, score=score))
|
||||
return RetrievalResult(hits=hits)
|
||||
|
||||
async def entries_search(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult:
|
||||
"""Published Wiki search, using the upstream KB-bound read endpoint.
|
||||
|
||||
Wiki pages can combine several source documents; v0.8.3's search API
|
||||
cannot enforce document filters. Reject them before contacting upstream.
|
||||
"""
|
||||
scope = execution_scope(context.scope) if context.scope is not None else {"mode": "all"}
|
||||
if scope["mode"] == "disabled" or not query.strip():
|
||||
raise ValueError("Knowledge search is disabled or query is empty")
|
||||
if scope.get("document_filters"):
|
||||
raise ValueError("Wiki retrieval does not support document filters")
|
||||
datasets = await self._datasets(context)
|
||||
selected = scope["dataset_ids"] if scope["mode"] == "selected" else list(datasets)
|
||||
if not set(selected) <= datasets.keys():
|
||||
raise PermissionError("Selected WeKnora knowledge base is unavailable")
|
||||
hits = []
|
||||
for dataset_id in selected:
|
||||
ds = datasets[dataset_id]
|
||||
if not ds.wiki_enabled:
|
||||
continue
|
||||
# Wiki search is lexical, unlike document hybrid search. A model's
|
||||
# space-separated keyword combination can miss a matching title.
|
||||
# Retry only an empty successful response, within the same bound KB.
|
||||
queries = [query.strip()]
|
||||
seen = set()
|
||||
for keyword in queries:
|
||||
payload = await self._request(ds.binding, "GET", f"/api/v1/knowledgebase/{ds.native_id}/wiki/search", envelope=False, params={"q": keyword, "limit": self.settings.match_count})
|
||||
if "pages" not in payload:
|
||||
raise RuntimeError("Invalid WeKnora Wiki response")
|
||||
pages = _items(payload["pages"])
|
||||
if len(pages) > 100:
|
||||
raise RuntimeError("WeKnora response exceeds the supported result limit")
|
||||
if keyword == queries[0] and not pages:
|
||||
terms = list(dict.fromkeys(term for term in query.split() if len(term) >= 2 and term != keyword))
|
||||
queries.extend(terms[:4])
|
||||
for page in pages:
|
||||
if page.get("knowledge_base_id") != ds.native_id:
|
||||
raise PermissionError("WeKnora returned an entry outside the selected base")
|
||||
if page.get("status") != "published":
|
||||
continue
|
||||
title = ds.binding.text(page.get("title"), 500)
|
||||
content = ds.binding.text(page.get("summary") or page.get("content"), 100_000)
|
||||
identity = (title, content)
|
||||
if content and identity not in seen:
|
||||
seen.add(identity)
|
||||
source = KnowledgeSource(provider="weknora", title=title, excerpt=content, dataset_name=ds.name) if title else None
|
||||
hits.append(RetrievalHit(content=content, source=source))
|
||||
if len(hits) >= 100:
|
||||
break
|
||||
if len(hits) >= 100:
|
||||
break
|
||||
return RetrievalResult(hits=hits[:100])
|
||||
|
||||
@ -45,6 +45,7 @@ export const extensionCatalog = [
|
||||
package: "deerflow-extension-knowledge",
|
||||
namespace: null,
|
||||
},
|
||||
{ key: "lightrag", package: "deerflow-extension-lightrag", namespace: null },
|
||||
{ key: "weknora", package: "deerflow-extension-weknora", namespace: null },
|
||||
{ key: "example", package: "deerflow-extension-example", namespace: null },
|
||||
] as const;
|
||||
|
||||
@ -80,6 +80,11 @@ export const enUS: Translations = {
|
||||
description:
|
||||
"Demonstrate read-only retrieval and structured tool results using fixed sample documents, without an external knowledge service.",
|
||||
},
|
||||
lightrag: {
|
||||
title: "LightRAG",
|
||||
description:
|
||||
"Retrieve documents and entity relations from LightRAG with shared source citations.",
|
||||
},
|
||||
weknora: {
|
||||
title: "WeKnora knowledge retrieval",
|
||||
description:
|
||||
|
||||
@ -36,6 +36,7 @@ export interface Translations {
|
||||
| "retrieval"
|
||||
| "knowledge"
|
||||
| "weknora"
|
||||
| "lightrag"
|
||||
| "example",
|
||||
{ title: string; description: string }
|
||||
>;
|
||||
|
||||
@ -76,6 +76,10 @@ export const zhCN: Translations = {
|
||||
description:
|
||||
"使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。",
|
||||
},
|
||||
lightrag: {
|
||||
title: "LightRAG",
|
||||
description: "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。",
|
||||
},
|
||||
weknora: {
|
||||
title: "WeKnora 知识检索",
|
||||
description:
|
||||
|
||||
@ -448,7 +448,14 @@ for (const locale of ["en-US", "zh-CN"]) {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("article")).toHaveCount(10);
|
||||
await expect(page.getByRole("article")).toHaveCount(11);
|
||||
const lightrag = page.getByRole("article").filter({ hasText: "LightRAG" });
|
||||
await expect(lightrag).toHaveCount(1);
|
||||
await expect(lightrag).toContainText(
|
||||
zh
|
||||
? "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。"
|
||||
: "Retrieve documents and entity relations from LightRAG with shared source citations.",
|
||||
);
|
||||
const weknoraTitle = zh
|
||||
? "WeKnora 知识检索"
|
||||
: "WeKnora knowledge retrieval";
|
||||
@ -665,10 +672,10 @@ test("catalog remains discoverable when runtime discovery fails", async ({
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "Extensions unavailable." }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("article")).toHaveCount(10);
|
||||
await expect(page.getByRole("article")).toHaveCount(11);
|
||||
await expect(
|
||||
page.getByRole("article").filter({ hasText: "Catalog extension" }),
|
||||
).toHaveCount(10);
|
||||
).toHaveCount(11);
|
||||
await expect(
|
||||
page.getByRole("article").filter({ hasText: "Script workflows" }),
|
||||
).toHaveCount(1);
|
||||
|
||||
@ -164,3 +164,14 @@ test("WeKnora discovery is localized without implying runtime activation", () =>
|
||||
expect(entry?.loaded).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("LightRAG discovery is localized without claiming a runtime registration", () => {
|
||||
for (const copy of [enUS.extensions.catalog, zhCN.extensions.catalog]) {
|
||||
const entry = extensionDirectory([], copy).find(
|
||||
(item) => item.package === "deerflow-extension-lightrag",
|
||||
);
|
||||
expect(entry?.title).toBe("LightRAG");
|
||||
expect(entry?.description).toBe(copy.lightrag.description);
|
||||
expect(entry?.loaded).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user