diff --git a/README.md b/README.md index 6a8db16d7..6fef6111c 100644 --- a/README.md +++ b/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 diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py index 05e44225c..54825cc81 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py @@ -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) diff --git a/backend/packages/harness/deerflow/community/ragflow/AGENTS.md b/backend/packages/harness/deerflow/community/ragflow/AGENTS.md new file mode 100644 index 000000000..21ffac0f8 --- /dev/null +++ b/backend/packages/harness/deerflow/community/ragflow/AGENTS.md @@ -0,0 +1,20 @@ +# RAGFlow citation snapshots + +`knowledge_search_tool` returns native `content_and_artifact`: model-visible +`[citation:N](#knowledge-)` 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. diff --git a/backend/packages/harness/deerflow/community/ragflow/formatting.py b/backend/packages/harness/deerflow/community/ragflow/formatting.py index 7a179273a..ce2bf2061 100644 --- a/backend/packages/harness/deerflow/community/ragflow/formatting.py +++ b/backend/packages/harness/deerflow/community/ragflow/formatting.py @@ -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}} diff --git a/backend/packages/harness/deerflow/community/ragflow/sources.py b/backend/packages/harness/deerflow/community/ragflow/sources.py new file mode 100644 index 000000000..5fa5a4cda --- /dev/null +++ b/backend/packages/harness/deerflow/community/ragflow/sources.py @@ -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 diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index 31e6bf76f..2836472b2 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -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, ) diff --git a/backend/packages/harness/deerflow/tools/AGENTS.md b/backend/packages/harness/deerflow/tools/AGENTS.md index 7d8e822e8..2aa6281bf 100644 --- a/backend/packages/harness/deerflow/tools/AGENTS.md +++ b/backend/packages/harness/deerflow/tools/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index be5990b75..d68b801cd 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -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 diff --git a/backend/tests/test_agent_guidance_check.py b/backend/tests/test_agent_guidance_check.py index 8dbc866a0..af1c07e4b 100644 --- a/backend/tests/test_agent_guidance_check.py +++ b/backend/tests/test_agent_guidance_check.py @@ -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", diff --git a/backend/tests/test_knowledge_citation_budget.py b/backend/tests/test_knowledge_citation_budget.py new file mode 100644 index 000000000..685e145e0 --- /dev/null +++ b/backend/tests/test_knowledge_citation_budget.py @@ -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) diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index a54777cb2..ff561d3be 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -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] diff --git a/docs/pr-evidence/knowledge-citations-desktop.png b/docs/pr-evidence/knowledge-citations-desktop.png new file mode 100644 index 000000000..c18752ac4 Binary files /dev/null and b/docs/pr-evidence/knowledge-citations-desktop.png differ diff --git a/docs/pr-evidence/knowledge-citations-mobile.png b/docs/pr-evidence/knowledge-citations-mobile.png new file mode 100644 index 000000000..912387bcf Binary files /dev/null and b/docs/pr-evidence/knowledge-citations-mobile.png differ diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index f543920e3..25fbb836d 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -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. diff --git a/frontend/src/components/workspace/citations/artifact-link.tsx b/frontend/src/components/workspace/citations/artifact-link.tsx index 1560c3086..105d30d69 100644 --- a/frontend/src/components/workspace/citations/artifact-link.tsx +++ b/frontend/src/components/workspace/citations/artifact-link.tsx @@ -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) { // 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) { ); } + if (knowledgeSourceId(props.href)) { + return ; + } const childrenText = extractReactNodeText(props.children); if (childrenText !== null) { const match = /^citation:(.+)$/.exec(childrenText); diff --git a/frontend/src/components/workspace/citations/citation-link.tsx b/frontend/src/components/workspace/citations/citation-link.tsx index 40c3c3064..ff5fcf2c2 100644 --- a/frontend/src/components/workspace/citations/citation-link.tsx +++ b/frontend/src/components/workspace/citations/citation-link.tsx @@ -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 ( + {displayText} + ); + } + return ( diff --git a/frontend/src/components/workspace/citations/knowledge-source.tsx b/frontend/src/components/workspace/citations/knowledge-source.tsx new file mode 100644 index 000000000..03f08b432 --- /dev/null +++ b/frontend/src/components/workspace/citations/knowledge-source.tsx @@ -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>( + new Map(), +); + +export function KnowledgeSourcesProvider({ + messages, + children, +}: { + messages: readonly Message[]; + children: ReactNode; +}) { + const sources = useMemo(() => collectKnowledgeSources(messages), [messages]); + return ( + + {children} + + ); +} + +function SourceDialog({ + source, + children, +}: { + source: KnowledgeSource; + children: ReactNode; +}) { + const { t } = useI18n(); + return ( + + + + + + + + {source.document_name} + + + {source.dataset_name} + {source.pages.length > 0 + ? ` · ${t.citations.sourcePages(source.pages.join(", "))}` + : ""} + + +

+ {t.citations.retrievedExcerpt} +

+
+ {source.text} +
+ {source.truncated && ( +

+ {t.citations.excerptTruncated} +

+ )} +
+
+ ); +} + +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 ( + + {children} + + ); + return ( + + + + {children} + + + ); +} + +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 ( +
+ + {t.citations.knowledgeSourcesSummary(sources.length)} + +
    + {sources.map((source) => ( +
  • + + + {source.document_name} + + + {source.dataset_name} + + +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/workspace/messages/markdown-link.tsx b/frontend/src/components/workspace/messages/markdown-link.tsx index 07fdd64af..7ed34b306 100644 --- a/frontend/src/components/workspace/messages/markdown-link.tsx +++ b/frontend/src/components/workspace/messages/markdown-link.tsx @@ -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) { ); } + // Knowledge destinations also appear as ordinary [Title](URL) Sources links. + if (knowledgeSourceId(href)) { + return ; + } // Safe-href check passed — citation links now route through CitationLink. const childrenText = extractReactNodeText(props.children); if (childrenText !== null) { diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 9ec0526e0..933949cc9 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -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} /> + {message.type === "ai" && showWorkspaceChanges && ( + )} - + ); } diff --git a/frontend/src/core/citations/sources.ts b/frontend/src/core/citations/sources.ts index e3c0fcdbd..123ed18ce 100644 --- a/frontend/src/core/citations/sources.ts +++ b/frontend/src/core/citations/sources.ts @@ -27,7 +27,7 @@ export function extractCitationSources(markdown: string): CitationSource[] { return []; } - const searchable = maskCode(markdown); + const searchable = maskCitationCode(markdown); const sourcesByUrl = new Map(); 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)); } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index d8cb73d89..8f5ee0a31 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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"}`, diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 7a36fe4c1..4d702ded8 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 33e01f55f..b69e198f6 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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} 引用`, diff --git a/frontend/src/core/knowledge/sources.ts b/frontend/src/core/knowledge/sources.ts new file mode 100644 index 000000000..266798054 --- /dev/null +++ b/frontend/src/core/knowledge/sources.ts @@ -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 = + /(? { + 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(); + 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, +) { + const result: KnowledgeSource[] = []; + const seen = new Set(); + 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; +} diff --git a/frontend/tests/e2e/knowledge-citations.spec.ts b/frontend/tests/e2e/knowledge-citations.spec.ts new file mode 100644 index 000000000..f65ccf9c2 --- /dev/null +++ b/frontend/tests/e2e/knowledge-citations.spec.ts @@ -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, + }); +}); diff --git a/frontend/tests/unit/components/workspace/citations/knowledge-source.dom.test.tsx b/frontend/tests/unit/components/workspace/citations/knowledge-source.dom.test.tsx new file mode 100644 index 000000000..8cf0ca3be --- /dev/null +++ b/frontend/tests/unit/components/workspace/citations/knowledge-source.dom.test.tsx @@ -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", + truncated: false, + pages: [3], + }, + ], + }, + }, + }, +] as unknown as Message[]; +afterEach(cleanup); + +function App({ items = messages }: { items?: Message[] }) { + return ( + + + 1 + + + + ); +} + +describe("knowledge source dialogs", () => { + it("opens the actual excerpt and source page from both citation and source list", () => { + render(); + 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( + , + ); + expect( + screen.getAllByRole("button", { name: "View source: Manual.pdf" }).length, + ).toBe(2); + view.rerender(); + 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( + + + {label} + + , + ); + 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( + + + Manual.pdf + External manual + + , + ); + 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"); + }); + }); +} diff --git a/frontend/tests/unit/core/knowledge/sources.test.ts b/frontend/tests/unit/core/knowledge/sources.test.ts new file mode 100644 index 000000000..359411012 --- /dev/null +++ b/frontend/tests/unit/core/knowledge/sources.test.ts @@ -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([]); +});