mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
parent
a4ff4b0b3b
commit
aec7d73890
@ -279,6 +279,67 @@ container or Pod, not the host machine.
|
|||||||
This integration is retrieval-only. Dataset creation, uploads, parsing, and
|
This integration is retrieval-only. Dataset creation, uploads, parsing, and
|
||||||
deletion remain in RAGFlow and are not exposed as Agent tools or DeerFlow APIs.
|
deletion remain in RAGFlow and are not exposed as Agent tools or DeerFlow APIs.
|
||||||
|
|
||||||
|
### LightRAG Knowledge Retrieval
|
||||||
|
|
||||||
|
LightRAG integration is disabled by default. It is an alternative provider for
|
||||||
|
the same read-only `knowledge_search` tool: an operator picks RAGFlow or
|
||||||
|
LightRAG by which entry appears in the `tools:` list — the two entries share
|
||||||
|
one name, and on duplicate names DeerFlow keeps the **first** configured
|
||||||
|
entry, so configure exactly one. Requires LightRAG v1.4.9 or newer: v1.4.8
|
||||||
|
introduced the data-retrieval endpoint but returned a pre-envelope response
|
||||||
|
shape, and the `status`/`data` envelope plus the citation fields consumed
|
||||||
|
here shipped in v1.4.9. DeerFlow does not persist any index
|
||||||
|
metadata; LightRAG stays the sole source of truth, and the deployment's
|
||||||
|
single indexed workspace is always searched.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tool_groups:
|
||||||
|
- name: knowledge
|
||||||
|
|
||||||
|
tools:
|
||||||
|
- name: knowledge_search
|
||||||
|
group: knowledge
|
||||||
|
use: deerflow.community.lightrag.tools:knowledge_search_tool
|
||||||
|
base_url: http://localhost:9621
|
||||||
|
api_key: $LIGHTRAG_API_KEY
|
||||||
|
mode: mix
|
||||||
|
timeout: 30
|
||||||
|
top_k: 60
|
||||||
|
chunk_top_k: 8
|
||||||
|
max_chars_per_chunk: 800
|
||||||
|
max_total_chars: 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool is opt-in through the normal `tools:` list. Retrieval uses LightRAG's
|
||||||
|
`POST /query/data` endpoint, which performs no LLM generation and returns
|
||||||
|
structured entities, relationships, chunks, and references; DeerFlow keeps the
|
||||||
|
chunks — the document text the selected mode already ranked as relevant — and
|
||||||
|
formats them as citation-numbered text, dropping the graph objects to stay
|
||||||
|
compact and keep the citation shape shared with the RAGFlow provider. `mode`
|
||||||
|
selects the retrieval strategy (`naive`, `local`, `global`, `hybrid`, or
|
||||||
|
`mix`; default `mix`, matching the LightRAG API's own `QueryRequest` default;
|
||||||
|
`bypass` is rejected because it skips the index entirely). `top_k` bounds the
|
||||||
|
entities retrieved in `local` mode or relationships in `global` mode, and the
|
||||||
|
optional `chunk_top_k` bounds the text chunks retrieved and kept after
|
||||||
|
reranking; both are capped at 1000 by the LightRAG server. Short queries that
|
||||||
|
fail LightRAG's minimum-length validation surface the server's readable
|
||||||
|
message. `max_chars_per_chunk` / `max_total_chars` bound the model-visible
|
||||||
|
output size.
|
||||||
|
|
||||||
|
`api_key` is optional because LightRAG may run without authentication. Only
|
||||||
|
omit it for loopback or trusted-network deployments — a network-exposed
|
||||||
|
LightRAG must have authentication enabled, and then the key is sent as the
|
||||||
|
`X-API-Key` header and redacted from every model-visible error and from
|
||||||
|
server logs. Blank values are treated as unauthenticated. `base_url` must not
|
||||||
|
contain embedded username or password information, and for Docker or
|
||||||
|
Kubernetes it must be reachable from the Gateway container or Pod.
|
||||||
|
|
||||||
|
Internal identifiers (chunk IDs and the response-local reference IDs) are
|
||||||
|
never exposed to the Agent; citations use the operator-readable `file_path`.
|
||||||
|
This integration is retrieval-only. Document insertion, indexing, and graph
|
||||||
|
mutation remain in LightRAG and are not exposed as Agent tools or DeerFlow
|
||||||
|
APIs.
|
||||||
|
|
||||||
### Tool Groups
|
### Tool Groups
|
||||||
|
|
||||||
Organize tools into logical groups:
|
Organize tools into logical groups:
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
|
||||||
170
backend/packages/harness/deerflow/community/lightrag/client.py
Normal file
170
backend/packages/harness/deerflow/community/lightrag/client.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""Minimal asynchronous client for the LightRAG APIs DeerFlow consumes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
QUERY_MODES = ("naive", "local", "global", "hybrid", "mix")
|
||||||
|
|
||||||
|
|
||||||
|
class LightRAGError(Exception):
|
||||||
|
"""Base class for normalized LightRAG failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class LightRAGAPIError(LightRAGError):
|
||||||
|
"""LightRAG rejected the request with a readable failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class LightRAGConnectionError(LightRAGError):
|
||||||
|
"""LightRAG could not be reached or timed out."""
|
||||||
|
|
||||||
|
|
||||||
|
class LightRAGProtocolError(LightRAGError):
|
||||||
|
"""LightRAG returned an invalid or unexpected HTTP response."""
|
||||||
|
|
||||||
|
|
||||||
|
class LightRAGClient:
|
||||||
|
"""Direct HTTP client for DeerFlow's read-only retrieval tools.
|
||||||
|
|
||||||
|
The client deliberately owns no cache or persistent state. A fresh HTTP
|
||||||
|
session is opened for each method call so callers do not need to manage a
|
||||||
|
client lifecycle. The optional API key is sent as the ``X-API-Key``
|
||||||
|
request header, the single credential form LightRAG documents for
|
||||||
|
API-key-authenticated servers; unauthenticated deployments simply omit it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str | None,
|
||||||
|
timeout: float = 30,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self._api_key = api_key
|
||||||
|
self._transport = transport
|
||||||
|
|
||||||
|
def _redact(self, value: object) -> str:
|
||||||
|
text = str(value)
|
||||||
|
if self._api_key:
|
||||||
|
text = text.replace(self._api_key, "[REDACTED]")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _error_message(self, payload: object, status_code: int) -> str | None:
|
||||||
|
"""Extract a redacted, human-readable message from an error payload.
|
||||||
|
|
||||||
|
LightRAG failures carry text in ``message`` (QueryDataResponse
|
||||||
|
envelope) or ``detail`` (FastAPI error handler, either a string or a
|
||||||
|
list of validation objects whose ``msg`` holds the reason, prefixed by
|
||||||
|
pydantic's "Value error, "). Anything else — structured bodies, plain
|
||||||
|
text, missing payloads — yields ``None`` so the caller falls back to a
|
||||||
|
stable protocol error instead of dumping raw JSON at the model.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
candidate = payload.get("message")
|
||||||
|
if not isinstance(candidate, str) or not candidate.strip():
|
||||||
|
detail = payload.get("detail")
|
||||||
|
if isinstance(detail, str):
|
||||||
|
candidate = detail
|
||||||
|
elif isinstance(detail, list):
|
||||||
|
candidate = self._first_validation_message(detail)
|
||||||
|
if isinstance(candidate, str) and candidate.strip():
|
||||||
|
text = candidate.removeprefix("Value error, ").strip()
|
||||||
|
return self._redact(text)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _first_validation_message(items: list[object]) -> str | None:
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
message = item.get("msg")
|
||||||
|
if isinstance(message, str) and message.strip():
|
||||||
|
return message
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
request_headers = {"Accept": "application/json"}
|
||||||
|
if self._api_key:
|
||||||
|
request_headers["X-API-Key"] = self._api_key
|
||||||
|
client_kwargs: dict[str, Any] = {
|
||||||
|
"base_url": self.base_url,
|
||||||
|
"headers": request_headers,
|
||||||
|
"timeout": self.timeout,
|
||||||
|
}
|
||||||
|
if self._transport is not None:
|
||||||
|
client_kwargs["transport"] = self._transport
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||||
|
response = await client.request(method, path, json=json)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise LightRAGConnectionError(f"LightRAG request timed out after {self.timeout:g} seconds.") from None
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
detail = self._redact(exc)
|
||||||
|
raise LightRAGConnectionError(f"{type(exc).__name__}: {detail}") from None
|
||||||
|
|
||||||
|
if response.is_error:
|
||||||
|
# A 404 on the data-retrieval endpoint means either a wrong
|
||||||
|
# base_url or a LightRAG older than v1.4.9, where /query/data did
|
||||||
|
# not exist yet; the default "Not Found" body helps neither case.
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise LightRAGAPIError("LightRAG data-retrieval endpoint not found; check base_url or upgrade LightRAG to v1.4.9 or newer.")
|
||||||
|
try:
|
||||||
|
error_payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
raise LightRAGProtocolError(f"LightRAG request failed (HTTP {response.status_code}).") from None
|
||||||
|
message = self._error_message(error_payload, response.status_code)
|
||||||
|
if message is not None:
|
||||||
|
raise LightRAGAPIError(message)
|
||||||
|
raise LightRAGProtocolError(f"LightRAG request failed (HTTP {response.status_code}).")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
raise LightRAGProtocolError("LightRAG returned invalid JSON.") from None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise LightRAGProtocolError("LightRAG returned a non-object JSON payload.")
|
||||||
|
|
||||||
|
status = payload.get("status")
|
||||||
|
if status != "success":
|
||||||
|
if "chunks" in payload or "entities" in payload:
|
||||||
|
# v1.4.8 answers with a flat {entities, relationships,
|
||||||
|
# chunks, metadata} payload; the status/data envelope and the
|
||||||
|
# reference-bearing chunk fields both shipped in v1.4.9.
|
||||||
|
raise LightRAGAPIError("LightRAG server response predates v1.4.9; upgrade LightRAG to v1.4.9 or newer to use the data-retrieval endpoint.")
|
||||||
|
message = payload.get("message")
|
||||||
|
text = self._redact(message) if isinstance(message, str) and message.strip() else "LightRAG request failed."
|
||||||
|
raise LightRAGAPIError(text)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def query_data(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
mode: str = "hybrid",
|
||||||
|
top_k: int = 60,
|
||||||
|
chunk_top_k: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run one read-only structured retrieval against ``POST /query/data``.
|
||||||
|
|
||||||
|
The data endpoint performs no LLM generation and always returns
|
||||||
|
entities, relationships, chunks, and references, which is exactly the
|
||||||
|
read-only shape DeerFlow's knowledge tool consumes.
|
||||||
|
"""
|
||||||
|
if mode not in QUERY_MODES:
|
||||||
|
raise ValueError(f"mode must be one of {QUERY_MODES}")
|
||||||
|
|
||||||
|
request_body: dict[str, object] = {"query": query, "mode": mode, "top_k": top_k}
|
||||||
|
if chunk_top_k is not None:
|
||||||
|
request_body["chunk_top_k"] = chunk_top_k
|
||||||
|
|
||||||
|
payload = await self._request("POST", "/query/data", json=request_body)
|
||||||
|
data = payload.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise LightRAGProtocolError("LightRAG returned an invalid retrieval result.")
|
||||||
|
return data
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
"""Compact, citation-friendly formatting for LightRAG retrieval results."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(value: str, max_chars: int, *, marker: str = "…") -> str:
|
||||||
|
if len(value) <= max_chars:
|
||||||
|
return value
|
||||||
|
if max_chars <= len(marker):
|
||||||
|
return marker[:max_chars]
|
||||||
|
return f"{value[: max_chars - len(marker)].rstrip()}{marker}"
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks(value: object) -> list[Mapping[str, Any]]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [chunk for chunk in value if isinstance(chunk, Mapping)]
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_file_paths(value: object) -> dict[str, str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return {}
|
||||||
|
file_paths: dict[str, str] = {}
|
||||||
|
for reference in value:
|
||||||
|
if not isinstance(reference, Mapping):
|
||||||
|
continue
|
||||||
|
reference_id = reference.get("reference_id")
|
||||||
|
file_path = reference.get("file_path")
|
||||||
|
if isinstance(reference_id, str) and isinstance(file_path, str) and file_path.strip():
|
||||||
|
file_paths.setdefault(reference_id, file_path.strip())
|
||||||
|
return file_paths
|
||||||
|
|
||||||
|
|
||||||
|
def format_retrieval_result(
|
||||||
|
result: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
max_chars_per_chunk: int = 800,
|
||||||
|
max_total_chars: int = 8000,
|
||||||
|
) -> str:
|
||||||
|
"""Format one LightRAG ``/query/data`` payload into compact cited text.
|
||||||
|
|
||||||
|
The consumed field names (``chunks[].content/file_path/chunk_id/reference_id``
|
||||||
|
and ``references[].reference_id/file_path``) are cross-checked against the
|
||||||
|
LightRAG v1.5.7 source tree; the endpoint shipped in v1.4.8 but only
|
||||||
|
v1.4.9 introduced the status/data envelope and these citation fields. Opaque
|
||||||
|
internal identifiers (``chunk_id`` and the response-local
|
||||||
|
``reference_id``) are never emitted; the operator-readable ``file_path``
|
||||||
|
labels each citation instead. Entities and relationships are deliberately
|
||||||
|
dropped: the chunks are the document text the selected query mode already
|
||||||
|
ranked as relevant, and keeping the output compact preserves the citation
|
||||||
|
shape shared with the RAGFlow provider.
|
||||||
|
"""
|
||||||
|
raw_chunks = _chunks(result.get("chunks"))
|
||||||
|
if not raw_chunks:
|
||||||
|
return "No relevant content found."
|
||||||
|
|
||||||
|
file_paths_by_reference = _reference_file_paths(result.get("references"))
|
||||||
|
|
||||||
|
entries: list[str] = []
|
||||||
|
matched_documents: list[str] = []
|
||||||
|
counts_by_document: dict[str, int] = {}
|
||||||
|
for index, chunk in enumerate(raw_chunks, start=1):
|
||||||
|
file_path = chunk.get("file_path")
|
||||||
|
if not isinstance(file_path, str) or not file_path.strip():
|
||||||
|
reference_id = chunk.get("reference_id")
|
||||||
|
file_path = file_paths_by_reference.get(str(reference_id)) if reference_id is not None else None
|
||||||
|
document_name = str(file_path).strip() if file_path and str(file_path).strip() else "Unknown document"
|
||||||
|
|
||||||
|
counts_by_document[document_name] = counts_by_document.get(document_name, 0) + 1
|
||||||
|
content = str(chunk.get("content") or "").strip()
|
||||||
|
content = _truncate(content, max_chars_per_chunk)
|
||||||
|
entries.append(f"[{index}] {document_name}\n{content}")
|
||||||
|
|
||||||
|
for document_name, count in counts_by_document.items():
|
||||||
|
unit = "chunk" if count == 1 else "chunks"
|
||||||
|
matched_documents.append(f"{document_name} ({count} {unit})")
|
||||||
|
entries.append(f"Matched documents: {', '.join(matched_documents)}")
|
||||||
|
|
||||||
|
formatted = "\n\n".join(entries)
|
||||||
|
truncation_marker = "… (response truncated)"
|
||||||
|
if len(formatted) <= max_total_chars:
|
||||||
|
return formatted
|
||||||
|
if max_total_chars <= len(truncation_marker):
|
||||||
|
return truncation_marker[:max_total_chars]
|
||||||
|
prefix_length = max_total_chars - len(truncation_marker)
|
||||||
|
return f"{formatted[:prefix_length].rstrip()}{truncation_marker}"
|
||||||
164
backend/packages/harness/deerflow/community/lightrag/tools.py
Normal file
164
backend/packages/harness/deerflow/community/lightrag/tools.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
"""Read-only Agent tool for operator-scoped LightRAG knowledge retrieval."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from langchain_core.tools import StructuredTool
|
||||||
|
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator
|
||||||
|
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
|
from .client import LightRAGAPIError, LightRAGClient, LightRAGConnectionError, LightRAGProtocolError
|
||||||
|
from .formatting import format_retrieval_result
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NO_RELEVANT_CONTENT = "No relevant content found."
|
||||||
|
|
||||||
|
|
||||||
|
class _LightRAGRetrievalSettings(BaseModel):
|
||||||
|
"""Validated provider settings stored on the knowledge_search tool entry."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(validate_default=True)
|
||||||
|
|
||||||
|
base_url: AnyHttpUrl = Field(default="http://localhost:9621")
|
||||||
|
api_key: SecretStr | None = Field(default=None)
|
||||||
|
# LightRAG's QueryRequest also accepts "bypass", which skips the index and
|
||||||
|
# answers straight from the LLM; that would defeat a retrieval tool, so it
|
||||||
|
# is excluded on purpose. "mix" matches the LightRAG API's own default.
|
||||||
|
mode: Literal["naive", "local", "global", "hybrid", "mix"] = Field(default="mix")
|
||||||
|
timeout: float = Field(default=30, gt=0, le=600)
|
||||||
|
# The server caps both fields at MAX_QUERY_TOP_K = 1000; match that limit
|
||||||
|
# instead of inventing a tighter client-side one.
|
||||||
|
top_k: int = Field(default=60, ge=1, le=1000)
|
||||||
|
chunk_top_k: int | None = Field(default=None, ge=1, le=1000)
|
||||||
|
max_chars_per_chunk: int = Field(default=800, ge=1, le=100_000)
|
||||||
|
max_total_chars: int = Field(default=8000, ge=1, le=1_000_000)
|
||||||
|
|
||||||
|
@field_validator("base_url")
|
||||||
|
@classmethod
|
||||||
|
def _reject_url_userinfo(cls, value: AnyHttpUrl) -> AnyHttpUrl:
|
||||||
|
if value.username is not None or value.password is not None:
|
||||||
|
raise ValueError("base_url must not contain username or password information")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _api_key(settings: _LightRAGRetrievalSettings) -> str | None:
|
||||||
|
# LightRAG may run without authentication, so a missing key stays valid;
|
||||||
|
# blank values are treated as unconfigured rather than rejected.
|
||||||
|
value = settings.api_key
|
||||||
|
if isinstance(value, SecretStr):
|
||||||
|
value = value.get_secret_value()
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_api_key(value: object, api_key: str | None) -> str:
|
||||||
|
text = str(value)
|
||||||
|
if api_key:
|
||||||
|
text = text.replace(api_key, "[REDACTED]")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_from_extra(extra: Mapping[str, object]) -> _LightRAGRetrievalSettings:
|
||||||
|
return _LightRAGRetrievalSettings.model_validate(dict(extra))
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_or_error() -> tuple[_LightRAGRetrievalSettings | None, str | None]:
|
||||||
|
tool_config = get_app_config().get_tool_config("knowledge_search")
|
||||||
|
if tool_config is None:
|
||||||
|
return None, "Error: knowledge_search is not configured; add its LightRAG settings to the tools list in config.yaml."
|
||||||
|
try:
|
||||||
|
settings = _settings_from_extra(tool_config.model_extra or {})
|
||||||
|
except ValidationError:
|
||||||
|
logger.warning("LightRAG knowledge_search tool configuration is invalid")
|
||||||
|
return None, "Error: Invalid LightRAG settings for knowledge_search; check config.yaml."
|
||||||
|
return settings, None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_client(settings: _LightRAGRetrievalSettings) -> LightRAGClient:
|
||||||
|
return LightRAGClient(
|
||||||
|
base_url=str(settings.base_url).rstrip("/"),
|
||||||
|
api_key=_api_key(settings),
|
||||||
|
timeout=settings.timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_error(exc: Exception, settings: _LightRAGRetrievalSettings) -> str:
|
||||||
|
key = _api_key(settings)
|
||||||
|
safe_detail = _redact_api_key(exc, key)
|
||||||
|
base_url = _redact_api_key(str(settings.base_url).rstrip("/"), key)
|
||||||
|
|
||||||
|
if isinstance(exc, LightRAGAPIError):
|
||||||
|
logger.warning("LightRAG API rejected a read-only tool request: %s", safe_detail)
|
||||||
|
return f"Error: {safe_detail}"
|
||||||
|
if isinstance(exc, LightRAGConnectionError):
|
||||||
|
logger.warning("LightRAG connection failed for %s (%s)", base_url, type(exc).__name__)
|
||||||
|
return f"Error: Unable to connect to LightRAG ({base_url}): {safe_detail}"
|
||||||
|
if isinstance(exc, LightRAGProtocolError):
|
||||||
|
logger.warning("LightRAG returned an invalid response for a read-only tool request (%s)", type(exc).__name__)
|
||||||
|
return f"Error: LightRAG request failed: {safe_detail}"
|
||||||
|
|
||||||
|
logger.warning("Unexpected LightRAG read-only tool failure (%s)", type(exc).__name__)
|
||||||
|
return "Error: An unexpected LightRAG retrieval error occurred; try again later."
|
||||||
|
|
||||||
|
|
||||||
|
async def knowledge_search(query: str) -> str:
|
||||||
|
"""Search the operator-configured LightRAG instance.
|
||||||
|
|
||||||
|
LightRAG has no dataset catalog to scope: the deployment's single indexed
|
||||||
|
workspace is always searched with the configured retrieval mode, so no
|
||||||
|
binding resolution happens before the one read-only request.
|
||||||
|
"""
|
||||||
|
query = query.strip()
|
||||||
|
if not query:
|
||||||
|
return "Error: query must not be empty."
|
||||||
|
|
||||||
|
settings, error = _settings_or_error()
|
||||||
|
if settings is None:
|
||||||
|
return error or "Error: Invalid LightRAG settings for knowledge_search; check config.yaml."
|
||||||
|
|
||||||
|
client = _build_client(settings)
|
||||||
|
try:
|
||||||
|
result = await client.query_data(
|
||||||
|
query,
|
||||||
|
mode=settings.mode,
|
||||||
|
top_k=settings.top_k,
|
||||||
|
chunk_top_k=settings.chunk_top_k,
|
||||||
|
)
|
||||||
|
formatted = format_retrieval_result(
|
||||||
|
result,
|
||||||
|
max_chars_per_chunk=settings.max_chars_per_chunk,
|
||||||
|
max_total_chars=settings.max_total_chars,
|
||||||
|
)
|
||||||
|
# API-key redaction remains mandatory on the success path; chunk and
|
||||||
|
# reference identifiers never enter the formatted text at all.
|
||||||
|
return _redact_api_key(formatted, _api_key(settings))
|
||||||
|
except Exception as exc:
|
||||||
|
return _tool_error(exc, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_description() -> str:
|
||||||
|
base = "Search the operator-approved LightRAG knowledge base and return compact, citation-numbered source chunks retrieved with the configured graph/vector mode."
|
||||||
|
return f"{base} Internal identifiers are never shown to the model."
|
||||||
|
|
||||||
|
|
||||||
|
async def _knowledge_search_entrypoint(query: str) -> str:
|
||||||
|
"""Search the operator-configured LightRAG knowledge base.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Specific question or search terms to retrieve from the configured private documents.
|
||||||
|
"""
|
||||||
|
return await knowledge_search(query)
|
||||||
|
|
||||||
|
|
||||||
|
knowledge_search_tool = StructuredTool.from_function(
|
||||||
|
coroutine=_knowledge_search_entrypoint,
|
||||||
|
name="knowledge_search",
|
||||||
|
description=_tool_description(),
|
||||||
|
parse_docstring=True,
|
||||||
|
)
|
||||||
368
backend/tests/test_lightrag_client.py
Normal file
368
backend/tests/test_lightrag_client.py
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.community.lightrag.client import (
|
||||||
|
LightRAGAPIError,
|
||||||
|
LightRAGClient,
|
||||||
|
LightRAGConnectionError,
|
||||||
|
LightRAGProtocolError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_query_data_sends_documented_body_and_api_key_header() -> None:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
assert request.method == "POST"
|
||||||
|
assert request.url == httpx.URL("http://lightrag.test/query/data")
|
||||||
|
assert request.headers["X-API-Key"] == "lightrag-secret"
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"status": "success",
|
||||||
|
"message": None,
|
||||||
|
"data": {"chunks": [], "references": [], "entities": [], "relationships": []},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test/",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
timeout=12,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await client.query_data("annual leave", mode="hybrid", top_k=60)
|
||||||
|
|
||||||
|
assert data == {"chunks": [], "references": [], "entities": [], "relationships": []}
|
||||||
|
assert len(requests) == 1
|
||||||
|
assert json.loads(requests[0].content) == {"query": "annual leave", "mode": "hybrid", "top_k": 60}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_query_data_omits_api_key_header_when_unauthenticated() -> None:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
assert "Authorization" not in request.headers
|
||||||
|
assert "X-API-Key" not in request.headers
|
||||||
|
return httpx.Response(200, json={"status": "success", "data": {"chunks": []}})
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key=None,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
assert len(requests) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_query_data_sends_chunk_top_k_only_when_configured() -> None:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
return httpx.Response(200, json={"status": "success", "data": {"chunks": []}})
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.query_data("annual leave", mode="mix", top_k=15, chunk_top_k=8)
|
||||||
|
await client.query_data("annual leave", mode="mix", top_k=15)
|
||||||
|
|
||||||
|
assert json.loads(requests[0].content) == {"query": "annual leave", "mode": "mix", "top_k": 15, "chunk_top_k": 8}
|
||||||
|
assert json.loads(requests[1].content) == {"query": "annual leave", "mode": "mix", "top_k": 15}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_query_data_rejects_unknown_mode_before_request() -> None:
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return httpx.Response(500)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="mode must be one of"):
|
||||||
|
await client.query_data("fallback search", mode="vector")
|
||||||
|
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_non_success_envelope_is_normalized_and_redacts_api_key() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"status": "failure", "message": "invalid credential lightrag-secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGAPIError) as exc_info:
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
assert "invalid credential" in str(exc_info.value)
|
||||||
|
assert "lightrag-secret" not in str(exc_info.value)
|
||||||
|
assert "[REDACTED]" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_http_error_envelope_message_is_used() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
400,
|
||||||
|
json={"status": "failure", "message": "RAG query is too short"},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGAPIError, match="RAG query is too short"):
|
||||||
|
await client.query_data("ab")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_http_error_detail_string_is_used() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(401, json={"detail": "Invalid API key provided"})
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGAPIError, match="Invalid API key provided"):
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_structured_validation_error_message_is_extracted_from_detail() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
422,
|
||||||
|
json={
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"type": "value_error",
|
||||||
|
"loc": ["body", "query"],
|
||||||
|
"msg": "Value error, RAG query is too short. Enter at least 3 English characters or an equivalent combination where each Chinese, Japanese or Korean character counts as 2.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGAPIError) as exc_info:
|
||||||
|
await client.query_data("ab")
|
||||||
|
|
||||||
|
assert "RAG query is too short" in str(exc_info.value)
|
||||||
|
assert "Value error," not in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_structured_validation_error_without_readable_msg_falls_back_to_protocol_error() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(422, json={"detail": [{"type": "missing", "loc": ["body"]}]})
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGProtocolError, match=r"LightRAG request failed \(HTTP 422\)"):
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("body", [None, '{"detail": "Not Found"}'])
|
||||||
|
async def test_404_returns_upgrade_guidance_instead_of_plain_not_found(body: str | None) -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(404, text=body, headers={"Content-Type": "application/json"} if body else None)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGAPIError, match="v1.4.9"):
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_pre_v149_flat_success_payload_returns_upgrade_error() -> None:
|
||||||
|
"""v1.4.8 answers 200 with a flat payload (no status/data envelope).
|
||||||
|
|
||||||
|
Pinning this shape matters: the documented minimum is v1.4.9, so a v1.4.8
|
||||||
|
server must fail with explicit upgrade guidance instead of a generic
|
||||||
|
"request failed" that hides the version incompatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"entities": [{"entity_name": "LightRAG"}],
|
||||||
|
"relationships": [],
|
||||||
|
"chunks": [{"content": "v1.4.8 chunk without citation fields."}],
|
||||||
|
"metadata": {"query_mode": "hybrid"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGAPIError, match="predates v1.4.9"):
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_http_error_text_body_cannot_echo_api_key() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(500, text="internal error: lightrag-secret")
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGProtocolError) as exc_info:
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
assert str(exc_info.value) == "LightRAG request failed (HTTP 500)."
|
||||||
|
assert "lightrag-secret" not in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_server_500_detail_string_is_used_verbatim() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(500, json={"detail": "Internal server error"})
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGAPIError, match="^Internal server error$"):
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_timeout_is_english_and_does_not_leak_api_key(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ReadTimeout("timed out with lightrag-secret", request=request)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
timeout=2,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGConnectionError) as exc_info:
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
assert str(exc_info.value) == "LightRAG request timed out after 2 seconds."
|
||||||
|
assert "lightrag-secret" not in str(exc_info.value)
|
||||||
|
assert "lightrag-secret" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_request_error_is_normalized_and_redacts_api_key() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ConnectError("refused with lightrag-secret", request=request)
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGConnectionError) as exc_info:
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
assert "ConnectError" in str(exc_info.value)
|
||||||
|
assert "lightrag-secret" not in str(exc_info.value)
|
||||||
|
assert "[REDACTED]" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_invalid_json_response_is_normalized_in_english() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, text="not-json")
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGProtocolError, match="LightRAG returned invalid JSON"):
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_non_object_json_payload_is_rejected() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=["not", "an", "object"])
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGProtocolError, match="non-object JSON payload"):
|
||||||
|
await client.query_data("annual leave")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_non_dict_data_payload_is_rejected() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={"status": "success", "data": ["not-a-dict"]})
|
||||||
|
|
||||||
|
client = LightRAGClient(
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LightRAGProtocolError, match="invalid retrieval result"):
|
||||||
|
await client.query_data("annual leave")
|
||||||
419
backend/tests/test_lightrag_tools.py
Normal file
419
backend/tests/test_lightrag_tools.py
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import deerflow.community.lightrag.tools as lightrag_tools
|
||||||
|
from deerflow.community.lightrag.client import LightRAGAPIError, LightRAGConnectionError
|
||||||
|
from deerflow.community.lightrag.formatting import format_retrieval_result
|
||||||
|
from deerflow.config.tool_config import ToolConfig
|
||||||
|
from deerflow.tools.tools import get_available_tools
|
||||||
|
|
||||||
|
CHUNK_ID = "71a4613a-e91b-4d3b-bdff-6f45d9ac1f80"
|
||||||
|
|
||||||
|
|
||||||
|
def _data(
|
||||||
|
*,
|
||||||
|
chunks: list[dict[str, Any]] | None = None,
|
||||||
|
references: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"chunks": chunks if chunks is not None else [],
|
||||||
|
"references": references if references is not None else [],
|
||||||
|
"entities": [],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLightRAGClient:
|
||||||
|
def __init__(self, *, data: dict | None = None, error: Exception | None = None) -> None:
|
||||||
|
self.data = data if data is not None else _data()
|
||||||
|
self.error = error
|
||||||
|
self.query_calls: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
async def query_data(self, query: str, **kwargs: object) -> dict:
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
self.query_calls.append((query, kwargs))
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
|
||||||
|
def _config(
|
||||||
|
*,
|
||||||
|
configured: bool = True,
|
||||||
|
api_key: str | None = "lightrag-secret",
|
||||||
|
base_url: str = "http://lightrag.test",
|
||||||
|
mode: str = "mix",
|
||||||
|
extra: dict[str, object] | None = None,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
settings: dict[str, object] = {
|
||||||
|
"base_url": base_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"mode": mode,
|
||||||
|
"timeout": 30,
|
||||||
|
"top_k": 60,
|
||||||
|
"max_chars_per_chunk": 800,
|
||||||
|
"max_total_chars": 8000,
|
||||||
|
}
|
||||||
|
settings.update(extra or {})
|
||||||
|
search_config = ToolConfig(
|
||||||
|
name="knowledge_search",
|
||||||
|
group="knowledge",
|
||||||
|
use="deerflow.community.lightrag.tools:knowledge_search_tool",
|
||||||
|
**settings,
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
get_tool_config=lambda name: search_config if configured and name == "knowledge_search" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _install(monkeypatch: pytest.MonkeyPatch, fake: FakeLightRAGClient, *, config: SimpleNamespace | None = None) -> None:
|
||||||
|
monkeypatch.setattr(lightrag_tools, "get_app_config", lambda: config or _config())
|
||||||
|
monkeypatch.setattr(lightrag_tools, "_build_client", lambda settings: fake)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_knowledge_search_formats_citation_numbered_chunks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = FakeLightRAGClient(
|
||||||
|
data=_data(
|
||||||
|
chunks=[
|
||||||
|
{
|
||||||
|
"content": "Annual leave is based on years of service.",
|
||||||
|
"file_path": "documents/handbook.md",
|
||||||
|
"chunk_id": CHUNK_ID,
|
||||||
|
"reference_id": "3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Sick leave requires a medical certificate.",
|
||||||
|
"file_path": "documents/handbook.md",
|
||||||
|
"chunk_id": "71a4613a-e91b-4d3b-bdff-6f45d9ac1f81",
|
||||||
|
"reference_id": "3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
references=[{"reference_id": "3", "file_path": "documents/handbook.md"}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_install(monkeypatch, fake)
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search("annual leave")
|
||||||
|
|
||||||
|
assert fake.query_calls == [("annual leave", {"mode": "mix", "top_k": 60, "chunk_top_k": None})]
|
||||||
|
assert "[1] documents/handbook.md\nAnnual leave is based on years of service." in result
|
||||||
|
assert "[2] documents/handbook.md\nSick leave requires a medical certificate." in result
|
||||||
|
assert "Matched documents: documents/handbook.md (2 chunks)" in result
|
||||||
|
assert CHUNK_ID not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_knowledge_search_resolves_missing_chunk_file_path_from_references(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = FakeLightRAGClient(
|
||||||
|
data=_data(
|
||||||
|
chunks=[{"content": "Graph-reconstructed context.", "chunk_id": CHUNK_ID, "reference_id": "7"}],
|
||||||
|
references=[{"reference_id": "7", "file_path": "documents/graph-notes.md"}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_install(monkeypatch, fake)
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search("graph")
|
||||||
|
|
||||||
|
assert "[1] documents/graph-notes.md\nGraph-reconstructed context." in result
|
||||||
|
assert "Unknown document" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_knowledge_search_sends_configured_retrieval_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = FakeLightRAGClient(data=_data(chunks=[{"content": "Guide.", "file_path": "docs/guide.md"}]))
|
||||||
|
_install(monkeypatch, fake, config=_config(mode="local", extra={"top_k": 12, "chunk_top_k": 6}))
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search("guide")
|
||||||
|
|
||||||
|
assert fake.query_calls == [("guide", {"mode": "local", "top_k": 12, "chunk_top_k": 6})]
|
||||||
|
assert "Guide." in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_knowledge_search_works_without_api_key_for_unauthenticated_servers(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
fake = FakeLightRAGClient(data=_data(chunks=[{"content": "Open knowledge.", "file_path": "docs/open.md"}]))
|
||||||
|
_install(monkeypatch, fake, config=_config(api_key=None))
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search("open")
|
||||||
|
|
||||||
|
assert "Open knowledge." in result
|
||||||
|
assert fake.query_calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_blank_api_key_is_treated_as_unauthenticated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = FakeLightRAGClient(data=_data(chunks=[{"content": "Open knowledge.", "file_path": "docs/open.md"}]))
|
||||||
|
_install(monkeypatch, fake, config=_config(api_key=" "))
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search("open")
|
||||||
|
|
||||||
|
assert "Open knowledge." in result
|
||||||
|
assert fake.query_calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_missing_knowledge_search_config_returns_english_guidance(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = FakeLightRAGClient()
|
||||||
|
_install(monkeypatch, fake, config=_config(configured=False))
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search("leave")
|
||||||
|
|
||||||
|
assert result == "Error: knowledge_search is not configured; add its LightRAG settings to the tools list in config.yaml."
|
||||||
|
assert fake.query_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_invalid_settings_return_english_guidance_without_leaking_values(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
fake = FakeLightRAGClient()
|
||||||
|
_install(monkeypatch, fake, config=_config(mode="vector"))
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.lightrag.tools"):
|
||||||
|
result = await lightrag_tools.knowledge_search("leave")
|
||||||
|
|
||||||
|
assert result == "Error: Invalid LightRAG settings for knowledge_search; check config.yaml."
|
||||||
|
assert "vector" not in caplog.text
|
||||||
|
assert fake.query_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"base_url",
|
||||||
|
[
|
||||||
|
"http://lightrag-secret@lightrag.test",
|
||||||
|
"http://lightrag%2Dsecret@lightrag.test",
|
||||||
|
"http://user:lightrag-secret@lightrag.test",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_base_url_with_plain_or_encoded_userinfo_is_rejected_without_leaking_credentials(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
base_url: str,
|
||||||
|
) -> None:
|
||||||
|
fake = FakeLightRAGClient()
|
||||||
|
_install(monkeypatch, fake, config=_config(base_url=base_url))
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.lightrag.tools"):
|
||||||
|
result = await lightrag_tools.knowledge_search("leave")
|
||||||
|
|
||||||
|
assert result == "Error: Invalid LightRAG settings for knowledge_search; check config.yaml."
|
||||||
|
assert "lightrag-secret" not in result
|
||||||
|
assert "lightrag-secret" not in caplog.text
|
||||||
|
assert "lightrag%2Dsecret" not in caplog.text
|
||||||
|
assert fake.query_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_empty_query_has_english_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = FakeLightRAGClient()
|
||||||
|
_install(monkeypatch, fake)
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search(" ")
|
||||||
|
|
||||||
|
assert result == "Error: query must not be empty."
|
||||||
|
assert fake.query_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_empty_retrieval_has_explicit_english_message(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = FakeLightRAGClient()
|
||||||
|
_install(monkeypatch, fake)
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search("nothing")
|
||||||
|
|
||||||
|
assert result == "No relevant content found."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_error_is_returned_as_readable_text_and_logged(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
fake = FakeLightRAGClient(error=LightRAGAPIError("RAG query is too short"))
|
||||||
|
_install(monkeypatch, fake)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.lightrag.tools"):
|
||||||
|
result = await lightrag_tools.knowledge_search("ab")
|
||||||
|
|
||||||
|
assert result == "Error: RAG query is too short"
|
||||||
|
assert "too short" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_connection_error_is_english_and_does_not_leak_key(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
fake = FakeLightRAGClient(error=LightRAGConnectionError("ConnectError: refused lightrag-secret"))
|
||||||
|
_install(monkeypatch, fake)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.lightrag.tools"):
|
||||||
|
result = await lightrag_tools.knowledge_search("leave")
|
||||||
|
|
||||||
|
assert result == "Error: Unable to connect to LightRAG (http://lightrag.test): ConnectError: refused [REDACTED]"
|
||||||
|
assert "lightrag-secret" not in result
|
||||||
|
assert "lightrag-secret" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_success_path_still_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = FakeLightRAGClient(data=_data(chunks=[{"content": "Accidental echo: lightrag-secret", "file_path": "docs/secret.md"}]))
|
||||||
|
_install(monkeypatch, fake)
|
||||||
|
|
||||||
|
result = await lightrag_tools.knowledge_search("secret")
|
||||||
|
|
||||||
|
assert "lightrag-secret" not in result
|
||||||
|
assert "[REDACTED]" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_formatting_uses_only_documented_chunk_fields() -> None:
|
||||||
|
result = format_retrieval_result(
|
||||||
|
{
|
||||||
|
"chunks": [
|
||||||
|
{
|
||||||
|
"content": "abcdefghij",
|
||||||
|
"file_path": "docs/policy.md",
|
||||||
|
"chunk_id": CHUNK_ID,
|
||||||
|
"reference_id": "1",
|
||||||
|
"unexpected_future_field": "ignored",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"references": [{"reference_id": "1", "file_path": "docs/policy.md"}],
|
||||||
|
},
|
||||||
|
max_chars_per_chunk=5,
|
||||||
|
max_total_chars=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "[1] docs/policy.md" in result
|
||||||
|
assert "abcd…" in result
|
||||||
|
assert "abcdefghij" not in result
|
||||||
|
assert CHUNK_ID not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_formatting_without_chunk_file_path_or_reference_labels_unknown_document() -> None:
|
||||||
|
result = format_retrieval_result({"chunks": [{"content": "Orphan chunk.", "chunk_id": CHUNK_ID}]})
|
||||||
|
|
||||||
|
assert "[1] Unknown document\nOrphan chunk." in result
|
||||||
|
assert CHUNK_ID not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_formatting_applies_total_response_truncation_in_english() -> None:
|
||||||
|
result = format_retrieval_result(
|
||||||
|
{"chunks": [{"content": "content " * 20, "file_path": f"documents/document-{index}.md"} for index in range(4)]},
|
||||||
|
max_chars_per_chunk=100,
|
||||||
|
max_total_chars=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) <= 120
|
||||||
|
assert result.endswith("… (response truncated)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieval_settings_load_provider_fields_and_hide_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(lightrag_tools, "get_app_config", lambda: _config(mode="mix", extra={"top_k": 12, "chunk_top_k": 6}))
|
||||||
|
|
||||||
|
config, error = lightrag_tools._settings_or_error()
|
||||||
|
|
||||||
|
assert error is None
|
||||||
|
assert config is not None
|
||||||
|
assert str(config.base_url).rstrip("/") == "http://lightrag.test"
|
||||||
|
assert config.mode == "mix"
|
||||||
|
assert config.top_k == 12
|
||||||
|
assert config.chunk_top_k == 6
|
||||||
|
assert config.max_chars_per_chunk == 800
|
||||||
|
assert config.max_total_chars == 8000
|
||||||
|
assert "lightrag-secret" not in repr(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieval_settings_allow_omitting_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(lightrag_tools, "get_app_config", lambda: _config(api_key=None))
|
||||||
|
|
||||||
|
config, error = lightrag_tools._settings_or_error()
|
||||||
|
|
||||||
|
assert error is None
|
||||||
|
assert config is not None
|
||||||
|
assert config.api_key is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_exposes_only_query_on_single_search_tool() -> None:
|
||||||
|
assert not hasattr(lightrag_tools, "list_knowledge_bases_tool")
|
||||||
|
assert not hasattr(lightrag_tools, "list_knowledge_bases")
|
||||||
|
assert lightrag_tools.knowledge_search_tool.name == "knowledge_search"
|
||||||
|
assert lightrag_tools.knowledge_search_tool.coroutine is not None
|
||||||
|
assert set(lightrag_tools.knowledge_search_tool.tool_call_schema.model_fields) == {"query"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_assembly_hides_credentials_without_network_io(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(lightrag_tools, "_build_client", lambda settings: pytest.fail("tool assembly must not perform network IO"))
|
||||||
|
tool_config = ToolConfig(
|
||||||
|
name="knowledge_search",
|
||||||
|
group="knowledge",
|
||||||
|
use="deerflow.community.lightrag.tools:knowledge_search_tool",
|
||||||
|
base_url="http://lightrag.test",
|
||||||
|
api_key="lightrag-secret",
|
||||||
|
mode="hybrid",
|
||||||
|
)
|
||||||
|
config = SimpleNamespace(
|
||||||
|
tools=[tool_config],
|
||||||
|
sandbox=SimpleNamespace(use="example.remote:Sandbox"),
|
||||||
|
skill_evolution=SimpleNamespace(enabled=False),
|
||||||
|
models=[],
|
||||||
|
acp_agents={},
|
||||||
|
get_model_config=lambda name: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
tools = get_available_tools(include_mcp=False, app_config=config)
|
||||||
|
assembled = next(tool for tool in tools if tool.name == "knowledge_search")
|
||||||
|
|
||||||
|
assert "LightRAG" in assembled.description
|
||||||
|
assert "lightrag-secret" not in assembled.description
|
||||||
|
assert {tool.name for tool in tools}.isdisjoint({"list_knowledge_bases"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieval_settings_default_mode_matches_lightrag_request_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(lightrag_tools, "get_app_config", lambda: _config(mode="mix"))
|
||||||
|
|
||||||
|
config, error = lightrag_tools._settings_or_error()
|
||||||
|
|
||||||
|
assert error is None
|
||||||
|
assert config is not None
|
||||||
|
assert config.mode == "mix"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_knowledge_search_name_keeps_first_configured_entry() -> None:
|
||||||
|
"""RAGFlow and LightRAG share the ``knowledge_search`` tool name.
|
||||||
|
|
||||||
|
This pins the config-layer behavior for that shared name so an operator
|
||||||
|
accidentally configuring both entries gets a documented outcome (the
|
||||||
|
first entry wins) instead of silent provider swapping.
|
||||||
|
"""
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.sandbox_config import SandboxConfig
|
||||||
|
|
||||||
|
ragflow_entry = ToolConfig(
|
||||||
|
name="knowledge_search",
|
||||||
|
group="knowledge",
|
||||||
|
use="deerflow.community.ragflow.tools:knowledge_search_tool",
|
||||||
|
)
|
||||||
|
lightrag_entry = ToolConfig(
|
||||||
|
name="knowledge_search",
|
||||||
|
group="knowledge",
|
||||||
|
use="deerflow.community.lightrag.tools:knowledge_search_tool",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = AppConfig(tools=[ragflow_entry, lightrag_entry], sandbox=SandboxConfig(use="example.remote:Sandbox"))
|
||||||
|
|
||||||
|
assert config.get_tool_config("knowledge_search") is ragflow_entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_lightrag_package_has_explicit_init_file() -> None:
|
||||||
|
package_dir = Path(lightrag_tools.__file__).resolve().parent
|
||||||
|
|
||||||
|
assert (package_dir / "__init__.py").is_file()
|
||||||
@ -746,6 +746,28 @@ tools:
|
|||||||
# max_chars_per_chunk: 800
|
# max_chars_per_chunk: 800
|
||||||
# max_total_chars: 8000
|
# max_total_chars: 8000
|
||||||
|
|
||||||
|
# LightRAG knowledge retrieval (read-only). Alternative provider for the same
|
||||||
|
# knowledge_search tool; uncomment this entry instead of the RAGFlow one —
|
||||||
|
# duplicate names keep the first entry, so configure exactly one. Requires
|
||||||
|
# LightRAG v1.4.9+ (response envelope and citation fields). Searches the deployment's
|
||||||
|
# single indexed workspace through the data retrieval endpoint (no LLM
|
||||||
|
# generation inside LightRAG). `api_key` is optional; only omit it for
|
||||||
|
# loopback/trusted-network servers — when auth is enabled the key is sent as
|
||||||
|
# the X-API-Key header. `mode` picks the retrieval strategy: naive, local,
|
||||||
|
# global, hybrid, or mix (default mix, matching LightRAG's own default).
|
||||||
|
# Internal chunk and reference identifiers never reach the model.
|
||||||
|
# - name: knowledge_search
|
||||||
|
# group: knowledge
|
||||||
|
# use: deerflow.community.lightrag.tools:knowledge_search_tool
|
||||||
|
# base_url: http://localhost:9621 # Docker: use a backend-reachable URL
|
||||||
|
# api_key: $LIGHTRAG_API_KEY # Omit only for unauthenticated trusted servers
|
||||||
|
# mode: mix
|
||||||
|
# timeout: 30
|
||||||
|
# top_k: 60
|
||||||
|
# chunk_top_k: 8 # Optional; omit to use the server default
|
||||||
|
# max_chars_per_chunk: 800
|
||||||
|
# max_total_chars: 8000
|
||||||
|
|
||||||
# Web search tool (uses DuckDuckGo, no API key required)
|
# Web search tool (uses DuckDuckGo, no API key required)
|
||||||
- name: web_search
|
- name: web_search
|
||||||
group: web
|
group: web
|
||||||
|
|||||||
@ -337,6 +337,25 @@ config: |
|
|||||||
# top_k: 256
|
# top_k: 256
|
||||||
# max_chars_per_chunk: 800
|
# max_chars_per_chunk: 800
|
||||||
# max_total_chars: 8000
|
# max_total_chars: 8000
|
||||||
|
# Optional tenant-shared, read-only LightRAG retrieval; alternative
|
||||||
|
# provider for the same knowledge_search tool (duplicate names keep the
|
||||||
|
# first entry, so configure exactly one). Requires LightRAG v1.4.9+. Put
|
||||||
|
# LIGHTRAG_API_KEY in `secrets` when the server enables API-key auth;
|
||||||
|
# omit `api_key` only for unauthenticated trusted-network deployments.
|
||||||
|
# Searches the single indexed workspace through the data-retrieval
|
||||||
|
# endpoint with no LLM generation inside LightRAG. Internal chunk and
|
||||||
|
# reference identifiers stay hidden from the Agent.
|
||||||
|
# - name: knowledge_search
|
||||||
|
# group: knowledge
|
||||||
|
# use: deerflow.community.lightrag.tools:knowledge_search_tool
|
||||||
|
# base_url: http://lightrag:9621
|
||||||
|
# api_key: $LIGHTRAG_API_KEY
|
||||||
|
# mode: mix
|
||||||
|
# timeout: 30
|
||||||
|
# top_k: 60
|
||||||
|
# chunk_top_k: 8 # Optional; omit to use the server default
|
||||||
|
# max_chars_per_chunk: 800
|
||||||
|
# max_total_chars: 8000
|
||||||
- name: web_search
|
- name: web_search
|
||||||
group: web
|
group: web
|
||||||
use: deerflow.community.ddg_search.tools:web_search_tool
|
use: deerflow.community.ddg_search.tools:web_search_tool
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user