fix: improve llmwiki search integration
This commit is contained in:
1 parent
4809ee7db3
commit
56044d9cb7
20 files changed
+504
-66
No files matched your search
@@ -101,9 +101,9 @@ def _agent_bound_ids(body: Any) -> list[str]:
|
||||
|
||||
|
||||
def resolve_requested_llmwiki_knowledge_base_ids(body: Any) -> list[str]:
|
||||
"""Return selected ids plus custom-agent defaults, de-duplicated in order."""
|
||||
"""Return knowledge bases explicitly selected for the current conversation."""
|
||||
|
||||
return list(dict.fromkeys([*_agent_bound_ids(body), *_body_selected_ids(body)]))
|
||||
return _body_selected_ids(body)
|
||||
|
||||
|
||||
def llmwiki_selection_requested(body: Any) -> bool:
|
||||
|
||||
@@ -1045,6 +1045,10 @@ async def start_run(
|
||||
body_context["rag_mode_enabled"] = True
|
||||
if "llmwiki_knowledge_base_ids" not in body_context and llmwiki_rag.knowledge_base_ids:
|
||||
body_context["llmwiki_knowledge_base_ids"] = llmwiki_rag.knowledge_base_ids
|
||||
if not llmwiki_rag.knowledge_base_ids:
|
||||
existing_excluded = list(body_context.get("excluded_tools") or getattr(body, "excluded_tools", None) or [])
|
||||
if "llmwiki_search" not in existing_excluded:
|
||||
body_context["excluded_tools"] = [*existing_excluded, "llmwiki_search"]
|
||||
# Promote whitelisted top-level body fields into the context map so
|
||||
# callers can pass them either nested under ``context`` or as flat
|
||||
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
||||
|
||||
+22
-8
@@ -2,11 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from deerflow.config.agents_config import load_agent_soul
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
@@ -22,7 +23,20 @@ logger = logging.getLogger(__name__)
|
||||
# Truthy strings for boolean env vars. Mirrors the convention used by
|
||||
# DEER_FLOW_AUTH_DISABLED so operators don't have to learn yet another
|
||||
# spelling for "yes, turn this on".
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
|
||||
_BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _current_time_context() -> str:
|
||||
now = datetime.now(_BEIJING_TZ)
|
||||
return (
|
||||
f"\n<current_datetime timezone=\"Asia/Shanghai\">{now.strftime('%Y-%m-%d %H:%M:%S, %A')}</current_datetime>"
|
||||
"\n<time_grounding>"
|
||||
"When interpreting relative dates such as 今天, 昨天, 近几天, 最近, this week, or last month, "
|
||||
"use current_datetime as the only reference point. Before calling web_search, rewrite relative "
|
||||
"time expressions into concrete dates or date ranges in the query; never infer a stale year."
|
||||
"</time_grounding>"
|
||||
)
|
||||
|
||||
|
||||
def _is_offline_mode() -> bool:
|
||||
@@ -761,7 +775,7 @@ RAG/reference display mode is enabled.
|
||||
acp_section=acp_and_mounts_section,
|
||||
)
|
||||
|
||||
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
prompt = prompt + rag_mode_section + _current_time_context()
|
||||
return prompt
|
||||
|
||||
|
||||
@@ -1359,7 +1373,7 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
acp_section=acp_and_mounts_section,
|
||||
)
|
||||
|
||||
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
prompt = prompt + rag_mode_section + _current_time_context()
|
||||
|
||||
if is_notebook_mode:
|
||||
source_ids = notebook_source_ids or []
|
||||
|
||||
+64
-14
@@ -6,8 +6,10 @@ import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
@@ -16,6 +18,12 @@ from deerflow.config import get_app_config
|
||||
from deerflow.config.tool_config import ToolConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _current_time_hint() -> str:
|
||||
now = datetime.now(_BEIJING_TZ)
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S, %A, Asia/Shanghai")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -87,6 +95,23 @@ def _text(value: Any) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _metadata(item: dict[str, Any]) -> dict[str, Any]:
|
||||
value = item.get("metadata")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _first_text(*values: Any) -> str:
|
||||
for value in values:
|
||||
text = _text(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _looks_like_url(value: str) -> bool:
|
||||
return value.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def _error_result(query: str, message: str) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -100,10 +125,13 @@ def _error_result(query: str, message: str) -> str:
|
||||
|
||||
|
||||
def _normalize_results(data: Any, settings: ConfigurableSearchSettings) -> list[dict[str, str]]:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("search API response must be a JSON object")
|
||||
if isinstance(data, list):
|
||||
raw_results = data
|
||||
elif isinstance(data, dict):
|
||||
raw_results = data.get("results")
|
||||
else:
|
||||
raise ValueError("search API response must be a JSON object or array")
|
||||
|
||||
raw_results = data.get("results")
|
||||
if not isinstance(raw_results, list):
|
||||
raise ValueError("search API response field 'results' must be an array")
|
||||
|
||||
@@ -113,8 +141,21 @@ def _normalize_results(data: Any, settings: ConfigurableSearchSettings) -> list[
|
||||
logger.warning("Ignoring non-object search result: %r", item)
|
||||
continue
|
||||
|
||||
rec_uuid = _text(item.get("recUuid")).strip()
|
||||
original_url = _text(item.get("url")).strip()
|
||||
metadata = _metadata(item)
|
||||
rec_uuid = _first_text(item.get("recUuid"), item.get("rec_uuid"), metadata.get("recUuid"), metadata.get("rec_uuid"))
|
||||
source = _first_text(item.get("source"), item.get("source1"), metadata.get("source"), metadata.get("source1"))
|
||||
original_url = _first_text(
|
||||
item.get("url"),
|
||||
item.get("link"),
|
||||
item.get("href"),
|
||||
item.get("source_url"),
|
||||
metadata.get("url"),
|
||||
metadata.get("link"),
|
||||
metadata.get("href"),
|
||||
metadata.get("source_url"),
|
||||
)
|
||||
if not original_url and _looks_like_url(source):
|
||||
original_url = source
|
||||
result_url = original_url
|
||||
if rec_uuid and settings.result_url_template:
|
||||
result_url = settings.result_url_template.replace(
|
||||
@@ -122,14 +163,19 @@ def _normalize_results(data: Any, settings: ConfigurableSearchSettings) -> list[
|
||||
quote(rec_uuid, safe=""),
|
||||
)
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"recUuid": rec_uuid,
|
||||
"content": _text(item.get("content")),
|
||||
"title": _text(item.get("title")),
|
||||
"url": result_url,
|
||||
}
|
||||
)
|
||||
normalized_item = {
|
||||
"recUuid": rec_uuid,
|
||||
"content": _first_text(item.get("content"), item.get("page_content"), metadata.get("content")),
|
||||
"title": _first_text(item.get("title"), item.get("m_title"), metadata.get("m_title")),
|
||||
"url": result_url,
|
||||
}
|
||||
if source:
|
||||
normalized_item["source"] = source
|
||||
publish_time = _first_text(item.get("time"), item.get("publish_time"), item.get("m_publish"), metadata.get("m_publish"))
|
||||
if publish_time:
|
||||
normalized_item["time"] = publish_time
|
||||
normalized_item["publish_time"] = publish_time
|
||||
normalized.append(normalized_item)
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -193,10 +239,14 @@ async def web_search_tool(query: str) -> str:
|
||||
|
||||
Args:
|
||||
query: Focused search keywords or a natural-language retrieval question.
|
||||
Interpret relative time expressions against the current time in
|
||||
Asia/Shanghai, and include concrete dates/date ranges in the query
|
||||
when the user asks about recent periods.
|
||||
"""
|
||||
|
||||
try:
|
||||
settings = ConfigurableSearchSettings.from_tool_config(get_app_config().get_tool_config("web_search"))
|
||||
except ValueError as exc:
|
||||
return _error_result(query, str(exc))
|
||||
return await execute_search(query, settings)
|
||||
grounded_query = f"{query.strip()}\n当前时间: {_current_time_hint()}" if query.strip() else query
|
||||
return await execute_search(grounded_query, settings)
|
||||
+14
-17
@@ -71,8 +71,8 @@ async def llmwiki_search_tool(
|
||||
) -> str:
|
||||
"""Search the LLMWiki knowledge selected for this conversation.
|
||||
|
||||
Use this tool when an answer can benefit from the user's managed knowledge
|
||||
bases or the shared public LLMWiki. It only retrieves passages; it cannot
|
||||
Use this tool only when the current conversation explicitly selected one
|
||||
or more LLMWiki knowledge bases. It only retrieves passages; it cannot
|
||||
upload, edit, delete, publish or change permissions.
|
||||
|
||||
Args:
|
||||
@@ -90,26 +90,23 @@ async def llmwiki_search_tool(
|
||||
selected_ids = selected_knowledge_base_ids(context)
|
||||
allowed_ids = _agent_allowed_ids(context)
|
||||
if selected_ids is None:
|
||||
selected_ids = allowed_ids
|
||||
return "No LLMWiki knowledge base is selected for this conversation."
|
||||
elif allowed_ids is not None and not set(selected_ids).issubset(set(allowed_ids)):
|
||||
return "The conversation requested knowledge outside this agent's configured LLMWiki scope."
|
||||
|
||||
if selected_ids == []:
|
||||
return "No LLMWiki knowledge base is selected for this conversation."
|
||||
if selected_ids is not None:
|
||||
rows = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(
|
||||
mapping_id,
|
||||
user_id,
|
||||
write=False,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if row is None:
|
||||
return f"Knowledge base {mapping_id!r} is not available to the current user."
|
||||
rows.append(row)
|
||||
else:
|
||||
rows = await store.list_visible(user_id, scope="all", is_admin=False)
|
||||
rows = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(
|
||||
mapping_id,
|
||||
user_id,
|
||||
write=False,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if row is None:
|
||||
return f"Knowledge base {mapping_id!r} is not available to the current user."
|
||||
rows.append(row)
|
||||
if not rows:
|
||||
return "No authorized LLMWiki knowledge bases are available."
|
||||
|
||||
|
||||
+76
@@ -104,6 +104,82 @@ async def test_configurable_search_handles_empty_results() -> None:
|
||||
assert result == {"query": "nothing", "total_results": 0, "results": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_accepts_root_array_results() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"recUuid": "root-array-record",
|
||||
"content": "direct array content",
|
||||
"title": "direct array title",
|
||||
"url": "https://original.example.test/root-array",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
"array response",
|
||||
_settings(result_url_template=""),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"query": "array response",
|
||||
"total_results": 1,
|
||||
"results": [
|
||||
{
|
||||
"recUuid": "root-array-record",
|
||||
"content": "direct array content",
|
||||
"title": "direct array title",
|
||||
"url": "https://original.example.test/root-array",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_normalizes_metadata_wrapped_results() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"page_content": "outer content",
|
||||
"metadata": {
|
||||
"m_title": "metadata title",
|
||||
"content": "metadata content",
|
||||
"source1": "https://source.example.test/article/1",
|
||||
"m_publish": "2026-08-13",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
"metadata response",
|
||||
_settings(result_url_template=""),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["results"] == [
|
||||
{
|
||||
"recUuid": "",
|
||||
"content": "outer content",
|
||||
"title": "metadata title",
|
||||
"source": "https://source.example.test/article/1",
|
||||
"time": "2026-08-13",
|
||||
"publish_time": "2026-08-13",
|
||||
"url": "https://source.example.test/article/1",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("response_factory", "expected_error"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -128,6 +129,18 @@ def test_conversation_selection_is_deduplicated_and_absence_is_distinct() -> Non
|
||||
assert selected_knowledge_base_ids({"llmwiki_knowledge_base_ids": ["kb-one", "kb-one", "", "kb-two"]}) == ["kb-one", "kb-two"]
|
||||
|
||||
|
||||
def test_llmwiki_rag_only_uses_explicit_conversation_selection() -> None:
|
||||
from app.gateway.llmwiki_rag import resolve_requested_llmwiki_knowledge_base_ids
|
||||
|
||||
body = SimpleNamespace(
|
||||
assistant_id="agent-with-bound-kb",
|
||||
context={},
|
||||
metadata={"llmwiki_knowledge_base_ids": ["kb-one", "kb-one", "", "kb-two"]},
|
||||
)
|
||||
|
||||
assert resolve_requested_llmwiki_knowledge_base_ids(body) == ["kb-one", "kb-two"]
|
||||
|
||||
|
||||
def test_weknora_frame_bootstrap_accepts_query_token_only_for_frame_route() -> None:
|
||||
from app.gateway.deps import get_request_access_token
|
||||
|
||||
|
||||
Reference in new issue
Block a user