mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-11 05:40:52 +08:00
feat(agent): add Sofya search tool (#19323)
Add Sofya web search tool — returns full page content (not snippets). Modeled on You.com. Configurable depth (basic/snippets), top N, and per-call params. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jin Hai <haijin.chn@gmail.com>
This commit is contained in:
19 files changed
+953
-1
No files matched your search
@@ -0,0 +1,290 @@
|
||||
#
|
||||
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from abc import ABC
|
||||
|
||||
import requests
|
||||
|
||||
from agent.tools.base import ToolBase, ToolMeta, ToolParamBase
|
||||
from common.connection_utils import timeout
|
||||
|
||||
SOFYA_SEARCH_URL = "https://sofya.co/v1/search"
|
||||
# Sofya returns at most 20 results per search.
|
||||
SOFYA_MAX_RESULTS = 20
|
||||
# "basic" fetches the result pages and returns their content; "snippets"
|
||||
# returns the search snippets only, which is faster and cheaper.
|
||||
SOFYA_SEARCH_DEPTHS = ["basic", "snippets"]
|
||||
SOFYA_DEFAULT_SEARCH_DEPTH = "basic"
|
||||
SOFYA_TOPICS = ["general", "news"]
|
||||
SOFYA_DEFAULT_TOPIC = "general"
|
||||
# Sofya also accepts a date range for freshness. Only the named windows are
|
||||
# offered here, so the value can be checked against a fixed list.
|
||||
SOFYA_FRESHNESS_ANY = "any"
|
||||
SOFYA_FRESHNESS_VALUES = [SOFYA_FRESHNESS_ANY, "day", "week", "month", "year"]
|
||||
# Statuses worth another attempt. Anything else (bad request, bad key, no
|
||||
# credits) will fail the same way again, so it is returned at once.
|
||||
SOFYA_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
def _search(api_key: str, payload: dict, timeout_s: int = 30) -> dict:
|
||||
"""POST a search to Sofya and return the decoded body."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {(api_key or '').strip()}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
# Identifies RAGFlow to Sofya.
|
||||
"User-Agent": "RAGFlow sofya-integration/infiniflow-ragflow",
|
||||
}
|
||||
response = requests.post(SOFYA_SEARCH_URL, headers=headers, json=payload, timeout=timeout_s)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _is_transient(error: Exception) -> bool:
|
||||
"""Whether a failed request might succeed if repeated."""
|
||||
if isinstance(error, requests.HTTPError):
|
||||
response = getattr(error, "response", None)
|
||||
return getattr(response, "status_code", None) in SOFYA_RETRY_STATUSES
|
||||
return isinstance(error, requests.RequestException)
|
||||
|
||||
|
||||
def _search_depth(value) -> str:
|
||||
"""Normalize the node's search depth.
|
||||
|
||||
This one is set in the node config rather than by the caller, so an unknown
|
||||
value falls back to the default instead of failing the run.
|
||||
"""
|
||||
depth = str(value or "").strip().lower()
|
||||
if depth in SOFYA_SEARCH_DEPTHS:
|
||||
return depth
|
||||
if depth:
|
||||
logging.warning(f"Sofya search depth {depth} is not supported, using {SOFYA_DEFAULT_SEARCH_DEPTH}")
|
||||
return SOFYA_DEFAULT_SEARCH_DEPTH
|
||||
|
||||
|
||||
def _topic(value) -> str:
|
||||
"""Normalize a topic. Blank means the default, anything else must be known."""
|
||||
topic = str(value or "").strip().lower()
|
||||
if not topic:
|
||||
return SOFYA_DEFAULT_TOPIC
|
||||
if topic not in SOFYA_TOPICS:
|
||||
raise ValueError("Topic {} is not supported, it should be in {}".format(topic, SOFYA_TOPICS))
|
||||
return topic
|
||||
|
||||
|
||||
def _freshness(value) -> str:
|
||||
"""Normalize a freshness value to a Sofya window, or "" for no limit.
|
||||
|
||||
Blank and `any` both mean no restriction. Anything else has to be one of the
|
||||
named windows, because the value is forwarded to Sofya as-is.
|
||||
"""
|
||||
freshness = str(value or "").strip().lower()
|
||||
if not freshness or freshness == SOFYA_FRESHNESS_ANY:
|
||||
return ""
|
||||
if freshness not in SOFYA_FRESHNESS_VALUES:
|
||||
raise ValueError("Freshness {} is not supported, it should be in {}".format(freshness, SOFYA_FRESHNESS_VALUES))
|
||||
return freshness
|
||||
|
||||
|
||||
def _max_results(value, fallback) -> int:
|
||||
"""Result count: the caller's if it gave one, else the node's Top N.
|
||||
|
||||
A blank or unparseable caller value is treated as "not given" so a bad
|
||||
argument falls back rather than failing the run.
|
||||
"""
|
||||
for candidate in (value, fallback):
|
||||
try:
|
||||
count = int(candidate)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return min(max(1, count), SOFYA_MAX_RESULTS)
|
||||
return 1
|
||||
|
||||
|
||||
def _result_content(result: dict) -> str:
|
||||
"""Prefer the page content, falling back to the search snippet.
|
||||
|
||||
`content` carries the text of the result page and is empty when the page
|
||||
was not read; `description` is the search snippet. Either can be missing, so
|
||||
a result with no text at all returns "" and is dropped rather than stored as
|
||||
a blank chunk.
|
||||
"""
|
||||
content = " ".join(str(result.get("content") or "").split())
|
||||
if content:
|
||||
return content
|
||||
return " ".join(str(result.get("description") or "").split())
|
||||
|
||||
|
||||
def _results(data, top_n: int) -> list[dict]:
|
||||
"""Pull the result list out of the response body."""
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
results = data.get("results")
|
||||
if not isinstance(results, list):
|
||||
return []
|
||||
return [r for r in results if isinstance(r, dict)][:top_n]
|
||||
|
||||
|
||||
class SofyaSearchParam(ToolParamBase):
|
||||
"""
|
||||
Define the Sofya search component parameters.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.meta: ToolMeta = {
|
||||
"name": "sofya_search",
|
||||
"description": """
|
||||
Sofya is a web search API for AI agents. It returns the content of the result
|
||||
pages, not just a snippet, so results carry usable context without a separate
|
||||
fetch. An API key is required.
|
||||
When searching:
|
||||
- Use a focused query of the most important terms (and synonyms).
|
||||
- Use the news topic for current events, and the general topic otherwise.
|
||||
- Optionally restrict results by how recently they were published.
|
||||
""",
|
||||
"parameters": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search keywords to execute with Sofya. The keywords should be the most important words/terms(includes synonyms) from the original request.",
|
||||
"default": "{sys.query}",
|
||||
"required": True,
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "default:'general'. The category of the search. 'news' searches news sources and suits current events; 'general' is a broader web search.",
|
||||
"enum": SOFYA_TOPICS,
|
||||
"default": SOFYA_DEFAULT_TOPIC,
|
||||
"required": False,
|
||||
},
|
||||
"freshness": {
|
||||
"type": "string",
|
||||
"description": "default:'any'. Restrict results by recency. One of 'day', 'week', 'month', 'year', or 'any' for no limit.",
|
||||
"enum": SOFYA_FRESHNESS_VALUES,
|
||||
"default": SOFYA_FRESHNESS_ANY,
|
||||
"required": False,
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Number of results to return, 1 to 20. Leave it out to use the Top N set on the node.",
|
||||
"default": "",
|
||||
"required": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
super().__init__()
|
||||
self.api_key = ""
|
||||
self.search_depth = SOFYA_DEFAULT_SEARCH_DEPTH
|
||||
self.top_n = 10
|
||||
|
||||
def check(self):
|
||||
self.check_empty(self.api_key, "Sofya API key")
|
||||
self.check_valid_value(self.search_depth, "Sofya search depth should be in 'basic/snippets'", ["basic", "snippets"])
|
||||
self.check_positive_integer(self.top_n, "Top N")
|
||||
|
||||
def get_input_form(self) -> dict[str, dict]:
|
||||
return {
|
||||
"query": {
|
||||
"name": "Query",
|
||||
"type": "line",
|
||||
},
|
||||
"topic": {
|
||||
"name": "Topic",
|
||||
"type": "options",
|
||||
"value": SOFYA_DEFAULT_TOPIC,
|
||||
"options": SOFYA_TOPICS,
|
||||
},
|
||||
"freshness": {
|
||||
"name": "Freshness",
|
||||
"type": "options",
|
||||
"value": SOFYA_FRESHNESS_ANY,
|
||||
"options": SOFYA_FRESHNESS_VALUES,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SofyaSearch(ToolBase, ABC):
|
||||
component_name = "SofyaSearch"
|
||||
|
||||
@timeout(int(os.environ.get("COMPONENT_EXEC_TIMEOUT", 12)))
|
||||
def _invoke(self, **kwargs):
|
||||
if self.check_if_canceled("SofyaSearch processing"):
|
||||
return
|
||||
|
||||
if not kwargs.get("query"):
|
||||
self.set_output("formalized_content", "")
|
||||
return ""
|
||||
|
||||
search_depth = _search_depth(self._param.search_depth)
|
||||
top_n = _max_results(kwargs.get("max_results"), self._param.top_n)
|
||||
payload = {
|
||||
"query": kwargs["query"],
|
||||
"search_depth": search_depth,
|
||||
"max_results": top_n,
|
||||
"topic": _topic(kwargs.get("topic")),
|
||||
}
|
||||
freshness = _freshness(kwargs.get("freshness"))
|
||||
if freshness:
|
||||
payload["freshness"] = freshness
|
||||
|
||||
logging.info(f"SofyaSearch: starting search (search_depth={search_depth}, max_results={top_n})")
|
||||
last_e = None
|
||||
attempts = self._param.max_retries + 1
|
||||
for attempt in range(attempts):
|
||||
if self.check_if_canceled("SofyaSearch processing"):
|
||||
logging.info("SofyaSearch: cancelled before request")
|
||||
return
|
||||
|
||||
try:
|
||||
data = _search(self._param.api_key, payload)
|
||||
if self.check_if_canceled("SofyaSearch processing"):
|
||||
logging.info("SofyaSearch: cancelled after request")
|
||||
return
|
||||
|
||||
results = _results(data, top_n)
|
||||
self._retrieve_chunks(
|
||||
results,
|
||||
get_title=lambda r: str(r.get("title") or ""),
|
||||
get_url=lambda r: str(r.get("url") or ""),
|
||||
get_content=_result_content,
|
||||
)
|
||||
self.set_output("json", results)
|
||||
logging.info(f"SofyaSearch: returned {len(results)} results")
|
||||
return self.output("formalized_content")
|
||||
except Exception as e:
|
||||
if self.check_if_canceled("SofyaSearch processing"):
|
||||
return
|
||||
|
||||
# Only the exception type is recorded, so the query and the key
|
||||
# stay out of the log whatever the error message holds.
|
||||
last_e = e
|
||||
logging.error(f"Sofya error: {type(e).__name__}")
|
||||
if not _is_transient(e):
|
||||
break
|
||||
if attempt < attempts - 1:
|
||||
time.sleep(self._param.delay_after_error)
|
||||
|
||||
if last_e:
|
||||
return f"Sofya error: {type(last_e).__name__}"
|
||||
|
||||
assert False, self.output()
|
||||
|
||||
def thoughts(self) -> str:
|
||||
return """
|
||||
Keywords: {}
|
||||
Looking for the most relevant articles.
|
||||
""".format(self.get_input().get("query", "-_-!"))
|
||||
@@ -0,0 +1,412 @@
|
||||
#
|
||||
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import agent.tools.sofya as sofya_module
|
||||
from agent.tools.sofya import SofyaSearch, SofyaSearchParam
|
||||
|
||||
SEARCH_URL = "https://sofya.co/v1/search"
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.url = SEARCH_URL
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise sofya_module.requests.HTTPError(
|
||||
f"{self.status_code} Client Error for url: {self.url}",
|
||||
response=self,
|
||||
)
|
||||
|
||||
|
||||
def _make_tool(api_key="sofya-key"):
|
||||
tool = SofyaSearch.__new__(SofyaSearch)
|
||||
param = SofyaSearchParam()
|
||||
param.api_key = api_key
|
||||
param.delay_after_error = 0
|
||||
param.max_retries = 0
|
||||
tool._param = param
|
||||
tool.check_if_canceled = lambda *args, **kwargs: False
|
||||
|
||||
captured = {}
|
||||
outputs = {}
|
||||
|
||||
def fake_retrieve(results, get_title, get_url, get_content):
|
||||
captured["references"] = [{"title": get_title(item), "url": get_url(item), "content": get_content(item)} for item in results]
|
||||
outputs["formalized_content"] = "FORMALIZED"
|
||||
|
||||
tool._retrieve_chunks = fake_retrieve
|
||||
tool.set_output = lambda key, value: outputs.__setitem__(key, value)
|
||||
tool.output = lambda key=None: outputs.get(key) if key else outputs
|
||||
return tool, captured, outputs
|
||||
|
||||
|
||||
def _payload():
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"url": "https://example.com/a",
|
||||
"title": "A",
|
||||
"description": "Snippet for A.",
|
||||
"content": "Page text for A.",
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/b",
|
||||
"title": "B",
|
||||
"description": "Snippet for B.",
|
||||
"content": "",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _capture_post(monkeypatch, response=None):
|
||||
calls = []
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
calls.append({"url": url, "headers": headers, "json": json})
|
||||
return response if response is not None else _FakeResponse(_payload())
|
||||
|
||||
monkeypatch.setattr(sofya_module.requests, "post", fake_post)
|
||||
return calls
|
||||
|
||||
|
||||
def test_search_posts_the_query_with_a_bearer_key(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, outputs = _make_tool(api_key=" sofya-key ")
|
||||
|
||||
tool._invoke(query="What is RAGFlow?")
|
||||
|
||||
assert calls[0]["url"] == SEARCH_URL
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer sofya-key"
|
||||
assert calls[0]["json"]["query"] == "What is RAGFlow?"
|
||||
assert outputs["formalized_content"] == "FORMALIZED"
|
||||
|
||||
|
||||
def test_requests_identify_ragflow(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert calls[0]["headers"]["User-Agent"] == "RAGFlow sofya-integration/infiniflow-ragflow"
|
||||
|
||||
|
||||
def test_page_content_is_preferred_over_the_snippet(monkeypatch):
|
||||
_capture_post(monkeypatch)
|
||||
tool, captured, outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert captured["references"] == [
|
||||
{"title": "A", "url": "https://example.com/a", "content": "Page text for A."},
|
||||
# B has no page content, so its snippet is used.
|
||||
{"title": "B", "url": "https://example.com/b", "content": "Snippet for B."},
|
||||
]
|
||||
assert len(outputs["json"]) == 2
|
||||
|
||||
|
||||
def test_non_string_result_fields_do_not_raise(monkeypatch):
|
||||
payload = {"results": [{"url": 42, "title": True, "content": 7, "description": None}]}
|
||||
_capture_post(monkeypatch, _FakeResponse(payload))
|
||||
tool, captured, _outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert captured["references"] == [{"title": "True", "url": "42", "content": "7"}]
|
||||
|
||||
|
||||
def test_whitespace_only_content_falls_back_to_the_snippet(monkeypatch):
|
||||
payload = {"results": [{"url": "https://example.com/c", "title": "C", "content": " ", "description": " Snippet for C. "}]}
|
||||
_capture_post(monkeypatch, _FakeResponse(payload))
|
||||
tool, captured, _outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert captured["references"] == [{"title": "C", "url": "https://example.com/c", "content": "Snippet for C."}]
|
||||
|
||||
|
||||
def test_a_result_with_no_text_at_all_yields_no_content(monkeypatch):
|
||||
payload = {"results": [{"url": "https://example.com/d", "title": "D", "content": " ", "description": " "}]}
|
||||
_capture_post(monkeypatch, _FakeResponse(payload))
|
||||
tool, captured, _outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
# An empty content is dropped by _retrieve_chunks rather than stored blank.
|
||||
assert captured["references"] == [{"title": "D", "url": "https://example.com/d", "content": ""}]
|
||||
|
||||
|
||||
def test_a_results_field_that_is_not_a_list_yields_nothing(monkeypatch):
|
||||
_capture_post(monkeypatch, _FakeResponse({"results": None}))
|
||||
tool, captured, _outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert captured["references"] == []
|
||||
|
||||
|
||||
def test_the_node_top_n_is_used_when_the_caller_gives_no_count(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, captured, _outputs = _make_tool()
|
||||
tool._param.top_n = 1
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert calls[0]["json"]["max_results"] == 1
|
||||
assert [r["title"] for r in captured["references"]] == ["A"]
|
||||
|
||||
|
||||
def test_a_caller_supplied_count_wins_over_the_node_top_n(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
tool._param.top_n = 10
|
||||
|
||||
tool._invoke(query="q", max_results=3)
|
||||
|
||||
assert calls[0]["json"]["max_results"] == 3
|
||||
|
||||
|
||||
def test_the_result_count_is_clamped_to_the_supported_range(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q", max_results=500)
|
||||
assert calls[0]["json"]["max_results"] == 20
|
||||
|
||||
tool._invoke(query="q", max_results=0)
|
||||
assert calls[1]["json"]["max_results"] == 1
|
||||
|
||||
# An unusable argument falls back to the node's Top N.
|
||||
tool._invoke(query="q", max_results="lots")
|
||||
assert calls[2]["json"]["max_results"] == tool._param.top_n
|
||||
|
||||
|
||||
def test_an_unknown_search_depth_falls_back_to_the_default(monkeypatch, caplog):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
tool._param.search_depth = "advanced"
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert calls[0]["json"]["search_depth"] == "basic"
|
||||
assert "advanced" in caplog.text
|
||||
|
||||
|
||||
def test_the_search_depth_is_forwarded_when_it_is_supported(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
tool._param.search_depth = "snippets"
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert calls[0]["json"]["search_depth"] == "snippets"
|
||||
|
||||
|
||||
def test_freshness_is_forwarded_only_when_a_window_is_set(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q", freshness="week")
|
||||
assert calls[0]["json"]["freshness"] == "week"
|
||||
|
||||
# Blank and `any` both mean no restriction.
|
||||
tool._invoke(query="q", freshness="")
|
||||
assert "freshness" not in calls[1]["json"]
|
||||
|
||||
tool._invoke(query="q", freshness="any")
|
||||
assert "freshness" not in calls[2]["json"]
|
||||
|
||||
|
||||
def test_an_unsupported_freshness_is_rejected_before_the_request(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
|
||||
try:
|
||||
tool._invoke(query="q", freshness="decade")
|
||||
except ValueError as e:
|
||||
assert "decade" in str(e)
|
||||
assert calls == []
|
||||
return
|
||||
raise AssertionError("expected an unsupported freshness to raise")
|
||||
|
||||
|
||||
def test_an_unsupported_topic_is_rejected_before_the_request(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
|
||||
try:
|
||||
tool._invoke(query="q", topic="sports")
|
||||
except ValueError as e:
|
||||
assert "sports" in str(e)
|
||||
assert calls == []
|
||||
return
|
||||
raise AssertionError("expected an unsupported topic to raise")
|
||||
|
||||
|
||||
def test_the_topic_defaults_to_general(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
|
||||
tool._invoke(query="q")
|
||||
assert calls[0]["json"]["topic"] == "general"
|
||||
|
||||
tool._invoke(query="q", topic="news")
|
||||
assert calls[1]["json"]["topic"] == "news"
|
||||
|
||||
|
||||
def test_the_published_schema_restricts_the_enums():
|
||||
properties = SofyaSearchParam().get_meta()["function"]["parameters"]["properties"]
|
||||
|
||||
assert properties["topic"]["enum"] == ["general", "news"]
|
||||
assert properties["freshness"]["enum"] == ["any", "day", "week", "month", "year"]
|
||||
|
||||
|
||||
def test_blank_query_short_circuits(monkeypatch):
|
||||
calls = _capture_post(monkeypatch)
|
||||
tool, _captured, outputs = _make_tool()
|
||||
|
||||
assert tool._invoke(query="") == ""
|
||||
assert calls == []
|
||||
assert outputs["formalized_content"] == ""
|
||||
|
||||
|
||||
def test_failures_never_log_the_query_or_the_key(monkeypatch, caplog):
|
||||
_capture_post(monkeypatch, _FakeResponse({}, status_code=402))
|
||||
tool, _captured, _outputs = _make_tool(api_key="sofya-secret")
|
||||
|
||||
with caplog.at_level("ERROR"):
|
||||
result = tool._invoke(query="my private query")
|
||||
|
||||
assert "my private query" not in caplog.text
|
||||
assert "sofya-secret" not in caplog.text
|
||||
assert "my private query" not in str(result)
|
||||
assert "HTTPError" in str(result)
|
||||
|
||||
|
||||
def _capture_sleep(monkeypatch):
|
||||
"""Record the retry delays without patching the stdlib for other threads."""
|
||||
slept = []
|
||||
|
||||
class _Clock:
|
||||
@staticmethod
|
||||
def sleep(seconds):
|
||||
slept.append(seconds)
|
||||
|
||||
monkeypatch.setattr(sofya_module, "time", _Clock)
|
||||
return slept
|
||||
|
||||
|
||||
def test_a_failed_final_attempt_does_not_sleep(monkeypatch):
|
||||
"""The delay only buys something when another attempt follows it."""
|
||||
_capture_post(monkeypatch, _FakeResponse({}, status_code=402))
|
||||
slept = _capture_sleep(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
tool._param.max_retries = 0
|
||||
tool._param.delay_after_error = 5
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert slept == []
|
||||
|
||||
|
||||
def test_transient_failures_are_retried_with_a_delay(monkeypatch):
|
||||
calls = _capture_post(monkeypatch, _FakeResponse({}, status_code=503))
|
||||
slept = _capture_sleep(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
tool._param.max_retries = 2
|
||||
tool._param.delay_after_error = 5
|
||||
|
||||
tool._invoke(query="q")
|
||||
|
||||
assert len(calls) == 3
|
||||
# Two gaps between three attempts, and nothing after the last one.
|
||||
assert slept == [5, 5]
|
||||
|
||||
|
||||
def test_non_transient_failures_are_not_retried(monkeypatch):
|
||||
"""A bad key or no credits fails the same way every time, so ask once."""
|
||||
calls = _capture_post(monkeypatch, _FakeResponse({}, status_code=402))
|
||||
slept = _capture_sleep(monkeypatch)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
tool._param.max_retries = 2
|
||||
tool._param.delay_after_error = 5
|
||||
|
||||
result = tool._invoke(query="q")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert slept == []
|
||||
assert "HTTPError" in str(result)
|
||||
|
||||
|
||||
def test_network_errors_are_retried(monkeypatch):
|
||||
slept = _capture_sleep(monkeypatch)
|
||||
attempts = []
|
||||
|
||||
def failing_post(url, headers=None, json=None, timeout=None):
|
||||
attempts.append(url)
|
||||
raise sofya_module.requests.ConnectionError("connection reset")
|
||||
|
||||
monkeypatch.setattr(sofya_module.requests, "post", failing_post)
|
||||
tool, _captured, _outputs = _make_tool()
|
||||
tool._param.max_retries = 1
|
||||
tool._param.delay_after_error = 2
|
||||
|
||||
result = tool._invoke(query="q")
|
||||
|
||||
assert len(attempts) == 2
|
||||
assert slept == [2]
|
||||
assert "ConnectionError" in str(result)
|
||||
|
||||
|
||||
def test_param_check_requires_an_api_key():
|
||||
param = SofyaSearchParam()
|
||||
|
||||
try:
|
||||
param.check()
|
||||
except Exception:
|
||||
return
|
||||
raise AssertionError("expected check() to reject a blank API key")
|
||||
|
||||
|
||||
def test_param_check_rejects_an_unknown_search_depth():
|
||||
param = SofyaSearchParam()
|
||||
param.api_key = "sofya-key"
|
||||
param.search_depth = "advanced"
|
||||
|
||||
try:
|
||||
param.check()
|
||||
except Exception:
|
||||
return
|
||||
raise AssertionError("expected check() to reject an unknown search depth")
|
||||
|
||||
|
||||
def test_param_check_rejects_a_non_positive_top_n():
|
||||
param = SofyaSearchParam()
|
||||
param.api_key = "sofya-key"
|
||||
param.top_n = 0
|
||||
|
||||
try:
|
||||
param.check()
|
||||
except Exception:
|
||||
return
|
||||
raise AssertionError("expected check() to reject top_n=0")
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="24" height="24" fill="none" role="img" aria-label="Sofya">
|
||||
<title>Sofya</title>
|
||||
<g transform="translate(0 -24)">
|
||||
<path d="M 80 400 L 80 300 C 80 220, 140 160, 210 160 L 210 400 Z" fill="#b0b820"/>
|
||||
<path d="M 210 160 L 242 190 L 242 385 L 210 400 Z" fill="#707818"/>
|
||||
<path d="M 302 160 L 270 190 L 270 385 L 302 400 Z" fill="#707818"/>
|
||||
<path d="M 432 400 L 432 300 C 432 220, 372 160, 302 160 L 302 400 Z" fill="#b0b820"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
@@ -73,6 +73,7 @@ export const SVGIconMap = {
|
||||
[Operator.SearXNG]: 'searxng',
|
||||
[Operator.KeenableSearch]: 'keenable',
|
||||
[Operator.YouComSearch]: 'youcom',
|
||||
[Operator.SofyaSearch]: 'sofya',
|
||||
[Operator.TavilyExtract]: 'tavily',
|
||||
[Operator.TavilySearch]: 'tavily',
|
||||
[Operator.QueritContents]: 'querit',
|
||||
|
||||
@@ -119,6 +119,7 @@ export enum Operator {
|
||||
BGPT = 'BGPT',
|
||||
KeenableSearch = 'KeenableSearch',
|
||||
YouComSearch = 'YouComSearch',
|
||||
SofyaSearch = 'SofyaSearch',
|
||||
DocGenerator = 'DocGenerator',
|
||||
Browser = 'Browser',
|
||||
Placeholder = 'Placeholder',
|
||||
|
||||
@@ -2781,6 +2781,15 @@ Best for: Documents with flowing, contextually connected content — such as boo
|
||||
youComFreshnessMonth: 'Past month',
|
||||
youComFreshnessYear: 'Past year',
|
||||
youComApiKeyTip: 'Optional. Leave blank to use the keyless free tier.',
|
||||
sofyaSearch: 'Sofya',
|
||||
sofyaSearchDescription:
|
||||
'A web search component powered by Sofya. Results carry the content of each result page, or only its search snippet, depending on the search depth. An API Key is required.',
|
||||
sofyaSearchDepth: 'Search depth',
|
||||
sofyaSearchDepthTip:
|
||||
'Basic returns the content of each result page. Snippets returns the search snippets only, which is faster and costs less.',
|
||||
sofyaSearchDepthBasic: 'Page content',
|
||||
sofyaSearchDepthSnippets: 'Snippets only',
|
||||
sofyaApiKeyTip: 'Required. Create an API Key at sofya.co.',
|
||||
docGenerator: 'Doc Generator',
|
||||
docGeneratorDescription: `Generate a file from Markdown content.`,
|
||||
browser: 'Browser',
|
||||
|
||||
@@ -2405,6 +2405,15 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取 Entities 和 R
|
||||
youComFreshnessMonth: '一个月内',
|
||||
youComFreshnessYear: '一年内',
|
||||
youComApiKeyTip: '可选。留空则使用免密钥的免费额度。',
|
||||
sofyaSearch: 'Sofya',
|
||||
sofyaSearchDescription:
|
||||
'基于 Sofya 的网络搜索组件。根据搜索深度,结果附带结果页面的正文内容或仅附带搜索摘要。需要配置 API Key。',
|
||||
sofyaSearchDepth: '搜索深度',
|
||||
sofyaSearchDepthTip:
|
||||
'Basic 返回每条结果页面的正文内容;Snippets 只返回搜索摘要,速度更快、消耗更少。',
|
||||
sofyaSearchDepthBasic: '页面正文',
|
||||
sofyaSearchDepthSnippets: '仅摘要',
|
||||
sofyaApiKeyTip: '必填。请在 sofya.co 申请 API Key。',
|
||||
docGenerator: '文档生成器',
|
||||
docGeneratorDescription: `从 Markdown 内容生成文件。`,
|
||||
browser: 'Browser',
|
||||
|
||||
@@ -127,6 +127,7 @@ export function AccordionOperators({
|
||||
Operator.SearXNG,
|
||||
Operator.KeenableSearch,
|
||||
Operator.YouComSearch,
|
||||
Operator.SofyaSearch,
|
||||
Operator.DocGenerator,
|
||||
Operator.Browser,
|
||||
]}
|
||||
|
||||
@@ -220,6 +220,28 @@ export const initialYouComValues = {
|
||||
},
|
||||
};
|
||||
|
||||
export enum SofyaSearchDepth {
|
||||
Basic = 'basic',
|
||||
Snippets = 'snippets',
|
||||
}
|
||||
|
||||
export const initialSofyaValues = {
|
||||
api_key: '',
|
||||
query: AgentGlobals.SysQuery,
|
||||
search_depth: SofyaSearchDepth.Basic,
|
||||
top_n: 10,
|
||||
outputs: {
|
||||
formalized_content: {
|
||||
value: '',
|
||||
type: 'string',
|
||||
},
|
||||
json: {
|
||||
value: [],
|
||||
type: 'Array<Object>',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const initialKeenableValues = {
|
||||
api_key: '',
|
||||
query: AgentGlobals.SysQuery,
|
||||
@@ -775,6 +797,7 @@ export const RestrictedUpstreamMap = {
|
||||
[Operator.SearXNG]: [Operator.Begin, Operator.Retrieval],
|
||||
[Operator.KeenableSearch]: [Operator.Begin, Operator.Retrieval],
|
||||
[Operator.YouComSearch]: [Operator.Begin, Operator.Retrieval],
|
||||
[Operator.SofyaSearch]: [Operator.Begin, Operator.Retrieval],
|
||||
[Operator.ExeSQL]: [Operator.Begin],
|
||||
[Operator.Switch]: [Operator.Begin],
|
||||
[Operator.WenCai]: [Operator.Begin],
|
||||
@@ -832,6 +855,7 @@ export const NodeMap = {
|
||||
[Operator.SearXNG]: 'ragNode',
|
||||
[Operator.KeenableSearch]: 'ragNode',
|
||||
[Operator.YouComSearch]: 'ragNode',
|
||||
[Operator.SofyaSearch]: 'ragNode',
|
||||
[Operator.ExeSQL]: 'ragNode',
|
||||
[Operator.Switch]: 'switchNode',
|
||||
[Operator.WenCai]: 'ragNode',
|
||||
|
||||
@@ -21,6 +21,7 @@ import InvokeForm from '../form/invoke-form';
|
||||
import IterationForm from '../form/iteration-form';
|
||||
import IterationStartForm from '../form/iteration-start-from';
|
||||
import KeenableForm from '../form/keenable-form';
|
||||
import SofyaForm from '../form/sofya-form';
|
||||
import YouComForm from '../form/youcom-form';
|
||||
import ListOperationsForm from '../form/list-operations-form';
|
||||
import LoopForm from '../form/loop-form';
|
||||
@@ -82,6 +83,9 @@ export const FormConfigMap = {
|
||||
[Operator.YouComSearch]: {
|
||||
component: YouComForm,
|
||||
},
|
||||
[Operator.SofyaSearch]: {
|
||||
component: SofyaForm,
|
||||
},
|
||||
[Operator.Wikipedia]: {
|
||||
component: WikipediaForm,
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ const Menus = [
|
||||
Operator.SearXNG,
|
||||
Operator.KeenableSearch,
|
||||
Operator.YouComSearch,
|
||||
Operator.SofyaSearch,
|
||||
Operator.YahooFinance,
|
||||
Operator.PubMed,
|
||||
Operator.BGPT,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { FormContainer } from '@/components/form-container';
|
||||
import { TopNFormField } from '@/components/top-n-item';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { RAGFlowSelect } from '@/components/ui/select';
|
||||
import { useTranslate } from '@/hooks/common-hooks';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useForm, useFormContext } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { SofyaSearchDepth, initialSofyaValues } from '../../constant';
|
||||
import { useFormValues } from '../../hooks/use-form-values';
|
||||
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
|
||||
import { INextOperatorForm } from '../../interface';
|
||||
import { buildOutputList } from '../../utils/build-output-list';
|
||||
import { ApiKeyField } from '../components/api-key-field';
|
||||
import { FormWrapper } from '../components/form-wrapper';
|
||||
import { Output } from '../components/output';
|
||||
import { QueryVariable } from '../components/query-variable';
|
||||
|
||||
// Search depth is a user-facing word, so each option carries its own key rather
|
||||
// than deriving an English label from the enum value.
|
||||
const SofyaSearchDepthLabelKeys: Record<SofyaSearchDepth, string> = {
|
||||
[SofyaSearchDepth.Basic]: 'sofyaSearchDepthBasic',
|
||||
[SofyaSearchDepth.Snippets]: 'sofyaSearchDepthSnippets',
|
||||
};
|
||||
|
||||
export const SofyaFormPartialSchema = {
|
||||
api_key: z.string().optional(),
|
||||
search_depth: z.string().optional(),
|
||||
top_n: z.coerce.number(),
|
||||
};
|
||||
|
||||
const FormSchema = z.object({
|
||||
query: z.string(),
|
||||
...SofyaFormPartialSchema,
|
||||
});
|
||||
|
||||
export function SofyaWidgets() {
|
||||
const { t } = useTranslate('flow');
|
||||
const form = useFormContext();
|
||||
|
||||
const searchDepthOptions = useMemo(
|
||||
() =>
|
||||
Object.values(SofyaSearchDepth).map((x) => ({
|
||||
value: x,
|
||||
label: t(SofyaSearchDepthLabelKeys[x]),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ApiKeyField placeholder={t('sofyaApiKeyTip')}></ApiKeyField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'search_depth'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel tooltip={t('sofyaSearchDepthTip')}>
|
||||
{t('sofyaSearchDepth')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RAGFlowSelect {...field} options={searchDepthOptions} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TopNFormField></TopNFormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SofyaOutputList = buildOutputList(initialSofyaValues.outputs);
|
||||
|
||||
function SofyaForm({ node }: INextOperatorForm) {
|
||||
const defaultValues = useFormValues(initialSofyaValues, node);
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(FormSchema),
|
||||
});
|
||||
|
||||
useWatchFormChange(node?.id, form);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormWrapper>
|
||||
<FormContainer>
|
||||
<QueryVariable></QueryVariable>
|
||||
<SofyaWidgets></SofyaWidgets>
|
||||
</FormContainer>
|
||||
</FormWrapper>
|
||||
<div className="p-5">
|
||||
<Output list={SofyaOutputList}></Output>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SofyaForm);
|
||||
@@ -9,6 +9,7 @@ import GithubForm from './github-form';
|
||||
import GoogleForm from './google-form';
|
||||
import GoogleScholarForm from './google-scholar-form';
|
||||
import KeenableForm from './keenable-form';
|
||||
import SofyaForm from './sofya-form';
|
||||
import YouComForm from './youcom-form';
|
||||
import PubMedForm from './pubmed-form';
|
||||
import QueritForm from './querit-form';
|
||||
@@ -44,4 +45,5 @@ export const ToolFormConfigMap = {
|
||||
[Operator.SearXNG]: SearXNGForm,
|
||||
[Operator.KeenableSearch]: KeenableForm,
|
||||
[Operator.YouComSearch]: YouComForm,
|
||||
[Operator.SofyaSearch]: SofyaForm,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FormContainer } from '@/components/form-container';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { memo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { FormWrapper } from '../../components/form-wrapper';
|
||||
import { SofyaFormPartialSchema, SofyaWidgets } from '../../sofya-form';
|
||||
import { useValues } from '../use-values';
|
||||
import { useWatchFormChange } from '../use-watch-change';
|
||||
|
||||
function SofyaForm() {
|
||||
const values = useValues();
|
||||
|
||||
const FormSchema = z.object(SofyaFormPartialSchema);
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
defaultValues: values,
|
||||
resolver: zodResolver(FormSchema),
|
||||
});
|
||||
|
||||
useWatchFormChange(form);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormWrapper>
|
||||
<FormContainer>
|
||||
<SofyaWidgets></SofyaWidgets>
|
||||
</FormContainer>
|
||||
</FormWrapper>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SofyaForm);
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
initialIterationStartValues,
|
||||
initialIterationValues,
|
||||
initialKeenableValues,
|
||||
initialSofyaValues,
|
||||
initialYouComValues,
|
||||
initialListOperationsValues,
|
||||
initialLoopValues,
|
||||
@@ -176,6 +177,7 @@ export const useInitializeOperatorParams = () => {
|
||||
[Operator.QueritSearch]: initialQueritValues,
|
||||
[Operator.KeenableSearch]: initialKeenableValues,
|
||||
[Operator.YouComSearch]: initialYouComValues,
|
||||
[Operator.SofyaSearch]: initialSofyaValues,
|
||||
[Operator.UserFillUp]: initialUserFillUpValues,
|
||||
[Operator.StringTransform]: initialStringTransformValues,
|
||||
[Operator.TavilyExtract]: initialTavilyExtractValues,
|
||||
|
||||
@@ -70,6 +70,8 @@ export function useAgentToolInitialValues() {
|
||||
return pick(initialValues, 'api_key', 'mode', 'site', 'top_n');
|
||||
case Operator.YouComSearch:
|
||||
return pick(initialValues, 'api_key', 'freshness', 'top_n');
|
||||
case Operator.SofyaSearch:
|
||||
return pick(initialValues, 'api_key', 'search_depth', 'top_n');
|
||||
|
||||
default:
|
||||
return initialValues;
|
||||
|
||||
@@ -33,6 +33,7 @@ type IToolIcon =
|
||||
| Operator.QueritSearch
|
||||
| Operator.KeenableSearch
|
||||
| Operator.YouComSearch
|
||||
| Operator.SofyaSearch
|
||||
| Operator.Wikipedia
|
||||
| Operator.YahooFinance
|
||||
| Operator.WenCai
|
||||
|
||||
@@ -5,6 +5,7 @@ jest.mock('@/constants/agent', () => ({
|
||||
Google: 'Google',
|
||||
KeenableSearch: 'KeenableSearch',
|
||||
YouComSearch: 'YouComSearch',
|
||||
SofyaSearch: 'SofyaSearch',
|
||||
BGPT: 'Bing',
|
||||
QueritContents: 'QueritContents',
|
||||
QueritSearch: 'QueritSearch',
|
||||
@@ -146,6 +147,42 @@ describe('clearSensitiveFields', () => {
|
||||
expect(dsl.tools[0].params.api_key).toBe('ydc-tool-secret');
|
||||
});
|
||||
|
||||
it('clears a Sofya key from a canvas node and from a tool record', () => {
|
||||
const dsl = {
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
data: {
|
||||
label: Operator.SofyaSearch,
|
||||
form: {
|
||||
api_key: 'sofya-graph-secret',
|
||||
search_depth: 'basic',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
component_name: Operator.SofyaSearch,
|
||||
params: {
|
||||
api_key: 'sofya-tool-secret',
|
||||
top_n: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sanitized = clearSensitiveFields(dsl);
|
||||
|
||||
expect(sanitized.graph.nodes[0].data.form.api_key).toBe('');
|
||||
expect(sanitized.graph.nodes[0].data.form.search_depth).toBe('basic');
|
||||
expect(sanitized.tools[0].params.api_key).toBe('');
|
||||
expect(sanitized.tools[0].params.top_n).toBe(10);
|
||||
expect(dsl.graph.nodes[0].data.form.api_key).toBe('sofya-graph-secret');
|
||||
expect(dsl.tools[0].params.api_key).toBe('sofya-tool-secret');
|
||||
});
|
||||
|
||||
it('does not change standalone graph export behavior for other tools', () => {
|
||||
const dsl = {
|
||||
graph: {
|
||||
|
||||
@@ -7,6 +7,7 @@ const apiKeyOperators = [
|
||||
Operator.Google,
|
||||
Operator.KeenableSearch,
|
||||
Operator.YouComSearch,
|
||||
Operator.SofyaSearch,
|
||||
Operator.BGPT,
|
||||
Operator.QueritContents,
|
||||
Operator.QueritSearch,
|
||||
@@ -15,7 +16,10 @@ const apiKeyOperators = [
|
||||
// Canvas nodes carry the operator under `data.label` and the key under
|
||||
// `data.form.api_key`, so a graph export has to be sanitized separately from the
|
||||
// agent-tool records above.
|
||||
const nodeLabelApiKeyOperators: string[] = [Operator.YouComSearch];
|
||||
const nodeLabelApiKeyOperators: string[] = [
|
||||
Operator.YouComSearch,
|
||||
Operator.SofyaSearch,
|
||||
];
|
||||
|
||||
function isQueritOperator(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
|
||||
Reference in new issue
Block a user