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