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:
Wenchao An 2026-09-19 07:44:05 +08:00 committed by GitHub
parent f9f3127dc1
commit 34bbeb1806
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1142 additions and 14 deletions

View File

@ -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

View File

@ -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)

View File

@ -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.

View File

@ -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}}

View 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

View File

@ -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,
)

View File

@ -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.

View File

@ -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

View File

@ -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",

View 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)

View File

@ -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]

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -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.

View File

@ -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);

View File

@ -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>

View 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>
);
}

View File

@ -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) {

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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));
}

View File

@ -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"}`,

View File

@ -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;

View File

@ -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} 引用`,

View 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;
}

View 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,
});
});

View File

@ -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");
});
});
}

View 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([]);
});