mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
feat(knowledge): add verifiable RAGFlow source citations (#5551)
* feat(knowledge): add verifiable RAGFlow source citations * docs(knowledge): scope RAGFlow guidance to its own directory * fix(knowledge): preserve citations through rendering and budgets
This commit is contained in:
parent
f9f3127dc1
commit
34bbeb1806
16
README.md
16
README.md
@ -1097,6 +1097,22 @@ under `web_fetch` or use `TAVILY_API_KEY` for both.
|
||||
|
||||
### Private Knowledge Retrieval (RAGFlow)
|
||||
|
||||
Answers can cite retrieved RAGFlow evidence with clickable knowledge citations.
|
||||
Click a citation, or an entry in the answer's knowledge sources list, to see the
|
||||
original retrieved excerpt, dataset and document names, and page numbers when
|
||||
RAGFlow supplies them. These are retrieval-time snapshots retained with the
|
||||
conversation, including sources forwarded by ordinary `task` subagents; they
|
||||
remain inspectable after reloading the conversation. An excerpt is not a live
|
||||
copy of the full document: changes in RAGFlow do not rewrite past evidence.
|
||||
Missing source records are shown as unavailable rather than turned into guessed
|
||||
links. Source snapshots do not add a knowledge-management page or expose the
|
||||
RAGFlow API key. Durable batch exports and standalone Markdown files do not
|
||||
carry these interactive conversation source records.
|
||||
Ordinary document-title links in a Sources section open the same evidence as
|
||||
inline citations. When a tool-output budget applies, only complete evidence
|
||||
entries that fit remain citable; omitted sources are reported rather than
|
||||
retaining a source record for a cut-off excerpt.
|
||||
|
||||
DeerFlow can optionally connect to a tenant-scoped RAGFlow deployment. The
|
||||
`knowledge_search` Agent tool resolves the configured dataset scope, groups
|
||||
datasets by embedding model, and retrieves those groups in parallel so mixed
|
||||
|
||||
@ -44,6 +44,7 @@ from deerflow.agents.middlewares.tool_call_args import ToolCallOccurrence, pair_
|
||||
from deerflow.agents.middlewares.tool_output_synopsis import render_tool_output_preview
|
||||
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY
|
||||
from deerflow.agents.middlewares.tool_transform_meta import append_tool_transform
|
||||
from deerflow.community.ragflow.sources import budget_source_artifact
|
||||
from deerflow.config.tool_output_config import ToolOutputConfig
|
||||
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
||||
|
||||
@ -480,14 +481,24 @@ def _patch_tool_message(
|
||||
config=config,
|
||||
sandbox=sandbox,
|
||||
)
|
||||
if budgeted is None:
|
||||
update: dict[str, Any] = {}
|
||||
trigger = _effective_trigger(tool_name, config)
|
||||
citation_result = None
|
||||
if tool_name in {"knowledge_search", "task"} and trigger > 0 and len(text) > trigger:
|
||||
citation_result = budget_source_artifact(text, msg.artifact, trigger, summary=(budgeted[0] if budgeted else text) if tool_name == "task" else "")
|
||||
if citation_result is not None:
|
||||
replacement, update["artifact"] = citation_result
|
||||
transform_kind = "truncated"
|
||||
elif budgeted is not None:
|
||||
replacement, transform_kind = budgeted
|
||||
else:
|
||||
return msg
|
||||
replacement, transform_kind = budgeted
|
||||
|
||||
update: dict[str, Any] = {"content": replacement}
|
||||
update["content"] = replacement
|
||||
if getattr(msg, "response_metadata", None):
|
||||
update["response_metadata"] = dict(msg.response_metadata)
|
||||
new_kwargs = dict(getattr(msg, "additional_kwargs", None) or {})
|
||||
if citation_result is not None and budgeted is not None and budgeted[1] == "externalized":
|
||||
append_tool_transform(new_kwargs, "externalized", by="ToolOutputBudgetMiddleware")
|
||||
append_tool_transform(new_kwargs, transform_kind, by="ToolOutputBudgetMiddleware")
|
||||
update["additional_kwargs"] = new_kwargs
|
||||
return msg.model_copy(update=update)
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
# RAGFlow citation snapshots
|
||||
|
||||
`knowledge_search_tool` returns native `content_and_artifact`: model-visible
|
||||
`[citation:N](#knowledge-<opaque-id>)` links resolve to bounded
|
||||
`artifact.knowledge_sources` version-one evidence snapshots. Provider locators
|
||||
stay in the artifact; source names and text are credential-redacted in both
|
||||
representations. IDs are unique per retrieval, never per-message ordinal IDs.
|
||||
Only actual emitted entries get source records. Retain the exact excerpt sent
|
||||
to the model and mark truncation; do not fetch a fresh chunk and present it as
|
||||
historical evidence. Direct `knowledge_search()` callers retain its string API.
|
||||
`sources.py` forwards only captured sources cited by ordinary subagent results,
|
||||
with count/text budgets. The source dialog uses stored thread messages and
|
||||
introduces no unauthenticated document proxy. Durable batch result storage and
|
||||
standalone Markdown do not include native source artifacts.
|
||||
|
||||
Output budgeting retains complete evidence entries and their source records
|
||||
together, including delegated results and model-request history. Never shorten
|
||||
an excerpt under an existing ID. Drop entries that cannot fit, with an omission
|
||||
notice, while preserving unrelated artifact fields. This honors per-tool and
|
||||
fallback limits without exempting citation-bearing results from the budget.
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
@ -94,3 +94,78 @@ def format_retrieval_result(
|
||||
return truncation_marker[:max_total_chars]
|
||||
prefix_length = max_total_chars - len(truncation_marker)
|
||||
return f"{formatted[:prefix_length].rstrip()}{truncation_marker}"
|
||||
|
||||
|
||||
def format_retrieval_sources(
|
||||
result: Mapping[str, Any],
|
||||
*,
|
||||
dataset_names_by_id: Mapping[str, str],
|
||||
max_chars_per_chunk: int = 800,
|
||||
max_total_chars: int = 8000,
|
||||
redact: Callable[[object], str] = str,
|
||||
) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Pair model-visible citations with bounded, immutable retrieval snapshots.
|
||||
|
||||
Citation identifiers are independent of provider IDs and unique per call.
|
||||
Only entries actually included in the text receive a source record; the
|
||||
artifact retains the same excerpt the model saw, never an unbounded payload.
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
chunks = result.get("chunks")
|
||||
if not isinstance(chunks, list):
|
||||
return "No relevant content found.", None
|
||||
call_id = uuid4().hex
|
||||
aggregates = _document_aggregates(result.get("doc_aggs"))
|
||||
names = {str(item["doc_id"]): str(item["doc_name"]) for item in aggregates if item.get("doc_id") and item.get("doc_name")}
|
||||
entries: list[str] = []
|
||||
sources: list[dict[str, Any]] = []
|
||||
remaining = max_total_chars
|
||||
for chunk in chunks[:100]:
|
||||
if not isinstance(chunk, Mapping):
|
||||
continue
|
||||
dataset_id = chunk.get("dataset_id")
|
||||
document_id = chunk.get("document_id")
|
||||
chunk_id = chunk.get("id")
|
||||
# Incomplete or out-of-scope locators must not become verified sources.
|
||||
if not all(isinstance(value, str) and value and len(value) <= 256 for value in (dataset_id, document_id, chunk_id)):
|
||||
continue
|
||||
if dataset_id not in dataset_names_by_id:
|
||||
continue
|
||||
source_id = f"{call_id}-{len(sources) + 1}"
|
||||
dataset_name = redact(dataset_names_by_id[dataset_id])[:512]
|
||||
document_name = redact(str(chunk.get("document_keyword") or names.get(document_id) or "Unknown document"))[:512]
|
||||
# Keep untrusted names outside the Markdown label to avoid link injection.
|
||||
header = f"[citation:{len(sources) + 1}](#knowledge-{source_id}) {dataset_name} / {document_name}\n"
|
||||
text = redact(str(chunk.get("content") or "").strip())
|
||||
allowance = min(max_chars_per_chunk, remaining - len(header) - (2 if entries else 0))
|
||||
if allowance < 1:
|
||||
break
|
||||
excerpt = _truncate(text, allowance)
|
||||
entry = header + excerpt
|
||||
entries.append(entry)
|
||||
remaining -= len(entry) + (2 if len(entries) > 1 else 0)
|
||||
positions = chunk.get("positions")
|
||||
pages = (
|
||||
sorted({position[0] for position in positions[:100] if isinstance(position, (list, tuple)) and position and isinstance(position[0], int) and not isinstance(position[0], bool) and 1 <= position[0] <= 1_000_000})
|
||||
if isinstance(positions, list)
|
||||
else []
|
||||
)
|
||||
sources.append(
|
||||
{
|
||||
"id": source_id,
|
||||
"provider": "ragflow",
|
||||
"dataset_id": redact(dataset_id),
|
||||
"document_id": redact(document_id),
|
||||
"chunk_id": redact(chunk_id),
|
||||
"dataset_name": dataset_name,
|
||||
"document_name": document_name,
|
||||
"text": excerpt,
|
||||
"truncated": len(excerpt) < len(text),
|
||||
"pages": pages,
|
||||
}
|
||||
)
|
||||
if not sources:
|
||||
# Legacy/incomplete provider responses still yield useful readable text.
|
||||
return redact(format_retrieval_result(result, dataset_names_by_id=dataset_names_by_id, max_chars_per_chunk=max_chars_per_chunk, max_total_chars=max_total_chars)), None
|
||||
return "\n\n".join(entries), {"knowledge_sources": {"version": 1, "sources": sources}}
|
||||
|
||||
101
backend/packages/harness/deerflow/community/ragflow/sources.py
Normal file
101
backend/packages/harness/deerflow/community/ragflow/sources.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""Bounded source-artifact forwarding across ordinary subagent results."""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def budget_source_artifact(content: str, artifact: object, max_chars: int, *, summary: str = "") -> tuple[str, dict[str, Any] | None] | None:
|
||||
"""Keep whole evidence records and their links together within a tool budget.
|
||||
|
||||
Never shorten an excerpt under its existing source ID: those IDs also occur
|
||||
in persisted child messages. Oversized records are omitted atomically, and
|
||||
unrelated artifact fields survive. A task result can retain a short synopsis
|
||||
before the evidence; its old knowledge links are replaced by retained ones.
|
||||
"""
|
||||
if not isinstance(artifact, dict) or max_chars <= 0:
|
||||
return None
|
||||
payload = artifact.get("knowledge_sources")
|
||||
if not isinstance(payload, dict) or payload.get("version") != 1 or not isinstance(payload.get("sources"), list):
|
||||
return None
|
||||
sources = []
|
||||
seen = set()
|
||||
for source in payload["sources"][:100]:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
source_id = source.get("id")
|
||||
if not isinstance(source_id, str) or not re.fullmatch(r"[a-f0-9]{32}-[1-9][0-9]{0,2}", source_id):
|
||||
continue
|
||||
if source_id in seen or f"](#knowledge-{source_id})" not in content:
|
||||
continue
|
||||
if source.get("provider") != "ragflow" or not all(isinstance(source.get(field), str) for field in ("text", "dataset_name", "document_name")):
|
||||
continue
|
||||
seen.add(source_id)
|
||||
sources.append(source)
|
||||
if not sources:
|
||||
return None
|
||||
|
||||
notice = "Knowledge sources omitted to fit output budget; request smaller excerpts."
|
||||
# Remove old destinations before shortening the report, including any link
|
||||
# fragments left by the generic synopsis/truncation transform.
|
||||
summary = re.sub(r"\[([^\]\n]*)\]\(#(?:user-content-)?knowledge-[^)\s]*\)", r"\1", summary)
|
||||
summary = re.sub(r"#(?:user-content-)?knowledge-[\w-]*", "", summary)
|
||||
summary_limit = min(1000, max_chars // 4) if max_chars >= 160 else 0
|
||||
if not summary_limit:
|
||||
summary = ""
|
||||
elif len(summary) > summary_limit:
|
||||
# Keep the synopsis tail too: it can contain the read_file reference
|
||||
# for the full externalized task report.
|
||||
head = (summary_limit - 3) // 2
|
||||
summary = summary[:head] + "\n…\n" + summary[-(summary_limit - head - 3) :]
|
||||
summary = summary.rstrip()
|
||||
entries = [summary] if summary else []
|
||||
used = len(summary)
|
||||
retained = []
|
||||
for source in sources:
|
||||
entry = f"[citation:{len(retained) + 1}](#knowledge-{source['id']}) {source['dataset_name']} / {source['document_name']}\n{source['text']}"
|
||||
cost = len(entry) + (2 if entries else 0)
|
||||
# Reserve a complete omission notice; never emit a partial source link.
|
||||
if used + cost + len(notice) + 2 > max_chars:
|
||||
continue
|
||||
entries.append(entry)
|
||||
used += cost
|
||||
retained.append(source)
|
||||
if len(retained) != len(sources):
|
||||
entries.append(notice[:max_chars])
|
||||
updated = dict(artifact)
|
||||
if retained:
|
||||
updated["knowledge_sources"] = {**payload, "sources": retained}
|
||||
else:
|
||||
updated.pop("knowledge_sources", None)
|
||||
return "\n\n".join(entries), updated or None
|
||||
|
||||
|
||||
def cited_source_artifact(messages: list[dict[str, Any]], content: str) -> dict[str, Any] | None:
|
||||
"""Carry only actual captured sources cited in the child's final result."""
|
||||
sources: dict[str, dict[str, Any]] = {}
|
||||
remaining = 1_000_000
|
||||
for message in messages:
|
||||
if message.get("type") != "tool" or message.get("name") not in {"knowledge_search", "task"}:
|
||||
continue
|
||||
artifact = message.get("artifact")
|
||||
payload = artifact.get("knowledge_sources") if isinstance(artifact, Mapping) else None
|
||||
if not isinstance(payload, Mapping) or payload.get("version") != 1:
|
||||
continue
|
||||
raw_sources = payload.get("sources")
|
||||
if not isinstance(raw_sources, list):
|
||||
continue
|
||||
for source in raw_sources[:100]:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
source_id = source.get("id")
|
||||
text = source.get("text")
|
||||
if not isinstance(source_id, str) or not isinstance(text, str) or f"](#knowledge-{source_id})" not in content:
|
||||
continue
|
||||
if source_id in sources:
|
||||
continue
|
||||
if len(sources) >= 100 or len(text) > remaining:
|
||||
continue
|
||||
sources[source_id] = dict(source)
|
||||
remaining -= len(text)
|
||||
return {"knowledge_sources": {"version": 1, "sources": list(sources.values())}} if sources else None
|
||||
@ -21,7 +21,7 @@ from deerflow.knowledge_scope import (
|
||||
from deerflow.tools.types import Runtime
|
||||
|
||||
from .client import RAGFlowAPIError, RAGFlowClient, RAGFlowConnectionError, RAGFlowProtocolError
|
||||
from .formatting import format_retrieval_result
|
||||
from .formatting import format_retrieval_result, format_retrieval_sources
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -516,6 +516,7 @@ async def knowledge_search(
|
||||
*,
|
||||
knowledge_scope: object | None = None,
|
||||
runtime: Runtime | None = None,
|
||||
_source_artifact: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Search the configured RAGFlow scope, defaulting to every accessible dataset."""
|
||||
query = query.strip()
|
||||
@ -564,6 +565,17 @@ async def knowledge_search(
|
||||
|
||||
result = await _retrieve_dataset_groups(client, settings, query, groups)
|
||||
names_by_id = {dataset.dataset_id: dataset.name for dataset in datasets}
|
||||
if _source_artifact is not None:
|
||||
content, artifact = format_retrieval_sources(
|
||||
result,
|
||||
dataset_names_by_id=names_by_id,
|
||||
max_chars_per_chunk=settings.max_chars_per_chunk,
|
||||
max_total_chars=settings.max_total_chars,
|
||||
redact=lambda value: _redact_api_key(value, _api_key(settings)),
|
||||
)
|
||||
if artifact is not None:
|
||||
_source_artifact.update(artifact)
|
||||
return content
|
||||
formatted = format_retrieval_result(
|
||||
result,
|
||||
dataset_names_by_id=names_by_id,
|
||||
@ -600,21 +612,28 @@ async def list_knowledge_bases() -> str:
|
||||
|
||||
def _tool_description() -> str:
|
||||
base = "Search the operator-approved RAGFlow datasets and return compact, citation-numbered source chunks."
|
||||
return f"{base} If knowledge_search.datasets is omitted, all datasets accessible to the configured RAGFlow API key are searched. Dataset IDs are never shown to the model."
|
||||
return (
|
||||
f"{base} If knowledge_search.datasets is omitted, all datasets accessible to the configured RAGFlow API key are searched. "
|
||||
"Dataset IDs are never shown to the model. When citing results, copy the supplied [citation:N](#knowledge-...) links exactly; "
|
||||
"do not invent or renumber source links."
|
||||
)
|
||||
|
||||
|
||||
async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> str:
|
||||
async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Search the configured RAGFlow datasets, or every accessible dataset by default.
|
||||
|
||||
Args:
|
||||
query: Specific question or search terms to retrieve from the configured private documents.
|
||||
"""
|
||||
return await knowledge_search(query, runtime=runtime)
|
||||
artifact: dict[str, Any] = {}
|
||||
content = await knowledge_search(query, runtime=runtime, _source_artifact=artifact)
|
||||
return content, artifact or None
|
||||
|
||||
|
||||
knowledge_search_tool = StructuredTool.from_function(
|
||||
coroutine=_knowledge_search_entrypoint,
|
||||
name="knowledge_search",
|
||||
response_format="content_and_artifact",
|
||||
description=_tool_description(),
|
||||
parse_docstring=True,
|
||||
)
|
||||
|
||||
@ -54,3 +54,9 @@ E2B output sync records remote file versions and actual host file metadata in a
|
||||
- ACP results collect only `agent_message_chunk` text. Thought chunks remain internal and must not be concatenated into the tool result
|
||||
- Missing ACP executables now return an actionable error message instead of a raw `[Errno 2]`
|
||||
- Each ACP agent uses a per-thread workspace at `{base_dir}/users/{user_id}/threads/{thread_id}/acp-workspace/`. The workspace is accessible to the lead agent via the virtual path `/mnt/acp-workspace/` (read-only). In docker sandbox mode, the directory is volume-mounted into the container at `/mnt/acp-workspace` (read-only); in local sandbox mode, path translation is handled by `tools.py`
|
||||
|
||||
Ordinary `task` results forward bounded `artifact.knowledge_sources` records
|
||||
from captured child tool messages only when the final/partial report cites those
|
||||
opaque source links. This preserves retrieval evidence across the delegation
|
||||
boundary without placing provider IDs in model-visible text. Never reconstruct
|
||||
source records from the child's prose or replace them with fresh provider reads.
|
||||
|
||||
@ -19,6 +19,7 @@ from langgraph.types import Command
|
||||
|
||||
from deerflow.agents.middlewares.receipt_verification import verify_receipt_citations
|
||||
from deerflow.authz.principal import normalize_authz_attributes
|
||||
from deerflow.community.ragflow.sources import cited_source_artifact
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.extensions import resolve_run_extensions
|
||||
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_RUNTIME_KEY, execution_scope
|
||||
@ -616,6 +617,7 @@ def _task_result_command(
|
||||
model_name: str | None = None,
|
||||
usage: dict[str, int] | None = None,
|
||||
tool_receipts: list[dict] | None = None,
|
||||
source_messages: list[dict] | None = None,
|
||||
receipt_verdict: dict | None = None,
|
||||
acceptance_verdict: dict | None = None,
|
||||
) -> Command:
|
||||
@ -631,6 +633,7 @@ def _task_result_command(
|
||||
content=content,
|
||||
tool_call_id=tool_call_id,
|
||||
name="task",
|
||||
artifact=cited_source_artifact(source_messages or [], content),
|
||||
additional_kwargs=make_subagent_additional_kwargs(
|
||||
status,
|
||||
result=result,
|
||||
@ -1089,6 +1092,7 @@ async def task_tool(
|
||||
model_name=effective_model,
|
||||
usage=usage,
|
||||
tool_receipts=receipts,
|
||||
source_messages=getattr(result, "ai_messages", None),
|
||||
receipt_verdict=receipt_verdict,
|
||||
acceptance_verdict=acceptance_verdict,
|
||||
)
|
||||
@ -1117,6 +1121,7 @@ async def task_tool(
|
||||
model_name=effective_model,
|
||||
usage=usage,
|
||||
tool_receipts=getattr(result, "tool_receipts", None),
|
||||
source_messages=getattr(result, "ai_messages", None),
|
||||
)
|
||||
elif result.status == SubagentStatus.CANCELLED:
|
||||
_report_subagent_usage(runtime, result)
|
||||
@ -1139,6 +1144,7 @@ async def task_tool(
|
||||
model_name=effective_model,
|
||||
usage=usage,
|
||||
tool_receipts=getattr(result, "tool_receipts", None),
|
||||
source_messages=getattr(result, "ai_messages", None),
|
||||
)
|
||||
elif result.status == SubagentStatus.TIMED_OUT:
|
||||
_report_subagent_usage(runtime, result)
|
||||
@ -1161,6 +1167,7 @@ async def task_tool(
|
||||
model_name=effective_model,
|
||||
usage=usage,
|
||||
tool_receipts=getattr(result, "tool_receipts", None),
|
||||
source_messages=getattr(result, "ai_messages", None),
|
||||
)
|
||||
|
||||
# Still running, wait before next poll
|
||||
@ -1197,6 +1204,7 @@ async def task_tool(
|
||||
model_name=effective_model,
|
||||
usage=usage,
|
||||
tool_receipts=getattr(result, "tool_receipts", None),
|
||||
source_messages=getattr(result, "ai_messages", None),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Signal the background subagent thread to stop cooperatively, then
|
||||
|
||||
@ -18,6 +18,7 @@ EXPECTED_GUIDANCE_PATHS = {
|
||||
"backend/packages/harness/deerflow/agents/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/agents/middlewares/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/agents/memory/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/community/ragflow/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/community/tavily/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/config/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/extensions/AGENTS.md",
|
||||
|
||||
127
backend/tests/test_knowledge_citation_budget.py
Normal file
127
backend/tests/test_knowledge_citation_budget.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Citation records and model-visible evidence must stay paired after budgeting."""
|
||||
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware, _patch_model_messages, _patch_result
|
||||
from deerflow.community.ragflow.formatting import format_retrieval_sources
|
||||
from deerflow.community.ragflow.sources import budget_source_artifact
|
||||
from deerflow.config.tool_output_config import ToolOutputConfig
|
||||
|
||||
|
||||
def message(name="knowledge_search"):
|
||||
chunks = [{"id": f"chunk-{i}", "dataset_id": "kb", "document_id": "doc", "document_keyword": "Manual.pdf", "content": f"Evidence {i}: " + "x" * 4800} for i in range(8)]
|
||||
content, artifact = format_retrieval_sources({"chunks": chunks}, dataset_names_by_id={"kb": "Engineering"}, max_chars_per_chunk=5000, max_total_chars=40000)
|
||||
if name == "task":
|
||||
links = " ".join(f"[Manual.pdf](#knowledge-{source['id']})" for source in artifact["knowledge_sources"]["sources"])
|
||||
content = "Task completed. Findings: " + links + "\n" + "report " * 6000
|
||||
artifact["other"] = "preserve me"
|
||||
return ToolMessage(content=content, artifact=artifact, name=name, tool_call_id="call-1")
|
||||
|
||||
|
||||
def assert_paired(original, result, limit):
|
||||
assert len(result.content) <= limit
|
||||
assert result.artifact["other"] == "preserve me"
|
||||
sources = result.artifact.get("knowledge_sources", {}).get("sources", [])
|
||||
assert sources
|
||||
assert len(sources) < len(original.artifact["knowledge_sources"]["sources"])
|
||||
for source in original.artifact["knowledge_sources"]["sources"]:
|
||||
if source in sources:
|
||||
assert f"](#knowledge-{source['id']})" in result.content
|
||||
assert source["text"] in result.content
|
||||
else:
|
||||
assert f"#knowledge-{source['id']}" not in result.content
|
||||
assert "omitted" in result.content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["knowledge_search", "task"])
|
||||
@pytest.mark.parametrize("mode", ["externalize", "fallback", "override", "history", "no_storage"])
|
||||
def test_complete_citations_survive_budget_paths(tmp_path, name, mode):
|
||||
original = message(name)
|
||||
snapshot = deepcopy(original)
|
||||
limit = 12000 if mode == "externalize" else 7000
|
||||
config = ToolOutputConfig(**({"fallback_max_chars": limit, "externalize_min_chars": 0} if mode in {"fallback", "history"} else {"tool_overrides": {name: limit}}))
|
||||
if mode == "history":
|
||||
result = _patch_model_messages([original], config)[0]
|
||||
else:
|
||||
command = Command(update={"messages": [original], "unrelated": 42})
|
||||
patched = _patch_result(command, config, str(tmp_path) if mode not in {"fallback", "no_storage"} else None)
|
||||
assert patched.update["unrelated"] == 42
|
||||
result = patched.update["messages"][0]
|
||||
assert_paired(original, result, limit)
|
||||
assert original == snapshot
|
||||
transforms = result.additional_kwargs["deerflow_tool_transforms"]
|
||||
assert transforms[-1]["kind"] == "truncated"
|
||||
if mode in {"externalize", "override"}:
|
||||
assert transforms[-2]["kind"] == "externalized"
|
||||
# A subsequent model hook must not rewrite the same evidence again.
|
||||
assert _patch_model_messages([result], config) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["knowledge_search", "task"])
|
||||
@pytest.mark.parametrize("limit", [1, 60, 159, 160, 500])
|
||||
def test_tiny_budget_does_not_leave_orphaned_or_partial_citations(name, limit):
|
||||
original = message(name)
|
||||
config = ToolOutputConfig(externalize_min_chars=0, fallback_max_chars=limit)
|
||||
result = _patch_result(original, config, None)
|
||||
assert len(result.content) <= limit
|
||||
assert "#knowledge-" not in result.content
|
||||
assert "knowledge_sources" not in result.artifact
|
||||
assert result.artifact["other"] == "preserve me"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config", [ToolOutputConfig(enabled=False), ToolOutputConfig(exempt_tools=["knowledge_search"]), ToolOutputConfig(externalize_min_chars=0, fallback_max_chars=0)])
|
||||
def test_disabled_and_exempt_budget_preserves_sources(config):
|
||||
original = message()
|
||||
request = SimpleNamespace(tool_call={"name": "knowledge_search", "id": "call-1"}, runtime=SimpleNamespace(state={}))
|
||||
result = ToolOutputBudgetMiddleware(config).wrap_tool_call(request, lambda _: original)
|
||||
assert result is original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_tool_hook_preserves_sources():
|
||||
original = message()
|
||||
request = SimpleNamespace(tool_call={"name": "knowledge_search", "id": "call-1"}, runtime=SimpleNamespace(state={}))
|
||||
|
||||
async def handler(_):
|
||||
return original
|
||||
|
||||
result = await ToolOutputBudgetMiddleware(ToolOutputConfig(externalize_min_chars=0, fallback_max_chars=7000)).awrap_tool_call(request, handler)
|
||||
assert_paired(original, result, 7000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["other_tool", "unknown_version", "malformed_records", "no_artifact"])
|
||||
def test_unrelated_results_keep_the_generic_budget_behavior(change):
|
||||
original = message()
|
||||
if change == "other_tool":
|
||||
original.name = "web_search"
|
||||
elif change == "unknown_version":
|
||||
original.artifact["knowledge_sources"]["version"] = 2
|
||||
elif change == "malformed_records":
|
||||
original.artifact["knowledge_sources"]["sources"] = [None, {"id": "invalid", "text": 42}]
|
||||
else:
|
||||
original.artifact = None
|
||||
config = ToolOutputConfig(externalize_min_chars=0, fallback_max_chars=7000)
|
||||
control = _patch_result(original.model_copy(update={"artifact": None}), config, None)
|
||||
result = _patch_result(original, config, None)
|
||||
assert result.content == control.content
|
||||
assert result.artifact == original.artifact
|
||||
|
||||
|
||||
def test_small_source_result_is_not_rewritten():
|
||||
original = message()
|
||||
config = ToolOutputConfig(externalize_min_chars=50000, fallback_max_chars=50000)
|
||||
assert _patch_result(original, config, None) is original
|
||||
|
||||
|
||||
def test_delegated_budget_retains_the_report_reference_and_complete_evidence():
|
||||
original = message("task")
|
||||
summary = "Task completed. " + "synopsis " * 500 + "\nRead the full report: /mnt/user-data/outputs/.tool-results/report.txt"
|
||||
content, artifact = budget_source_artifact(original.content, original.artifact, 7000, summary=summary)
|
||||
assert content.startswith("Task completed.")
|
||||
assert "/mnt/user-data/outputs/.tool-results/report.txt" in content
|
||||
assert_paired(original, original.model_copy(update={"content": content, "artifact": artifact}), 7000)
|
||||
@ -1065,3 +1065,98 @@ def test_ragflow_package_has_explicit_init_file() -> None:
|
||||
package_dir = Path(ragflow_tools.__file__).resolve().parent
|
||||
|
||||
assert (package_dir / "__init__.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_artifact_binds_citation_to_exact_retrieved_document(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = FakeRAGFlowClient(
|
||||
all_datasets=[_dataset(DATASET_ID_1, "Engineering")],
|
||||
retrieval={"chunks": [{"id": "chunk-a", "dataset_id": DATASET_ID_1, "document_id": "doc-a", "document_keyword": "Manual.pdf", "content": "The limit is 42.", "positions": [[3, 10, 20, 30, 40]]}]},
|
||||
)
|
||||
monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config())
|
||||
monkeypatch.setattr(ragflow_tools, "_build_client", lambda _: client)
|
||||
runtime = SimpleNamespace(context={})
|
||||
content, artifact = await ragflow_tools._knowledge_search_entrypoint("limit", runtime)
|
||||
source = artifact["knowledge_sources"]["sources"][0]
|
||||
assert source["document_id"] == "doc-a"
|
||||
assert source["chunk_id"] == "chunk-a"
|
||||
assert source["document_name"] == "Manual.pdf"
|
||||
assert source["text"] == "The limit is 42."
|
||||
assert source["pages"] == [3]
|
||||
assert f"](#knowledge-{source['id']})" in content
|
||||
assert DATASET_ID_1 not in content
|
||||
assert "doc-a" not in content
|
||||
_, next_artifact = await ragflow_tools._knowledge_search_entrypoint("limit", runtime)
|
||||
assert next_artifact["knowledge_sources"]["sources"][0]["id"] != source["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_artifact_redacts_credentials_and_has_no_sources_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config())
|
||||
client = FakeRAGFlowClient(
|
||||
all_datasets=[_dataset(DATASET_ID_1, "ragflow-secret")],
|
||||
retrieval={"chunks": [{"id": "chunk-a", "dataset_id": DATASET_ID_1, "document_id": "doc-a", "document_keyword": "ragflow-secret", "content": "ragflow-secret"}]},
|
||||
)
|
||||
monkeypatch.setattr(ragflow_tools, "_build_client", lambda _: client)
|
||||
result = await ragflow_tools._knowledge_search_entrypoint("query", SimpleNamespace(context={}))
|
||||
assert "ragflow-secret" not in str(result)
|
||||
content, artifact = await ragflow_tools._knowledge_search_entrypoint("", SimpleNamespace(context={}))
|
||||
assert content.startswith("Error:")
|
||||
assert artifact is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_citation_artifact_survives_native_tool_node_and_message_serialization(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from langchain_core.messages import AIMessage, messages_from_dict, messages_to_dict
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
client = FakeRAGFlowClient(
|
||||
all_datasets=[_dataset(DATASET_ID_1, "Engineering")],
|
||||
retrieval={"chunks": [{"id": "chunk-a", "dataset_id": DATASET_ID_1, "document_id": "doc-a", "document_keyword": "Manual.pdf", "content": "Limit: 42."}]},
|
||||
)
|
||||
monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config())
|
||||
monkeypatch.setattr(ragflow_tools, "_build_client", lambda _: client)
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("tools", ToolNode([ragflow_tools.knowledge_search_tool]))
|
||||
builder.add_edge(START, "tools")
|
||||
builder.add_edge("tools", END)
|
||||
result = await builder.compile().ainvoke({"messages": [AIMessage(content="", tool_calls=[{"name": "knowledge_search", "args": {"query": "limit"}, "id": "call-a"}])]})
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.status == "success"
|
||||
assert tool_message.artifact["knowledge_sources"]["sources"][0]["document_id"] == "doc-a"
|
||||
restored = messages_from_dict(messages_to_dict([tool_message]))[0]
|
||||
assert restored.artifact == tool_message.artifact
|
||||
assert restored.content == tool_message.content
|
||||
|
||||
|
||||
def test_subagent_forwards_only_cited_captured_sources() -> None:
|
||||
from deerflow.community.ragflow.sources import cited_source_artifact
|
||||
|
||||
sources = [{"id": "a", "text": "first"}, {"id": "b", "text": "second"}]
|
||||
messages = [{"type": "tool", "name": "knowledge_search", "artifact": {"knowledge_sources": {"version": 1, "sources": sources}}}]
|
||||
assert cited_source_artifact(messages, "[citation:1](#knowledge-a)") == {"knowledge_sources": {"version": 1, "sources": [sources[0]]}}
|
||||
assert cited_source_artifact(messages, "[citation:3](#knowledge-invented)") is None
|
||||
assert cited_source_artifact([{**messages[0], "type": "ai"}], "[citation:1](#knowledge-a)") is None
|
||||
|
||||
|
||||
def test_citation_budget_never_emits_partial_links_or_unseen_artifact_text() -> None:
|
||||
from deerflow.community.ragflow.formatting import format_retrieval_sources
|
||||
|
||||
chunks = [{"id": f"chunk-{i}", "dataset_id": DATASET_ID_1, "document_id": "doc", "content": "X" * 1000} for i in range(4)]
|
||||
content, artifact = format_retrieval_sources({"chunks": chunks}, dataset_names_by_id={DATASET_ID_1: "Knowledge"}, max_total_chars=200, max_chars_per_chunk=100)
|
||||
assert len(content) <= 200
|
||||
sources = artifact["knowledge_sources"]["sources"]
|
||||
assert len(sources) == 1
|
||||
assert f"](#knowledge-{sources[0]['id']})" in content
|
||||
assert sources[0]["text"] in content
|
||||
assert sources[0]["truncated"] is True
|
||||
|
||||
|
||||
def test_task_command_preserves_child_source_artifact() -> None:
|
||||
from deerflow.tools.builtins.task_tool import _task_result_command
|
||||
|
||||
source = {"id": "abc", "text": "evidence"}
|
||||
message = {"type": "tool", "name": "knowledge_search", "artifact": {"knowledge_sources": {"version": 1, "sources": [source]}}}
|
||||
command = _task_result_command(tool_call_id="task-1", status="completed", result="Answer [citation:1](#knowledge-abc)", source_messages=[message])
|
||||
assert command.update["messages"][0].artifact["knowledge_sources"]["sources"] == [source]
|
||||
|
||||
BIN
docs/pr-evidence/knowledge-citations-desktop.png
Normal file
BIN
docs/pr-evidence/knowledge-citations-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/pr-evidence/knowledge-citations-mobile.png
Normal file
BIN
docs/pr-evidence/knowledge-citations-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@ -190,3 +190,15 @@ association and usage accounting, but renders text accompanying
|
||||
message grouping. Incremental prefix/tail splitting applies only at human
|
||||
boundaries; clarification results also belong to the preceding processing group,
|
||||
so derive the full grouping and stabilize references at clarification boundaries.
|
||||
|
||||
### Knowledge source citations
|
||||
|
||||
`KnowledgeSourcesProvider` scopes source records to the current message list.
|
||||
Only versioned native `knowledge_search`/`task` tool artifacts supply evidence;
|
||||
AI/human text and metadata cannot create a source. `CitationLink` resolves
|
||||
`#knowledge-…` citations through that context and renders unavailable text when
|
||||
there is no matching record. `KnowledgeSourcesPanel` lists only sources cited
|
||||
outside code/images. Dialog excerpts render as plain text, never HTML or nested
|
||||
Markdown. Source records retain retrieval-time evidence, not live documents.
|
||||
Resolve knowledge destinations before testing the label in message and artifact
|
||||
link renderers: Sources lists use ordinary titles without a `citation:` prefix.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
|
||||
import { knowledgeSourceId } from "@/core/knowledge/sources";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { isSafeHref, UnsafeLink } from "../messages/markdown-link";
|
||||
@ -10,7 +11,7 @@ function isExternalUrl(href: string | undefined): boolean {
|
||||
return !!href && /^https?:\/\//.test(href);
|
||||
}
|
||||
|
||||
/** Link renderer for artifact markdown: citation: prefix → CitationLink, otherwise underlined text. */
|
||||
/** Knowledge destinations and citation-prefixed links use the source renderer. */
|
||||
export function ArtifactLink(props: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
// Reject unsafe schemes so prompt-injected [label](javascript:...) in a .md
|
||||
// artifact preview cannot execute in the main document, matching the guard in
|
||||
@ -26,6 +27,9 @@ export function ArtifactLink(props: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
</UnsafeLink>
|
||||
);
|
||||
}
|
||||
if (knowledgeSourceId(props.href)) {
|
||||
return <CitationLink {...props} />;
|
||||
}
|
||||
const childrenText = extractReactNodeText(props.children);
|
||||
if (childrenText !== null) {
|
||||
const match = /^citation:(.+)$/.exec(childrenText);
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { KnowledgeCitationLink } from "./knowledge-source";
|
||||
|
||||
/** Extract visible text from renderer-provided ReactNode children. */
|
||||
export function extractReactNodeText(node: ReactNode): string | null {
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
@ -41,6 +43,12 @@ export function CitationLink({
|
||||
const isGenericText = childrenText === "Source" || childrenText === "来源";
|
||||
const displayText = (!isGenericText && childrenText) ?? domain;
|
||||
|
||||
if (href && /^#(?:user-content-)?knowledge-/.test(href)) {
|
||||
return (
|
||||
<KnowledgeCitationLink href={href}>{displayText}</KnowledgeCitationLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard closeDelay={0} openDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
|
||||
153
frontend/src/components/workspace/citations/knowledge-source.tsx
Normal file
153
frontend/src/components/workspace/citations/knowledge-source.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { BookOpenTextIcon } from "lucide-react";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
citedKnowledgeSources,
|
||||
collectKnowledgeSources,
|
||||
knowledgeSourceId,
|
||||
type KnowledgeSource,
|
||||
} from "@/core/knowledge/sources";
|
||||
|
||||
const SourcesContext = createContext<ReadonlyMap<string, KnowledgeSource>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
export function KnowledgeSourcesProvider({
|
||||
messages,
|
||||
children,
|
||||
}: {
|
||||
messages: readonly Message[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const sources = useMemo(() => collectKnowledgeSources(messages), [messages]);
|
||||
return (
|
||||
<SourcesContext.Provider value={sources}>
|
||||
{children}
|
||||
</SourcesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDialog({
|
||||
source,
|
||||
children,
|
||||
}: {
|
||||
source: KnowledgeSource;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left"
|
||||
aria-label={t.citations.viewKnowledgeSource(source.document_name)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="break-words">
|
||||
{source.document_name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{source.dataset_name}
|
||||
{source.pages.length > 0
|
||||
? ` · ${t.citations.sourcePages(source.pages.join(", "))}`
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t.citations.retrievedExcerpt}
|
||||
</p>
|
||||
<blockquote className="border-primary/30 border-l-2 pl-4 text-sm leading-relaxed break-words whitespace-pre-wrap">
|
||||
{source.text}
|
||||
</blockquote>
|
||||
{source.truncated && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t.citations.excerptTruncated}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function KnowledgeCitationLink({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const sources = useContext(SourcesContext);
|
||||
const { t } = useI18n();
|
||||
const id = knowledgeSourceId(href);
|
||||
const source = id ? sources.get(id) : undefined;
|
||||
if (!source)
|
||||
return (
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
title={t.citations.sourceUnavailable}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<SourceDialog source={source}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
|
||||
>
|
||||
<BookOpenTextIcon className="size-3" />
|
||||
{children}
|
||||
</Badge>
|
||||
</SourceDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function KnowledgeSourcesPanel({ content }: { content: string }) {
|
||||
const allSources = useContext(SourcesContext);
|
||||
const { t } = useI18n();
|
||||
const sources = useMemo(
|
||||
() => citedKnowledgeSources(content, allSources),
|
||||
[content, allSources],
|
||||
);
|
||||
if (sources.length === 0) return null;
|
||||
return (
|
||||
<details className="not-prose border-border/60 bg-muted/20 mt-2 rounded-md border text-xs">
|
||||
<summary className="text-muted-foreground cursor-pointer px-3 py-2">
|
||||
{t.citations.knowledgeSourcesSummary(sources.length)}
|
||||
</summary>
|
||||
<ul className="border-border/60 max-h-80 space-y-2 overflow-y-auto border-t p-3">
|
||||
{sources.map((source) => (
|
||||
<li key={source.id}>
|
||||
<SourceDialog source={source}>
|
||||
<span className="text-foreground block font-medium break-words">
|
||||
{source.document_name}
|
||||
</span>
|
||||
<span className="text-muted-foreground block break-words">
|
||||
{source.dataset_name}
|
||||
</span>
|
||||
</SourceDialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AnchorHTMLAttributes, ComponentProps } from "react";
|
||||
|
||||
import { resolveMarkdownArtifactURL } from "@/core/artifacts/utils";
|
||||
import { knowledgeSourceId } from "@/core/knowledge/sources";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink, extractReactNodeText } from "../citations/citation-link";
|
||||
@ -107,6 +108,10 @@ export function createMarkdownLinkComponent(threadId?: string) {
|
||||
</UnsafeLink>
|
||||
);
|
||||
}
|
||||
// Knowledge destinations also appear as ordinary [Title](URL) Sources links.
|
||||
if (knowledgeSourceId(href)) {
|
||||
return <CitationLink {...props} href={href} />;
|
||||
}
|
||||
// Safe-href check passed — citation links now route through CitationLink.
|
||||
const childrenText = extractReactNodeText(props.children);
|
||||
if (childrenText !== null) {
|
||||
|
||||
@ -63,6 +63,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { WorkspaceChangeBadge } from "../changes";
|
||||
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||
import { KnowledgeSourcesPanel } from "../citations/knowledge-source";
|
||||
import { ConversationReferenceChip } from "../conversation-references/conversation-reference-chip";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
|
||||
@ -618,6 +619,7 @@ function MessageContent_({
|
||||
components={components}
|
||||
/>
|
||||
<CitationSourcesPanel sources={citationSources} />
|
||||
<KnowledgeSourcesPanel content={contentToDisplay} />
|
||||
{message.type === "ai" && showWorkspaceChanges && (
|
||||
<WorkspaceChangeBadge
|
||||
threadId={threadId}
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
type ConversationProps,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { KnowledgeSourcesProvider } from "@/components/workspace/citations/knowledge-source";
|
||||
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { getArtifactArchiveCandidatesByGroupIndex } from "@/core/messages/artifact-archive";
|
||||
@ -1078,7 +1079,7 @@ export function MessageList({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<KnowledgeSourcesProvider messages={thread.messages}>
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
data-testid={testId}
|
||||
@ -1504,6 +1505,6 @@ export function MessageList({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</KnowledgeSourcesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export function extractCitationSources(markdown: string): CitationSource[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchable = maskCode(markdown);
|
||||
const searchable = maskCitationCode(markdown);
|
||||
const sourcesByUrl = new Map<string, CitationSource>();
|
||||
|
||||
for (const match of searchable.matchAll(CITATION_LINK_RE)) {
|
||||
@ -99,7 +99,7 @@ function extractDomain(url: string): string {
|
||||
// Blanks out code regions so example citations inside code aren't scraped as
|
||||
// real sources, while preserving string length (and newlines) so occurrence
|
||||
// indices stay aligned with the original markdown.
|
||||
function maskCode(markdown: string): string {
|
||||
export function maskCitationCode(markdown: string): string {
|
||||
return maskInlineCode(maskFencedCodeBlocks(markdown));
|
||||
}
|
||||
|
||||
|
||||
@ -218,6 +218,14 @@ export const enUS: Translations = {
|
||||
|
||||
// Citations
|
||||
citations: {
|
||||
viewKnowledgeSource: (title: string) => `View source: ${title}`,
|
||||
sourcePages: (pages: string) => `Pages ${pages}`,
|
||||
retrievedExcerpt:
|
||||
"Evidence captured when this answer was researched. The source document may have changed since retrieval.",
|
||||
excerptTruncated: "This excerpt was shortened to fit the retrieval limit.",
|
||||
sourceUnavailable:
|
||||
"Source evidence is unavailable in the loaded conversation.",
|
||||
knowledgeSourcesSummary: (count: number) => `${count} knowledge sources`,
|
||||
sourcesSummary: (count) =>
|
||||
`Used ${count} ${count === 1 ? "source" : "sources"}`,
|
||||
citeCount: (count) => `${count} ${count === 1 ? "cite" : "cites"}`,
|
||||
|
||||
@ -185,6 +185,12 @@ export interface Translations {
|
||||
|
||||
// Citations
|
||||
citations: {
|
||||
viewKnowledgeSource: (title: string) => string;
|
||||
sourcePages: (pages: string) => string;
|
||||
retrievedExcerpt: string;
|
||||
excerptTruncated: string;
|
||||
sourceUnavailable: string;
|
||||
knowledgeSourcesSummary: (count: number) => string;
|
||||
sourcesSummary: (count: number) => string;
|
||||
citeCount: (count: number) => string;
|
||||
copyReference: (title: string) => string;
|
||||
|
||||
@ -207,6 +207,13 @@ export const zhCN: Translations = {
|
||||
|
||||
// Citations
|
||||
citations: {
|
||||
viewKnowledgeSource: (title: string) => `查看来源:${title}`,
|
||||
sourcePages: (pages: string) => `第 ${pages} 页`,
|
||||
retrievedExcerpt:
|
||||
"这是回答生成时检索到的证据片段,源文档此后可能已发生变化。",
|
||||
excerptTruncated: "该片段已按检索长度限制截短。",
|
||||
sourceUnavailable: "当前加载的对话中没有这条引用的来源记录。",
|
||||
knowledgeSourcesSummary: (count: number) => `${count} 个知识库来源`,
|
||||
sourcesSummary: (count) => `使用了 ${count} 个来源`,
|
||||
citeCount: (count) => `${count} 次引用`,
|
||||
copyReference: (title) => `复制 ${title} 引用`,
|
||||
|
||||
107
frontend/src/core/knowledge/sources.ts
Normal file
107
frontend/src/core/knowledge/sources.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { maskCitationCode } from "@/core/citations/sources";
|
||||
|
||||
export type KnowledgeSource = {
|
||||
id: string;
|
||||
provider: "ragflow";
|
||||
dataset_name: string;
|
||||
document_name: string;
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
pages: number[];
|
||||
};
|
||||
|
||||
const SOURCE_ID = /^[a-f0-9]{32}-[1-9][0-9]{0,2}$/;
|
||||
const SOURCE_LINK =
|
||||
/(?<!!)\[[^\]\n]+\]\(#(?:user-content-)?knowledge-([a-f0-9]{32}-[1-9][0-9]{0,2})\)/g;
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Read only native tool artifacts, never model or human-provided metadata. */
|
||||
export function collectKnowledgeSources(messages: readonly Message[]) {
|
||||
const sources = new Map<string, KnowledgeSource>();
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message.type !== "tool" ||
|
||||
!["knowledge_search", "task"].includes(message.name ?? "")
|
||||
)
|
||||
continue;
|
||||
const artifact: unknown = Reflect.get(message, "artifact");
|
||||
const payload = record(artifact) ? artifact.knowledge_sources : null;
|
||||
if (
|
||||
!record(payload) ||
|
||||
payload.version !== 1 ||
|
||||
!Array.isArray(payload.sources)
|
||||
)
|
||||
continue;
|
||||
for (const raw of payload.sources.slice(0, 100)) {
|
||||
if (
|
||||
!record(raw) ||
|
||||
typeof raw.id !== "string" ||
|
||||
!SOURCE_ID.test(raw.id) ||
|
||||
raw.provider !== "ragflow"
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
typeof raw.document_name !== "string" ||
|
||||
raw.document_name.length > 1024 ||
|
||||
typeof raw.dataset_name !== "string" ||
|
||||
raw.dataset_name.length > 1024 ||
|
||||
typeof raw.text !== "string" ||
|
||||
raw.text.length > 200_000 ||
|
||||
typeof raw.truncated !== "boolean" ||
|
||||
!Array.isArray(raw.pages) ||
|
||||
raw.pages.length > 100 ||
|
||||
!raw.pages.every(
|
||||
(page: unknown) =>
|
||||
typeof page === "number" &&
|
||||
Number.isInteger(page) &&
|
||||
page > 0 &&
|
||||
page <= 1_000_000,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const source: KnowledgeSource = {
|
||||
id: raw.id,
|
||||
provider: "ragflow",
|
||||
dataset_name: raw.dataset_name,
|
||||
document_name: raw.document_name,
|
||||
text: raw.text,
|
||||
truncated: raw.truncated,
|
||||
pages: raw.pages as number[],
|
||||
};
|
||||
// Stream replays can repeat records; never silently replace evidence.
|
||||
if (!sources.has(source.id)) sources.set(source.id, source);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function knowledgeSourceId(href: string | undefined): string | null {
|
||||
// rehype-sanitize prefixes same-document anchors to prevent DOM clobbering.
|
||||
const match =
|
||||
/^#(?:user-content-)?knowledge-([a-f0-9]{32}-[1-9][0-9]{0,2})$/.exec(
|
||||
href ?? "",
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function citedKnowledgeSources(
|
||||
markdown: string,
|
||||
sources: ReadonlyMap<string, KnowledgeSource>,
|
||||
) {
|
||||
const result: KnowledgeSource[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of maskCitationCode(markdown).matchAll(SOURCE_LINK)) {
|
||||
const id = match[1]!;
|
||||
const source = sources.get(id);
|
||||
if (source && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
result.push(source);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
106
frontend/tests/e2e/knowledge-citations.spec.ts
Normal file
106
frontend/tests/e2e/knowledge-citations.spec.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { MOCK_THREAD_ID, mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
const sourceId = "0123456789abcdef0123456789abcdef-1";
|
||||
const messages = [
|
||||
{ id: "human-1", type: "human", content: "What is the maximum pressure?" },
|
||||
{
|
||||
id: "ai-search",
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "search-1",
|
||||
name: "knowledge_search",
|
||||
args: { query: "maximum pressure" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tool-1",
|
||||
type: "tool",
|
||||
name: "knowledge_search",
|
||||
tool_call_id: "search-1",
|
||||
content: `[citation:1](#knowledge-${sourceId}) Engineering / Safety manual.pdf\nMaximum pressure: 42 kPa.`,
|
||||
artifact: {
|
||||
knowledge_sources: {
|
||||
version: 1,
|
||||
sources: [
|
||||
{
|
||||
id: sourceId,
|
||||
provider: "ragflow",
|
||||
dataset_name: "Engineering",
|
||||
document_name: "Safety manual.pdf",
|
||||
dataset_id: "dataset-a",
|
||||
document_id: "doc-a",
|
||||
chunk_id: "chunk-a",
|
||||
text: "Maximum pressure: 42 kPa.\nInspect the seal before operation.",
|
||||
pages: [3],
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "answer-1",
|
||||
type: "ai",
|
||||
content: `The maximum pressure is **42 kPa**. [citation:1](#knowledge-${sourceId})\n\n## Sources\n- [Safety manual.pdf](#knowledge-${sourceId})`,
|
||||
},
|
||||
];
|
||||
|
||||
test("knowledge references open source evidence before and after history reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Knowledge source verification",
|
||||
messages,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
const source = page.getByRole("button", {
|
||||
name: "View source: Safety manual.pdf",
|
||||
});
|
||||
await expect(source.first()).toBeVisible();
|
||||
await source.first().click();
|
||||
await expect(page.getByRole("dialog")).toContainText(
|
||||
"Maximum pressure: 42 kPa.",
|
||||
);
|
||||
await expect(page.getByRole("dialog")).toContainText("Pages 3");
|
||||
await expect(page.getByRole("dialog")).toHaveCSS("opacity", "1");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("knowledge-citation-desktop.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click();
|
||||
// Sources-section links have an ordinary title, without the citation prefix.
|
||||
const titledSource = source.filter({ hasText: "Safety manual.pdf" }).first();
|
||||
await titledSource.click();
|
||||
await expect(page.getByRole("dialog")).toContainText(
|
||||
"Maximum pressure: 42 kPa.",
|
||||
);
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click();
|
||||
await page.reload();
|
||||
await expect(source.first()).toBeVisible();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await titledSource.click();
|
||||
await expect(page.getByRole("dialog")).toContainText(
|
||||
"Inspect the seal before operation.",
|
||||
);
|
||||
await expect(page.getByRole("dialog")).toHaveCSS("opacity", "1");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("knowledge-citation-mobile.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,130 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { afterEach, describe, expect, it } from "@rstest/core";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { ArtifactLink } from "@/components/workspace/citations/artifact-link";
|
||||
import {
|
||||
KnowledgeCitationLink,
|
||||
KnowledgeSourcesPanel,
|
||||
KnowledgeSourcesProvider,
|
||||
} from "@/components/workspace/citations/knowledge-source";
|
||||
import { createMarkdownLinkComponent } from "@/components/workspace/messages/markdown-link";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
|
||||
const id = "0123456789abcdef0123456789abcdef-1";
|
||||
const href = `#knowledge-${id}`;
|
||||
const messages = [
|
||||
{
|
||||
type: "tool",
|
||||
name: "knowledge_search",
|
||||
content: "retrieved",
|
||||
tool_call_id: "call",
|
||||
artifact: {
|
||||
knowledge_sources: {
|
||||
version: 1,
|
||||
sources: [
|
||||
{
|
||||
id,
|
||||
provider: "ragflow",
|
||||
document_name: "Manual.pdf",
|
||||
dataset_name: "Engineering",
|
||||
text: "The limit is 42.\n<script>invalid</script>",
|
||||
truncated: false,
|
||||
pages: [3],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as Message[];
|
||||
afterEach(cleanup);
|
||||
|
||||
function App({ items = messages }: { items?: Message[] }) {
|
||||
return (
|
||||
<I18nProvider initialLocale="en-US">
|
||||
<KnowledgeSourcesProvider messages={items}>
|
||||
<KnowledgeCitationLink href={href}>1</KnowledgeCitationLink>
|
||||
<KnowledgeSourcesPanel content={`Limit: 42. [citation:1](${href})`} />
|
||||
</KnowledgeSourcesProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("knowledge source dialogs", () => {
|
||||
it("opens the actual excerpt and source page from both citation and source list", () => {
|
||||
render(<App />);
|
||||
const buttons = screen.getAllByRole("button", {
|
||||
name: "View source: Manual.pdf",
|
||||
});
|
||||
expect(buttons.length).toBe(2);
|
||||
fireEvent.click(buttons[0]!);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.textContent).toContain("Pages 3");
|
||||
expect(dialog.textContent).toContain("The limit is 42.");
|
||||
expect(dialog.querySelector("script")).toBeNull();
|
||||
});
|
||||
it("restores citations from persisted JSON and loses access on conversation switch", () => {
|
||||
const view = render(
|
||||
<App items={JSON.parse(JSON.stringify(messages)) as Message[]} />,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "View source: Manual.pdf" }).length,
|
||||
).toBe(2);
|
||||
view.rerender(<App items={[]} />);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "View source: Manual.pdf" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByTitle(
|
||||
"Source evidence is unavailable in the loaded conversation.",
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
for (const [surface, Link] of [
|
||||
["message", createMarkdownLinkComponent()],
|
||||
["artifact", ArtifactLink],
|
||||
] as const) {
|
||||
describe(`${surface} knowledge destinations`, () => {
|
||||
for (const label of ["Manual.pdf", "citation:1"]) {
|
||||
for (const destination of [href, href.replace("#", "#user-content-")]) {
|
||||
it(`opens ${label} at ${destination}`, () => {
|
||||
render(
|
||||
<I18nProvider initialLocale="en-US">
|
||||
<KnowledgeSourcesProvider messages={messages}>
|
||||
<Link href={destination}>{label}</Link>
|
||||
</KnowledgeSourcesProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "View source: Manual.pdf" }),
|
||||
);
|
||||
expect(screen.getByRole("dialog").textContent).toContain(
|
||||
"The limit is 42.",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
it("keeps missing sources unavailable and external links navigable", () => {
|
||||
render(
|
||||
<I18nProvider initialLocale="en-US">
|
||||
<KnowledgeSourcesProvider messages={[]}>
|
||||
<Link href={href}>Manual.pdf</Link>
|
||||
<Link href="https://example.com/manual">External manual</Link>
|
||||
</KnowledgeSourcesProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByTitle(
|
||||
"Source evidence is unavailable in the loaded conversation.",
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("link", { name: "External manual" })
|
||||
.getAttribute("href"),
|
||||
).toBe("https://example.com/manual");
|
||||
});
|
||||
});
|
||||
}
|
||||
100
frontend/tests/unit/core/knowledge/sources.test.ts
Normal file
100
frontend/tests/unit/core/knowledge/sources.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
citedKnowledgeSources,
|
||||
collectKnowledgeSources,
|
||||
knowledgeSourceId,
|
||||
} from "@/core/knowledge/sources";
|
||||
|
||||
const id = "0123456789abcdef0123456789abcdef-1";
|
||||
const source = {
|
||||
id,
|
||||
provider: "ragflow",
|
||||
document_name: "Manual.pdf",
|
||||
dataset_name: "Engineering",
|
||||
text: "Limit: 42.",
|
||||
truncated: false,
|
||||
pages: [3],
|
||||
};
|
||||
const message = {
|
||||
type: "tool",
|
||||
name: "knowledge_search",
|
||||
content: "retrieved",
|
||||
tool_call_id: "call",
|
||||
artifact: { knowledge_sources: { version: 1, sources: [source] } },
|
||||
} as unknown as Message;
|
||||
|
||||
describe("knowledge citation provenance", () => {
|
||||
it("resolves repeated citations to the persisted tool evidence after JSON reload", () => {
|
||||
const messages = JSON.parse(JSON.stringify([message])) as Message[];
|
||||
const sources = collectKnowledgeSources(messages);
|
||||
const citation = `[citation:1](#knowledge-${id})`;
|
||||
expect(citedKnowledgeSources(`${citation} ${citation}`, sources)).toEqual([
|
||||
source,
|
||||
]);
|
||||
expect(knowledgeSourceId(`#knowledge-${id}`)).toBe(id);
|
||||
expect(knowledgeSourceId(`#user-content-knowledge-${id}`)).toBe(id);
|
||||
});
|
||||
it("does not let model labels or human artifacts invent a source", () => {
|
||||
const sources = collectKnowledgeSources([
|
||||
{ ...message, type: "human" } as Message,
|
||||
{ ...message, type: "ai" } as Message,
|
||||
]);
|
||||
expect(sources.size).toBe(0);
|
||||
expect(
|
||||
citedKnowledgeSources(`[citation:invented](#knowledge-${id})`, sources),
|
||||
).toEqual([]);
|
||||
expect(knowledgeSourceId(`https://evil.test/#knowledge-${id}`)).toBeNull();
|
||||
});
|
||||
it("ignores citations in code and images", () => {
|
||||
const link = `[citation:1](#knowledge-${id})`;
|
||||
expect(
|
||||
citedKnowledgeSources(
|
||||
`\`${link}\`\n\n\`\`\`\n${link}\n\`\`\`\n!${link}`,
|
||||
collectKnowledgeSources([message]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
it("rejects malformed and unknown artifact versions", () => {
|
||||
for (const payload of [
|
||||
{ version: 2, sources: [source] },
|
||||
{ version: 1, sources: [{ ...source, pages: [-1] }] },
|
||||
{ version: 1, sources: [{ ...source, text: 42 }] },
|
||||
]) {
|
||||
const bad = {
|
||||
...message,
|
||||
artifact: { knowledge_sources: payload },
|
||||
} as Message;
|
||||
expect(collectKnowledgeSources([bad]).size).toBe(0);
|
||||
}
|
||||
});
|
||||
it("keeps independent source identities for repeated searches", () => {
|
||||
const other = {
|
||||
...source,
|
||||
id: "fedcba9876543210fedcba9876543210-1",
|
||||
text: "Limit: 43.",
|
||||
};
|
||||
const next = {
|
||||
...message,
|
||||
artifact: { knowledge_sources: { version: 1, sources: [other] } },
|
||||
} as Message;
|
||||
expect(collectKnowledgeSources([message, next, message]).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("collects ordinary Sources-section links without collecting code or images", () => {
|
||||
const link = `[Manual.pdf](#knowledge-${id})`;
|
||||
expect(
|
||||
citedKnowledgeSources(
|
||||
`## Sources\n- ${link}\n${link}`,
|
||||
collectKnowledgeSources([message]),
|
||||
),
|
||||
).toEqual([source]);
|
||||
expect(
|
||||
citedKnowledgeSources(
|
||||
`\`${link}\`\n!${link}`,
|
||||
collectKnowledgeSources([message]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user