deer-flow/backend/app/gateway/knowledge_scope_admission.py
zhangwei-way b6503e9a35
feat(knowledge): add per-message RAGFlow retrieval scope (#5238)
* feat(knowledge): integrate RAGFlow retrieval and management

* test(knowledge): cover merged listing tool

* feat(knowledge): add per-message retrieval scope

* chore(docs): remove unrelated document

* docs(knowledge): add interaction screenshots

* feat(knowledge): simplify scope selector trigger

* docs(knowledge): refresh selector screenshot

* feat(knowledge): defer standalone management

* docs(knowledge): show chat-only scope UI

* fix(knowledge): honor scope on clarification replies

* fix(knowledge): harden scoped replay validation

* docs(knowledge): clarify replay scope precedence

* fix(knowledge): keep provider settings on tools

* fix(config): preserve tools-only knowledge settings

* fix(knowledge): submit custom assistant identity

* refactor(knowledge): trim PR scope changes

* fix(knowledge): sanitize document scope display

* feat(knowledge): enable scope selection in main chat

* fix(knowledge): emphasize active scope icon without button frame

* fix(knowledge): close context scrubbing and refresh e2e checks

* fix(knowledge): preserve idempotent canonical retries

* fix(knowledge): accept promptless conversation runs

* style(knowledge): format backend regression tests

* chore(knowledge): trim PR scope and fix frontend format

* fix(knowledge): remove shared-scope notice

* fix(knowledge): remove scope persistence notice

* docs(knowledge): include main chat in catalog scope

* fix(knowledge): preserve scope recovery and upgrades

* fix(config): preserve LightRAG knowledge upgrades

---------

Co-authored-by: foreleven <for-eleven@hotmail.com>
2026-09-18 16:59:31 +08:00

131 lines
4.7 KiB
Python

"""Gateway trust-boundary checks for per-message knowledge scope."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from langchain_core.messages import BaseMessage, HumanMessage
from pydantic import ValidationError
from deerflow.knowledge_scope import (
KNOWLEDGE_SCOPE_KEY,
canonicalize_knowledge_scope,
execution_scope,
)
RAGFLOW_KNOWLEDGE_SEARCH_PROVIDER = "deerflow.community.ragflow.tools:knowledge_search_tool"
def assistant_supports_knowledge_scope(
*,
assistant_id: str | None,
app_config: Any,
agent_config: Any | None,
) -> bool:
"""Return whether this exact assistant/provider pairing is supported."""
if not assistant_id:
return False
knowledge_base = getattr(app_config, "knowledge_base", None)
if not getattr(knowledge_base, "enabled", False):
return False
get_tool_config = getattr(app_config, "get_tool_config", None)
tool = get_tool_config("knowledge_search") if callable(get_tool_config) else None
if getattr(tool, "use", None) != RAGFLOW_KNOWLEDGE_SEARCH_PROVIDER:
return False
# The main assistant has no custom-agent config row. Its knowledge tool is
# controlled solely by the app-level provider configuration.
if assistant_id == "lead_agent":
return True
if agent_config is None:
return False
tool_groups = getattr(agent_config, "tool_groups", None)
return tool_groups is None or "knowledge" in tool_groups
def _replace_scope(message: HumanMessage, scope: dict[str, Any] | None) -> HumanMessage:
additional_kwargs = dict(message.additional_kwargs or {})
if scope is None:
additional_kwargs.pop(KNOWLEDGE_SCOPE_KEY, None)
else:
additional_kwargs[KNOWLEDGE_SCOPE_KEY] = scope
return message.model_copy(update={"additional_kwargs": additional_kwargs})
def admit_message_knowledge_scope(
graph_input: dict[str, Any],
*,
assistant_id: str | None,
app_config: Any,
agent_config: Any | None,
recovery_scope: object | None = None,
recovery: bool = False,
) -> dict[str, Any] | None:
"""Canonicalize the sole eligible HumanMessage and return execution scope.
During regenerate/resume recovery, the server-resolved source snapshot is
authoritative and replaces any client-supplied value.
"""
messages = graph_input.get("messages")
if not isinstance(messages, list):
return None
scoped_indexes: list[int] = []
for index, message in enumerate(messages):
if not isinstance(message, BaseMessage):
continue
additional_kwargs = message.additional_kwargs
if KNOWLEDGE_SCOPE_KEY not in additional_kwargs:
continue
if not isinstance(message, HumanMessage):
raise HTTPException(
status_code=422,
detail="knowledge_scope is allowed only on the current HumanMessage",
)
scoped_indexes.append(index)
if len(scoped_indexes) > 1:
raise HTTPException(
status_code=422,
detail="knowledge_scope is allowed on only one new HumanMessage",
)
target_indexes = [index for index, message in enumerate(messages) if isinstance(message, HumanMessage)]
target_index = target_indexes[-1] if target_indexes else None
if scoped_indexes and scoped_indexes[0] != target_index:
raise HTTPException(
status_code=422,
detail="knowledge_scope is allowed only on the current HumanMessage",
)
if recovery:
raw_scope = recovery_scope
elif scoped_indexes:
target_index = scoped_indexes[0]
raw_scope = messages[target_index].additional_kwargs[KNOWLEDGE_SCOPE_KEY]
else:
return None
canonical: dict[str, Any] | None = None
if raw_scope is not None:
if not assistant_supports_knowledge_scope(
assistant_id=assistant_id,
app_config=app_config,
agent_config=agent_config,
):
raise HTTPException(
status_code=422,
detail="knowledge_scope is not supported by this assistant or knowledge provider",
)
try:
canonical = canonicalize_knowledge_scope(raw_scope)
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail=f"Invalid knowledge_scope: {exc.errors()[0]['msg']}",
) from exc
elif scoped_indexes and not recovery:
raise HTTPException(status_code=422, detail="knowledge_scope must be an object")
if target_index is not None:
messages[target_index] = _replace_scope(messages[target_index], canonical)
return execution_scope(canonical) if canonical is not None else None