feat(go-agent): Ported retrieval node, added Keenable web search tool (#16396)

Ported retrieval node, added Keenable web search tool
- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Zhichang Yu
2026-06-26 22:55:49 +08:00
committed by GitHub
parent 9d18f33296
commit 70546ea406
91 changed files with 5920 additions and 3817 deletions
+30 -6
View File
@@ -415,6 +415,7 @@ class Canvas(Graph):
if not self.globals["sys.conversation_turns"] : if not self.globals["sys.conversation_turns"] :
self.globals["sys.conversation_turns"] = 0 self.globals["sys.conversation_turns"] = 0
self.globals["sys.conversation_turns"] += 1 self.globals["sys.conversation_turns"] += 1
is_resume = bool(self.path) and self.path[0].lower().find("userfillup") >= 0
def decorate(event, dt): def decorate(event, dt):
nonlocal created_at nonlocal created_at
@@ -427,16 +428,16 @@ class Canvas(Graph):
"data": dt "data": dt
} }
if not self.path or self.path[-1].lower().find("userfillup") < 0: if not is_resume:
self.path.append("begin") self.path.append("begin")
self.retrieval.append({"chunks": [], "doc_aggs": []}) self.retrieval.append({"chunks": [], "doc_aggs": []})
if self.is_canceled(): if self.is_canceled():
msg = f"Task {self.task_id} has been canceled before starting." msg = f"Task {self.task_id} has been canceled before starting."
logging.info(msg) logging.info(msg)
raise TaskCanceledException(msg) raise TaskCanceledException(msg)
yield decorate("workflow_started", {"inputs": kwargs.get("inputs")}) if not is_resume:
yield decorate("workflow_started", {"inputs": kwargs.get("inputs")})
self.retrieval.append({"chunks": {}, "doc_aggs": {}}) self.retrieval.append({"chunks": {}, "doc_aggs": {}})
async def _run_batch(f, t): async def _run_batch(f, t):
@@ -501,7 +502,7 @@ class Canvas(Graph):
}) })
self.error = "" self.error = ""
idx = len(self.path) - 1 idx = 0 if is_resume else len(self.path) - 1
partials = [] partials = []
tts_mdl = None tts_mdl = None
while idx < len(self.path): while idx < len(self.path):
@@ -647,9 +648,14 @@ class Canvas(Graph):
o = self.get_component_obj(c) o = self.get_component_obj(c)
if o.component_name.lower() == "userfillup": if o.component_name.lower() == "userfillup":
o.invoke() o.invoke()
another_inputs.update(o.get_input_elements()) another_inputs.update({
k: v for k, v in o.get_input_elements().items()
if not self._is_input_field_satisfied(v)
})
if o.get_param("enable_tips"): if o.get_param("enable_tips"):
tips = o.output("tips") tips = o.output("tips")
if not another_inputs:
continue
self.path = path self.path = path
yield decorate("user_inputs", {"inputs": another_inputs, "tips": tips}) yield decorate("user_inputs", {"inputs": another_inputs, "tips": tips})
return return
@@ -734,7 +740,25 @@ class Canvas(Graph):
def add_user_input(self, question): def add_user_input(self, question):
self.history.append(("user", question)) self.history.append(("user", question))
self.globals["sys.history"].append(f"{self.history[-1][0]}: {self.history[-1][1]}") rendered = json.dumps(question, ensure_ascii=False) if isinstance(question, dict) else question
self.globals["sys.history"].append(f"{self.history[-1][0]}: {rendered}")
@staticmethod
def _is_input_field_satisfied(field: Any) -> bool:
if not isinstance(field, dict):
return field is not None
value = field.get("value")
field_type = str(field.get("type", "")).lower()
if field_type.find("file") >= 0:
if field.get("optional") and value is None:
return True
return value not in (None, [], "")
if value is None:
return False
return True
def get_prologue(self): def get_prologue(self):
return self.components["begin"]["obj"]._param.prologue return self.components["begin"]["obj"]._param.prologue
+3 -13
View File
@@ -14,7 +14,6 @@
# limitations under the License. # limitations under the License.
# #
from agent.component.fillup import UserFillUpParam, UserFillUp from agent.component.fillup import UserFillUpParam, UserFillUp
from api.db.services.file_service import FileService
class BeginParam(UserFillUpParam): class BeginParam(UserFillUpParam):
@@ -42,20 +41,11 @@ class Begin(UserFillUp):
return return
layout_recognize = self._param.layout_recognize or None layout_recognize = self._param.layout_recognize or None
for k, v in kwargs.get("inputs", {}).items(): merged_inputs = self._merge_runtime_inputs(kwargs.get("inputs", {}))
for k, v in merged_inputs.items():
if self.check_if_canceled("Begin processing"): if self.check_if_canceled("Begin processing"):
return return
v = self._resolve_input_value(v, layout_recognize)
if isinstance(v, dict) and v.get("type", "").lower().find("file") >= 0:
if v.get("optional") and v.get("value", None) is None:
v = None
else:
file_value = v["value"]
# Support both single file (backward compatibility) and multiple files
files = file_value if isinstance(file_value, list) else [file_value]
v = FileService.get_files(files, layout_recognize=layout_recognize)
else:
v = v.get("value")
self.set_output(k, v) self.set_output(k, v)
self.set_input_value(k, v) self.set_input_value(k, v)
+54 -12
View File
@@ -21,6 +21,9 @@ from agent.component.base import ComponentParamBase, ComponentBase
from api.db.services.file_service import FileService from api.db.services.file_service import FileService
_INITIAL_USER_INPUT_CONSUMED_KEY = "sys.__initial_user_input_consumed__"
class UserFillUpParam(ComponentParamBase): class UserFillUpParam(ComponentParamBase):
def __init__(self): def __init__(self):
@@ -36,6 +39,52 @@ class UserFillUpParam(ComponentParamBase):
class UserFillUp(ComponentBase): class UserFillUp(ComponentBase):
component_name = "UserFillUp" component_name = "UserFillUp"
def _merge_runtime_inputs(self, runtime_inputs):
if runtime_inputs:
return runtime_inputs
fields = self.get_input_elements()
if not fields:
return {}
if self._canvas.globals.get(_INITIAL_USER_INPUT_CONSUMED_KEY):
return {}
query = self._canvas.globals.get("sys.query")
if query is None or query == "":
return {}
if isinstance(query, dict):
matched = {
key: value if isinstance(value, dict) else {"value": value}
for key, value in query.items()
if key in fields
}
if matched:
self._canvas.globals[_INITIAL_USER_INPUT_CONSUMED_KEY] = True
return matched
if len(fields) == 1:
field_name = next(iter(fields))
self._canvas.globals[_INITIAL_USER_INPUT_CONSUMED_KEY] = True
return {field_name: {"value": query}}
return {}
def _resolve_input_value(self, value, layout_recognize):
if isinstance(value, dict) and value.get("type", "").lower().find("file") >= 0:
if value.get("optional") and value.get("value", None) is None:
return None
file_value = value["value"]
files = file_value if isinstance(file_value, list) else [file_value]
return FileService.get_files(files, layout_recognize=layout_recognize)
if isinstance(value, dict):
return value.get("value")
return value
def _invoke(self, **kwargs): def _invoke(self, **kwargs):
if self.check_if_canceled("UserFillUp processing"): if self.check_if_canceled("UserFillUp processing"):
return return
@@ -63,20 +112,13 @@ class UserFillUp(ComponentBase):
self.set_output("tips", content) self.set_output("tips", content)
layout_recognize = self._param.layout_recognize or None layout_recognize = self._param.layout_recognize or None
for k, v in kwargs.get("inputs", {}).items(): merged_inputs = self._merge_runtime_inputs(kwargs.get("inputs", {}))
for k, v in merged_inputs.items():
if self.check_if_canceled("UserFillUp processing"): if self.check_if_canceled("UserFillUp processing"):
return return
if isinstance(v, dict) and v.get("type", "").lower().find("file") >= 0: resolved = self._resolve_input_value(v, layout_recognize)
if v.get("optional") and v.get("value", None) is None: self.set_output(k, resolved)
v = None self.set_input_value(k, resolved)
else:
file_value = v["value"]
# Support both single file (backward compatibility) and multiple files
files = file_value if isinstance(file_value, list) else [file_value]
v = FileService.get_files(files, layout_recognize=layout_recognize)
else:
v = v.get("value")
self.set_output(k, v)
def thoughts(self) -> str: def thoughts(self) -> str:
return "Waiting for your input..." return "Waiting for your input..."
+8
View File
@@ -38,9 +38,17 @@ class ListOperationsParam(ComponentParamBase):
"type": "?" "type": "?"
} }
} }
@staticmethod
def _normalize_operation_name(operation):
op = "" if operation is None else str(operation).strip()
if op.lower() == "topn":
return "head"
return op or "nth"
def check(self): def check(self):
self.check_empty(self.query, "query") self.check_empty(self.query, "query")
self.operations = self._normalize_operation_name(self.operations)
self.check_valid_value( self.check_valid_value(
self.operations, self.operations,
"Support operations", "Support operations",
@@ -226,7 +226,7 @@ class AliyunCodeInterpreterProvider(SandboxProvider):
# Connect to existing sandbox instance # Connect to existing sandbox instance
sandbox = Sandbox.connect(sandbox_id=instance_id, config=self._config) sandbox = Sandbox.connect(sandbox_id=instance_id, config=self._config)
# agentrun-sdk 0.0.26 only exposes CodeLanguage.PYTHON; keep JS as string fallback. # CodeLanguage enum only exposes PYTHON across agentrun-sdk 0.0.26+; keep JS as string fallback.
code_language = CodeLanguage.PYTHON if normalized_lang == "python" else "javascript" code_language = CodeLanguage.PYTHON if normalized_lang == "python" else "javascript"
# Wrap code to call main() function # Wrap code to call main() function
@@ -355,7 +355,7 @@ class AliyunCodeInterpreterProvider(SandboxProvider):
# Try to list templates to verify connection # Try to list templates to verify connection
from agentrun.sandbox import Template from agentrun.sandbox import Template
templates = Template.list(config=self._config) templates = Template.list_templates(config=self._config)
return templates is not None return templates is not None
except Exception as e: except Exception as e:
@@ -45,11 +45,11 @@ class TestAliyunCodeInterpreterProvider:
assert provider.timeout == 30 assert provider.timeout == 30
assert not provider._initialized assert not provider._initialized
@patch("agent.sandbox.providers.aliyun_codeinterpreter.Template") @patch("agentrun.sandbox.Template")
def test_initialize_success(self, mock_template): def test_initialize_success(self, mock_template):
"""Test successful initialization.""" """Test successful initialization."""
# Mock health check response # Mock health check response
mock_template.list.return_value = [] mock_template.list_templates.return_value = []
provider = AliyunCodeInterpreterProvider() provider = AliyunCodeInterpreterProvider()
result = provider.initialize( result = provider.initialize(
@@ -89,10 +89,10 @@ class TestAliyunCodeInterpreterProvider:
result = provider2.initialize({"access_key_id": "LTAI5tXXXXXXXXXX", "access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}) result = provider2.initialize({"access_key_id": "LTAI5tXXXXXXXXXX", "access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"})
assert result is False assert result is False
@patch("agent.sandbox.providers.aliyun_codeinterpreter.Template") @patch("agentrun.sandbox.Template")
def test_initialize_default_config(self, mock_template): def test_initialize_default_config(self, mock_template):
"""Test initialization with default config.""" """Test initialization with default config."""
mock_template.list.return_value = [] mock_template.list_templates.return_value = []
provider = AliyunCodeInterpreterProvider() provider = AliyunCodeInterpreterProvider()
result = provider.initialize({"access_key_id": "LTAI5tXXXXXXXXXX", "access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "account_id": "1234567890123456"}) result = provider.initialize({"access_key_id": "LTAI5tXXXXXXXXXX", "access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "account_id": "1234567890123456"})
+1 -1
View File
@@ -574,7 +574,7 @@ async def move_files(uid: str, src_file_ids: list, dest_file_id: str = None, new
) )
except Exception as storage_err: except Exception as storage_err:
raise RuntimeError(f"Move file failed at storage layer: {str(storage_err)}") raise RuntimeError(f"Move file failed at storage layer: {str(storage_err)}")
if not moved: if moved is False:
raise RuntimeError("Move file failed at storage layer") raise RuntimeError("Move file failed at storage layer")
updates["parent_id"] = dest_folder_entry.id updates["parent_id"] = dest_folder_entry.id
updates["location"] = new_location updates["location"] = new_location
+30 -10
View File
@@ -178,6 +178,32 @@ def split_model_name(model_name: str):
return pure_model_name, instance_name, provider_name return pure_model_name, instance_name, provider_name
def _resolve_instance_for_model(provider_obj, instance_name: str, model_name: str):
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name)
if instance_obj:
return instance_obj
if instance_name != "default":
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
active_instances = [
inst for inst in TenantModelInstanceService.get_all_by_provider_id(provider_obj.id)
if inst.status == ActiveStatusEnum.ACTIVE.value
]
if len(active_instances) == 1:
logger.warning(
"Model instance fallback applied for legacy default instance name",
extra={
"provider_name": provider_obj.provider_name,
"requested_instance_name": instance_name,
"resolved_instance_name": active_instances[0].instance_name,
"model_name": model_name,
},
)
return active_instances[0]
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
def get_model_config_from_provider_instance(tenant_id, model_type: str|enum.Enum, model_name: str): def get_model_config_from_provider_instance(tenant_id, model_type: str|enum.Enum, model_name: str):
pure_model_name, instance_name, provider_name = split_model_name(model_name) pure_model_name, instance_name, provider_name = split_model_name(model_name)
model_type_val = model_type if isinstance(model_type, str) else model_type.value model_type_val = model_type if isinstance(model_type, str) else model_type.value
@@ -203,9 +229,7 @@ def get_model_config_from_provider_instance(tenant_id, model_type: str|enum.Enum
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name) provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
if not provider_obj: if not provider_obj:
raise LookupError(f"Provider {provider_name} not found for model {model_name}.") raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name) instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
if not instance_obj:
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(provider_obj.id, instance_obj.id, model_type_val, pure_model_name) model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(provider_obj.id, instance_obj.id, model_type_val, pure_model_name)
api_key, is_tool, api_key_payload = _decode_api_key_config(instance_obj.api_key) api_key, is_tool, api_key_payload = _decode_api_key_config(instance_obj.api_key)
@@ -242,7 +266,7 @@ def get_model_config_from_provider_instance(tenant_id, model_type: str|enum.Enum
raise LookupError(f"Model provider config not found: {provider_name}") raise LookupError(f"Model provider config not found: {provider_name}")
llm_list = [llm for llm in fac_list[0]["llm"] if llm["llm_name"] == pure_model_name] llm_list = [llm for llm in fac_list[0]["llm"] if llm["llm_name"] == pure_model_name]
if not llm_list: if not llm_list:
raise LookupError(f"Model config not found: {model_name}") raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
llm_info = llm_list[0] llm_info = llm_list[0]
if model_type_val not in _factory_model_types(llm_info): if model_type_val not in _factory_model_types(llm_info):
raise LookupError(f"Model {model_name} is not a {model_type_val} model.") raise LookupError(f"Model {model_name} is not a {model_type_val} model.")
@@ -268,9 +292,7 @@ def get_api_key(tenant_id: str, model_name: str):
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name) provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
if not provider_obj: if not provider_obj:
raise LookupError(f"Provider {provider_name} not found.") raise LookupError(f"Provider {provider_name} not found.")
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name) instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
if not instance_obj:
raise LookupError(f"Instance {instance_name} not found.")
return instance_obj.api_key return instance_obj.api_key
@@ -279,9 +301,7 @@ def get_model_type_by_name(tenant_id: str, model_name: str):
provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name) provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name)
if not provider_obj: if not provider_obj:
raise LookupError(f"Provider {provider_name} not found for model {model_name}.") raise LookupError(f"Provider {provider_name} not found for model {model_name}.")
instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name) instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name)
if not instance_obj:
raise LookupError(f"Instance {instance_name} not found for model {model_name}.")
model_objs = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, pure_model_name) model_objs = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, pure_model_name)
types_in_json = [] types_in_json = []
if not model_objs: if not model_objs:
+24 -19
View File
@@ -279,16 +279,20 @@ def normalize_str(v: Any) -> Any:
def validate_uuid1_hex(v: Any) -> str: def validate_uuid1_hex(v: Any) -> str:
""" """
Validates and converts input to a UUID version 1 hexadecimal string. Validates and converts input to a UUID hexadecimal string.
This function performs strict validation and normalization: The function name is retained for backward compatibility; only UUID
*format* is enforced (any version is accepted), because some IDs in the
system originate from external imports and use non-v1 UUIDs.
This function performs validation and normalization:
1. Accepts either UUID objects or UUID-formatted strings 1. Accepts either UUID objects or UUID-formatted strings
2. Verifies the UUID is version 1 (time-based) 2. Returns the 32-character hexadecimal representation
3. Returns the 32-character hexadecimal representation 3. Rejects anything that is not a valid UUID
Args: Args:
v (Any): Input value to validate. Can be: v (Any): Input value to validate. Can be:
- UUID object (must be version 1) - UUID object (any version)
- String in UUID format (e.g. "550e8400-e29b-41d4-a716-446655440000") - String in UUID format (e.g. "550e8400-e29b-41d4-a716-446655440000")
Returns: Returns:
@@ -296,9 +300,8 @@ def validate_uuid1_hex(v: Any) -> str:
Example: "550e8400e29b41d4a716446655440000" Example: "550e8400e29b41d4a716446655440000"
Raises: Raises:
PydanticCustomError: With code "invalid_UUID1_format" when: PydanticCustomError: With code "invalid_uuid_format" when:
- Input is not a UUID object or valid UUID string - Input is not a UUID object or valid UUID string
- UUID version is not 1
- String doesn't match UUID format - String doesn't match UUID format
Examples: Examples:
@@ -311,20 +314,22 @@ def validate_uuid1_hex(v: Any) -> str:
Invalid cases: Invalid cases:
>>> validate_uuid1_hex("not-a-uuid") # raises PydanticCustomError >>> validate_uuid1_hex("not-a-uuid") # raises PydanticCustomError
>>> validate_uuid1_hex(12345) # raises PydanticCustomError >>> validate_uuid1_hex(12345) # raises PydanticCustomError
>>> validate_uuid1_hex(UUID(int=0)) # v4, raises PydanticCustomError
Notes: Notes:
- Uses Python's built-in UUID parser for format validation - Uses Python's built-in UUID parser for format validation
- Version check prevents accidental use of other UUID versions - UUID version is no longer enforced (v1, v4, v7, etc. all accepted)
- Hyphens in input strings are automatically removed in output - Hyphens in input strings are automatically removed in output
""" """
try: try:
uuid_obj = UUID(v) if isinstance(v, str) else v if isinstance(v, UUID):
if uuid_obj.version != 1: uuid_obj = v
raise PydanticCustomError("invalid_UUID1_format", "Must be a UUID1 format") elif isinstance(v, str):
uuid_obj = UUID(v)
else:
raise TypeError
return uuid_obj.hex return uuid_obj.hex
except (AttributeError, ValueError, TypeError): except (AttributeError, ValueError, TypeError):
raise PydanticCustomError("invalid_UUID1_format", "Invalid UUID1 format") raise PydanticCustomError("invalid_uuid_format", "Invalid UUID format")
class Base(BaseModel): class Base(BaseModel):
@@ -801,7 +806,7 @@ class DeleteReq(Base):
This post-processing validator performs: This post-processing validator performs:
1. None input handling (pass-through) 1. None input handling (pass-through)
2. UUID version 1 validation for each list item 2. UUID format validation for each list item (any version accepted)
3. Duplicate value detection 3. Duplicate value detection
4. Returns normalized UUID hex strings or None 4. Returns normalized UUID hex strings or None
@@ -814,18 +819,18 @@ class DeleteReq(Base):
- None if input was None - None if input was None
- List of normalized UUID hex strings otherwise: - List of normalized UUID hex strings otherwise:
* 32-character lowercase * 32-character lowercase
* Valid UUID version 1 * Valid UUID format (any version)
* Unique within list * Unique within list
Raises: Raises:
PydanticCustomError: With structured error details when: PydanticCustomError: With structured error details when:
- "invalid_UUID1_format": Any string fails UUIDv1 validation - "invalid_uuid_format": Any string fails UUID format validation
- "duplicate_uuids": If duplicate IDs are detected - "duplicate_uuids": If duplicate IDs are detected
Validation Rules: Validation Rules:
- None input returns None - None input returns None
- Empty list returns empty list - Empty list returns empty list
- All non-None items must be valid UUIDv1 - All non-None items must be valid UUIDs (any version)
- No duplicates permitted - No duplicates permitted
- Original order preserved - Original order preserved
@@ -840,12 +845,12 @@ class DeleteReq(Base):
Invalid cases: Invalid cases:
>>> validate_ids(["invalid"]) >>> validate_ids(["invalid"])
# raises PydanticCustomError(invalid_UUID1_format) # raises PydanticCustomError(invalid_uuid_format)
>>> validate_ids(["550e...", "550e..."]) >>> validate_ids(["550e...", "550e..."])
# raises PydanticCustomError(duplicate_uuids) # raises PydanticCustomError(duplicate_uuids)
Security Notes: Security Notes:
- Validates UUID version to prevent version spoofing - Validates UUID format (any version)
- Duplicate check prevents data injection - Duplicate check prevents data injection
- None handling maintains pipeline integrity - None handling maintains pipeline integrity
""" """
+8 -5
View File
@@ -43,6 +43,7 @@ import (
"ragflow/internal/agent/canvas" "ragflow/internal/agent/canvas"
_ "ragflow/internal/agent/component" // blank import: registers every Component factory (Begin / Agent / LLM / Message / Retrieval / ...) into the shared runtime at package init _ "ragflow/internal/agent/component" // blank import: registers every Component factory (Begin / Agent / LLM / Message / Retrieval / ...) into the shared runtime at package init
"ragflow/internal/agent/runtime" "ragflow/internal/agent/runtime"
agenttool "ragflow/internal/agent/tool"
"ragflow/internal/dao" "ragflow/internal/dao"
"ragflow/internal/engine" "ragflow/internal/engine"
"ragflow/internal/handler" "ragflow/internal/handler"
@@ -90,9 +91,8 @@ func main() {
return return
} }
// Initialize logger with default level // Temporarily default to debug while investigating the Go chat/SSE path.
// logger.Init("info"); // set debug log level if err := common.Init("debug", common.FileOutput{Path: "server_main.log"}); err != nil {
if err := common.Init("info", common.FileOutput{Path: "server_main.log"}); err != nil {
panic(fmt.Sprintf("Failed to initialize logger: %v", err)) panic(fmt.Sprintf("Failed to initialize logger: %v", err))
} }
@@ -115,7 +115,7 @@ func main() {
// Reinitialize logger with configured level if different // Reinitialize logger with configured level if different
level := config.Log.Level level := config.Log.Level
if level == "" { if level == "" {
level = "info" level = "debug"
} }
if debugFlag { if debugFlag {
@@ -236,6 +236,9 @@ func startServer(config *server.Config) {
// Initialize doc engine for skill search // Initialize doc engine for skill search
docEngine := engine.Get() docEngine := engine.Get()
documentDAO := dao.NewDocumentDAO()
agenttool.SetRetrievalService(agenttool.NewNLPRetrievalAdapterFromDeps(docEngine, documentDAO))
common.Info("agent: retrieval service adapter installed")
// Initialize handler layer // Initialize handler layer
authHandler := handler.NewAuthHandler() authHandler := handler.NewAuthHandler()
@@ -296,7 +299,7 @@ func startServer(config *server.Config) {
fileCommitHandler := handler.NewFileCommitHandler(service.NewFileCommitService()) fileCommitHandler := handler.NewFileCommitHandler(service.NewFileCommitService())
// Dify retrieval handler // Dify retrieval handler
docDAO := dao.NewDocumentDAO() docDAO := documentDAO
retrievalService := nlp.NewRetrievalService(docEngine, docDAO) retrievalService := nlp.NewRetrievalService(docEngine, docDAO)
difyRetrievalHandler := handler.NewDifyRetrievalHandler( difyRetrievalHandler := handler.NewDifyRetrievalHandler(
knowledgebaseService, knowledgebaseService,
+4 -4
View File
@@ -4,8 +4,8 @@ go 1.26.4
require ( require (
github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/alibabacloud-go/agentrun-20250910 v1.1.0 github.com/alibabacloud-go/agentrun-20250910/v5 v5.8.4
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.12 github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1
github.com/alicebob/miniredis/v2 v2.38.0 github.com/alicebob/miniredis/v2 v2.38.0
github.com/aws/aws-sdk-go-v2 v1.41.3 github.com/aws/aws-sdk-go-v2 v1.41.3
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6
@@ -74,8 +74,8 @@ require (
connectrpc.com/connect v1.19.2 // indirect connectrpc.com/connect v1.19.2 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea v1.3.12 // indirect github.com/alibabacloud-go/tea v1.5.0 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/apache/thrift v0.23.0 // indirect github.com/apache/thrift v0.23.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
+10 -8
View File
@@ -15,8 +15,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/alibabacloud-go/agentrun-20250910 v1.1.0 h1:Vvhs0/Fd8Urn7gpfZmbWahA+c9GPsSnjRMcKNPWiF3k= github.com/alibabacloud-go/agentrun-20250910/v5 v5.8.4 h1:hiAsm9pz6aICOPLI1FC54vga10xwd/XxfNj06ow5jVM=
github.com/alibabacloud-go/agentrun-20250910 v1.1.0/go.mod h1:j4kaTDVOaXT/I7alT6886+H310G3uypMyRQbdj3bU8o= github.com/alibabacloud-go/agentrun-20250910/v5 v5.8.4/go.mod h1:CYOwrIjr05fOHvdhid7MWtP/IT5ILLcE0eqiolzQT/Q=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
@@ -28,8 +28,8 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.12 h1:e2yCrhtWd6Qcsy4he2OL+jIAU+93Lx9OcLlPRoFLT1w= github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1 h1:R8b55YFS4K9x5P5IdgA+QWinVfVmulzqaJG/tr6HhxM=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.12/go.mod h1:f2wDpbM7hK9SvLIH09zSKVU1TsyemUNOqErMscMMl7c= github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1/go.mod h1:OCFim1kMbp2m+V8WS5IBnnVrk6nXaJiDwZpg3uqw8Po=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
@@ -49,13 +49,13 @@ github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/Ke
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.12 h1:ir2Io80UlBy1JHf7t+uCTxmaGQtiEta1WpV29NGJTkE= github.com/alibabacloud-go/tea v1.5.0 h1:8pUo8WzMChtJT+jpLIcN1vzMi6SW3rihzzBoihPGUvs=
github.com/alibabacloud-go/tea v1.3.12/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= github.com/alibabacloud-go/tea v1.5.0/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I= github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
@@ -485,6 +485,8 @@ github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
@@ -189,6 +189,10 @@ func initialUserFillUpData(ctx context.Context, inputSpec map[string]any) (any,
if err != nil || raw == nil { if err != nil || raw == nil {
return nil, false return nil, false
} }
if values, ok := raw.(map[string]any); ok {
state.Sys["__initial_user_input_consumed__"] = true
return values, true
}
text, ok := raw.(string) text, ok := raw.(string)
if !ok || text == "" { if !ok || text == "" {
return nil, false return nil, false
@@ -352,6 +352,29 @@ func TestInitialUserFillUpData_UsesSysQueryWhenSchemaPresent(t *testing.T) {
} }
} }
func TestInitialUserFillUpData_UsesStructuredSysQueryWhenSchemaPresent(t *testing.T) {
state := NewCanvasState("run-1", "task-1")
state.Sys["query"] = map[string]any{"kb": "da1", "query": "合同"}
ctx := WithState(context.Background(), state)
got, ok := initialUserFillUpData(ctx, map[string]any{
"inputs": map[string]any{
"kb": map[string]any{"type": "line"},
"query": map[string]any{"type": "line"},
},
})
if !ok {
t.Fatal("expected initialUserFillUpData to consume structured sys.query")
}
values, ok := got.(map[string]any)
if !ok {
t.Fatalf("got type %T, want map[string]any", got)
}
if values["kb"] != "da1" || values["query"] != "合同" {
t.Fatalf("got %#v, want kb=da1 query=合同", values)
}
}
func TestInitialUserFillUpData_SkipsWhenNoSchema(t *testing.T) { func TestInitialUserFillUpData_SkipsWhenNoSchema(t *testing.T) {
state := NewCanvasState("run-1", "task-1") state := NewCanvasState("run-1", "task-1")
state.Sys["query"] = "loop" state.Sys["query"] = "loop"
+3 -2
View File
@@ -221,7 +221,8 @@ func (r *Runner) getInterruptID(canvasID, sessionID string) string {
func (r *Runner) Run( func (r *Runner) Run(
ctx context.Context, ctx context.Context,
run RunFunc, run RunFunc,
canvasID, sessionID, userInput string, canvasID, sessionID string,
userInput any,
root map[string]any, root map[string]any,
) <-chan RunEvent { ) <-chan RunEvent {
out := make(chan RunEvent, 8) out := make(chan RunEvent, 8)
@@ -295,7 +296,7 @@ func (r *Runner) Run(
// invoking the workflow. The sentinel keys are deleted from // invoking the workflow. The sentinel keys are deleted from
// root inside the RunFunc — see service/agent.go's // root inside the RunFunc — see service/agent.go's
// buildRunFunc. // buildRunFunc.
if userInput != "" { if userInput != nil {
if id := r.getInterruptID(canvasID, sessionID); id != "" { if id := r.getInterruptID(canvasID, sessionID); id != "" {
root["__resume_interrupt_id__"] = id root["__resume_interrupt_id__"] = id
root["__resume_data__"] = userInput root["__resume_data__"] = userInput
@@ -34,7 +34,13 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"ragflow/internal/agent/runtime"
agenttool "ragflow/internal/agent/tool" agenttool "ragflow/internal/agent/tool"
"ragflow/internal/dao"
"ragflow/internal/entity"
) )
type codeExecSandboxRecorder struct { type codeExecSandboxRecorder struct {
@@ -190,6 +196,203 @@ func TestRetrieval_KbIDsTranslatedToDatasetIDs(t *testing.T) {
} }
} }
func TestRetrieval_LegacyQueryStringNormalized(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("failed to unwrap sql db: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&entity.Knowledgebase{}); err != nil {
t.Fatalf("failed to migrate knowledgebase: %v", err)
}
if err := db.AutoMigrate(&entity.UserTenant{}); err != nil {
t.Fatalf("failed to migrate user_tenant: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
activeStatus := "1"
if err := db.Create(&entity.UserTenant{
ID: "ut-1",
UserID: "user-1",
TenantID: "tenant-1",
Role: "owner",
InvitedBy: "user-1",
Status: &activeStatus,
}).Error; err != nil {
t.Fatalf("failed to seed user_tenant: %v", err)
}
if err := db.Create(&entity.Knowledgebase{
ID: "kb-da1",
Name: "da1",
TenantID: "tenant-1",
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
Permission: "me",
CreatedBy: "user-1",
Status: func() *string { s := string(entity.StatusValid); return &s }(),
}).Error; err != nil {
t.Fatalf("failed to seed kb: %v", err)
}
c, err := newRetrievalComponent(nil)
if err != nil {
t.Fatalf("newRetrievalComponent: %v", err)
}
rc := c.(*retrievalComponent)
merged := rc.applyDefaults(map[string]any{
"query": "UserFillUp: da1\nInput diamond necklace\n",
})
state := runtime.NewCanvasState("run-1", "task-1")
state.Sys["user_id"] = "user-1"
normalizeLegacyRetrievalInputs(runtime.WithState(context.Background(), state), merged)
if got, _ := merged["query"].(string); got != "diamond necklace" {
t.Fatalf("query = %q, want diamond necklace", got)
}
ds, ok := merged["dataset_ids"].([]string)
if !ok || len(ds) != 1 || ds[0] != "kb-da1" {
t.Fatalf("dataset_ids = %#v, want []string{\"kb-da1\"}", merged["dataset_ids"])
}
}
func TestRetrieval_StructuredUserFillInputNormalized(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("failed to unwrap sql db: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.UserTenant{}); err != nil {
t.Fatalf("failed to migrate tables: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
activeStatus := "1"
if err := db.Create(&entity.UserTenant{
ID: "ut-1",
UserID: "user-1",
TenantID: "tenant-1",
Role: "owner",
InvitedBy: "user-1",
Status: &activeStatus,
}).Error; err != nil {
t.Fatalf("failed to seed user_tenant: %v", err)
}
if err := db.Create(&entity.Knowledgebase{
ID: "kb-da1",
Name: "da1",
TenantID: "tenant-1",
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
Permission: "me",
CreatedBy: "user-1",
Status: func() *string { s := string(entity.StatusValid); return &s }(),
}).Error; err != nil {
t.Fatalf("failed to seed kb: %v", err)
}
c, err := newRetrievalComponent(nil)
if err != nil {
t.Fatalf("newRetrievalComponent: %v", err)
}
rc := c.(*retrievalComponent)
merged := rc.applyDefaults(map[string]any{
"state": map[string]any{
"UserFillUp:KBInput": map[string]any{
"kb": "da1",
"query": "合同",
},
},
})
state := runtime.NewCanvasState("run-1", "task-1")
state.Sys["user_id"] = "user-1"
normalizeLegacyRetrievalInputs(runtime.WithState(context.Background(), state), merged)
if got, _ := merged["query"].(string); got != "合同" {
t.Fatalf("query = %q, want 合同", got)
}
ds, ok := merged["dataset_ids"].([]string)
if !ok || len(ds) != 1 || ds[0] != "kb-da1" {
t.Fatalf("dataset_ids = %#v, want []string{\"kb-da1\"}", merged["dataset_ids"])
}
}
func TestRetrieval_ResolveDatasetIDByTenantName(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("failed to unwrap sql db: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&entity.Knowledgebase{}); err != nil {
t.Fatalf("failed to migrate knowledgebase: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
if err := db.Create(&entity.Knowledgebase{
ID: "kb-da1",
Name: "da1",
TenantID: "tenant-1",
EmbdID: "BAAI/bge-m3@yy2@SILICONFLOW",
Permission: "me",
CreatedBy: "user-1",
Status: func() *string { s := string(entity.StatusValid); return &s }(),
}).Error; err != nil {
t.Fatalf("failed to seed kb: %v", err)
}
state := runtime.NewCanvasState("run-1", "task-1")
state.Sys["tenant_id"] = "tenant-1"
ctx := runtime.WithState(context.Background(), state)
if got := resolveRetrievalDatasetID(ctx, "da1"); got != "kb-da1" {
t.Fatalf("resolveRetrievalDatasetID = %q, want kb-da1", got)
}
}
func TestRetrieval_StructuredInputPreservesQueryWhenDatasetIDsAlreadyPresent(t *testing.T) {
c, err := newRetrievalComponent(nil)
if err != nil {
t.Fatalf("newRetrievalComponent: %v", err)
}
rc := c.(*retrievalComponent)
merged := rc.applyDefaults(map[string]any{
"dataset_ids": []string{"kb-fixed"},
"state": map[string]any{
"UserFillUp:KBInput": map[string]any{
"kb": "da1",
"query": "合同",
},
},
})
consumed := normalizeStructuredRetrievalInputs(context.Background(), merged)
if !consumed {
t.Fatal("normalizeStructuredRetrievalInputs should consume structured query")
}
if got, _ := merged["query"].(string); got != "合同" {
t.Fatalf("query = %q, want 合同", got)
}
ds, ok := merged["dataset_ids"].([]string)
if !ok || len(ds) != 1 || ds[0] != "kb-fixed" {
t.Fatalf("dataset_ids = %#v, want []string{\"kb-fixed\"}", merged["dataset_ids"])
}
}
// TestRetrieval_KbIDsEndToEndThroughTool is the wire-level // TestRetrieval_KbIDsEndToEndThroughTool is the wire-level
// companion to TestRetrieval_KbIDsTranslatedToDatasetIDs: it // companion to TestRetrieval_KbIDsTranslatedToDatasetIDs: it
// installs the simple retrieval service, builds a wrapper with // installs the simple retrieval service, builds a wrapper with
+16
View File
@@ -353,6 +353,22 @@ func evaluateClause(clause map[string]any, state *runtime.CanvasState) (bool, er
right := clause["right"] right := clause["right"]
lv := leftValue(left, state) lv := leftValue(left, state)
// Port of python PR #16320: for the four string operators,
// coerce nil on either side to "". In Python this avoids
// AttributeError on `.lower()`; in Go there's no crash (fmt
// renders nil as "<nil>"), but the Python post-fix semantic —
// where "foo" contains None is True — diverges from Go's
// "<nil>" rendering. Coercing to "" aligns the Go port with
// the Python workflow.
if op == "contains" || op == "not contains" || op == "start with" || op == "end with" {
if lv == nil {
lv = ""
}
if right == nil {
right = ""
}
}
switch op { switch op {
case "==": case "==":
return equalFoldValues(lv, right), nil return equalFoldValues(lv, right), nil
+143
View File
@@ -241,6 +241,149 @@ func TestSwitch_LegacyConditionsAndArrayTo(t *testing.T) {
} }
} }
// TestSwitch_NilUpstreamContainsEmptyNeedleMatches ports the
// regression covered by python PR #16320: when an upstream
// component yields nil and the configured value is the empty
// string, the "contains" operator must match (Python semantics
// after the fix: "" in "anything"). Pre-fix Python crashed with
// AttributeError; pre-port Go returned false because fmt rendered
// nil as "<nil>" instead of "". The fix coerces nil → "" before
// formatting, restoring parity with the Python workflow.
func TestSwitch_NilUpstreamContainsEmptyNeedleMatches(t *testing.T) {
s, _ := NewSwitchComponent(nil)
state := canvas.NewCanvasState("run-nil-contains", "task-nil-contains")
state.Sys["answer"] = nil
ctx := withStateForTest(context.Background(), state)
inputs := map[string]any{
"conditions": []any{
map[string]any{
"op": "and",
"to": []any{"case_target"},
"clauses": []any{
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": ""},
},
},
},
"default": "else_target",
}
out, err := s.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
targets := nextTargets(out)
if len(targets) != 1 || targets[0] != "case_target" {
t.Errorf("_next: got %v, want [\"case_target\"] (nil coerced to \"\" should match empty needle)", targets)
}
}
// TestSwitch_NilUpstreamContainsNonEmptyDoesNotMatch verifies
// the inverse: nil coerced to "" still must NOT match a
// non-empty needle (we only coerce, we don't synthesize a match).
func TestSwitch_NilUpstreamContainsNonEmptyDoesNotMatch(t *testing.T) {
s, _ := NewSwitchComponent(nil)
state := canvas.NewCanvasState("run-nil-needle", "task-nil-needle")
state.Sys["answer"] = nil
ctx := withStateForTest(context.Background(), state)
inputs := map[string]any{
"conditions": []any{
map[string]any{
"op": "and",
"to": []any{"case_target"},
"clauses": []any{
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": "foo"},
},
},
},
"default": "else_target",
}
out, err := s.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
targets := nextTargets(out)
if len(targets) != 1 || targets[0] != "else_target" {
t.Errorf("_next: got %v, want [\"else_target\"] (\"\" does not contain \"foo\")", targets)
}
}
// TestSwitch_NilValueContainsDoesNotRaise mirrors python test
// "test_switch_none_value_contains_does_not_raise": the configured
// value can also be nil and the operator must not crash. With
// nil coerced to "" on both sides, "foobar" contains "" matches.
func TestSwitch_NilValueContainsDoesNotRaise(t *testing.T) {
s, _ := NewSwitchComponent(nil)
state := canvas.NewCanvasState("run-nil-value", "task-nil-value")
state.Sys["answer"] = "foobar"
ctx := withStateForTest(context.Background(), state)
inputs := map[string]any{
"conditions": []any{
map[string]any{
"op": "and",
"to": []any{"case_target"},
"clauses": []any{
map[string]any{"left": "{{sys.answer}}", "op": "contains", "right": nil},
},
},
},
"default": "else_target",
}
out, err := s.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
targets := nextTargets(out)
if len(targets) != 1 || targets[0] != "case_target" {
t.Errorf("_next: got %v, want [\"case_target\"] (nil value coerced to \"\" matches any string)", targets)
}
}
// TestSwitch_NilUpstreamStartWithEndWithDoNotCrash guards the
// remaining two string operators covered by PR #16320. They were
// crash-prone in Python for the same reason; in Go they don't
// crash but the nil → "" coercion still applies, so a nil
// upstream with an empty prefix/suffix must match (rather than
// being rendered as "<nil>" and silently missing).
func TestSwitch_NilUpstreamStartWithEndWithDoNotCrash(t *testing.T) {
s, _ := NewSwitchComponent(nil)
state := canvas.NewCanvasState("run-nil-start-end", "task-nil-start-end")
state.Sys["answer"] = nil
ctx := withStateForTest(context.Background(), state)
for _, tc := range []struct {
name string
op string
}{
{name: "start with", op: "start with"},
{name: "end with", op: "end with"},
} {
t.Run(tc.name, func(t *testing.T) {
inputs := map[string]any{
"conditions": []any{
map[string]any{
"op": "and",
"to": []any{"case_target"},
"clauses": []any{
map[string]any{"left": "{{sys.answer}}", "op": tc.op, "right": ""},
},
},
},
"default": "else_target",
}
out, err := s.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
targets := nextTargets(out)
if len(targets) != 1 || targets[0] != "case_target" {
t.Errorf("_next: got %v, want [\"case_target\"] (nil coerced to \"\" %s \"\")", targets, tc.op)
}
})
}
}
// TestSwitch_MultiTargetTo verifies that Switch returns all cpn_ids // TestSwitch_MultiTargetTo verifies that Switch returns all cpn_ids
// from a multi-element "to" field. This mirrors Python's behavior // from a multi-element "to" field. This mirrors Python's behavior
// where a condition can route to multiple downstream nodes // where a condition can route to multiple downstream nodes
@@ -31,16 +31,22 @@ package component
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
einotool "github.com/cloudwego/eino/components/tool" einotool "github.com/cloudwego/eino/components/tool"
"ragflow/internal/agent/runtime"
agenttool "ragflow/internal/agent/tool" agenttool "ragflow/internal/agent/tool"
"ragflow/internal/common" "ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
) )
// tavilySearchComponent delegates to internal/agent/tool/TavilyTool. // tavilySearchComponent delegates to internal/agent/tool/TavilyTool.
@@ -153,6 +159,8 @@ type retrievalComponent struct {
params retrievalParams params retrievalParams
} }
var legacyRetrievalQueryPattern = regexp.MustCompile(`(?s)^\s*UserFillUp:\s*(.*?)\s+Input\s+(.*?)\s*$`)
func newRetrievalComponent(params map[string]any) (Component, error) { func newRetrievalComponent(params map[string]any) (Component, error) {
return &retrievalComponent{ return &retrievalComponent{
inner: agenttool.NewRetrievalTool(), inner: agenttool.NewRetrievalTool(),
@@ -180,11 +188,19 @@ func (c *retrievalComponent) Outputs() map[string]string {
func (c *retrievalComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) { func (c *retrievalComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
merged := c.applyDefaults(inputs) merged := c.applyDefaults(inputs)
normalizeLegacyRetrievalInputs(ctx, merged)
common.Debug("agent retrieval component: invoke",
zap.Any("inputs", inputs),
zap.Any("merged", merged),
)
argsJSON, _ := json.Marshal(merged) argsJSON, _ := json.Marshal(merged)
out, err := c.inner.InvokableRun(ctx, string(argsJSON)) out, err := c.inner.InvokableRun(ctx, string(argsJSON))
if err != nil { if err != nil {
return nil, fmt.Errorf("canvas: Retrieval: %w", err) return nil, fmt.Errorf("canvas: Retrieval: %w", err)
} }
common.Debug("agent retrieval component: output",
zap.String("tool_output", out),
)
return parseToolEnvelope(out), nil return parseToolEnvelope(out), nil
} }
@@ -261,6 +277,148 @@ func (c *retrievalComponent) applyDefaults(inputs map[string]any) map[string]any
return out return out
} }
func normalizeLegacyRetrievalInputs(ctx context.Context, out map[string]any) {
if normalizeStructuredRetrievalInputs(ctx, out) {
return
}
rawQuery, _ := out["query"].(string)
rawQuery = strings.TrimSpace(rawQuery)
if rawQuery == "" {
return
}
matches := legacyRetrievalQueryPattern.FindStringSubmatch(rawQuery)
if len(matches) != 3 {
return
}
kbName := strings.TrimSpace(matches[1])
queryText := strings.TrimSpace(matches[2])
if queryText != "" {
out["query"] = queryText
}
if _, hasDatasetIDs := out["dataset_ids"]; hasDatasetIDs {
return
}
if kbName == "" {
return
}
if datasetID := resolveRetrievalDatasetID(ctx, kbName); datasetID != "" {
out["dataset_ids"] = []string{datasetID}
}
}
func normalizeStructuredRetrievalInputs(ctx context.Context, out map[string]any) bool {
_, hasDatasetIDs := out["dataset_ids"]
candidateMaps := []map[string]any{}
if stateMap, ok := out["state"].(map[string]any); ok {
if raw, ok := stateMap["UserFillUp:KBInput"].(map[string]any); ok {
candidateMaps = append(candidateMaps, raw)
}
}
candidateMaps = append(candidateMaps, out)
consumed := false
for _, candidate := range candidateMaps {
kbName, _ := candidate["kb"].(string)
queryText, _ := candidate["query"].(string)
if kbName == "" && legacyRetrievalQueryPattern.MatchString(strings.TrimSpace(queryText)) {
continue
}
if kbName == "" && queryText == "" {
continue
}
consumed = true
if queryText != "" {
out["query"] = queryText
}
if kbName != "" && !hasDatasetIDs {
if datasetID := resolveRetrievalDatasetID(ctx, strings.TrimSpace(kbName)); datasetID != "" {
out["dataset_ids"] = []string{datasetID}
common.Debug("agent retrieval component: resolved dataset id",
zap.String("kb", strings.TrimSpace(kbName)),
zap.String("dataset_id", datasetID))
}
}
if queryText != "" {
return true
}
if kbName != "" && out["dataset_ids"] != nil {
return true
}
}
return consumed
}
func resolveRetrievalDatasetID(ctx context.Context, kbName string) string {
if kbName == "" {
return ""
}
if kb, err := dao.NewKnowledgebaseDAO().GetByID(kbName); err == nil && kb != nil {
common.Debug("agent retrieval component: resolved dataset id by direct id",
zap.String("kb", kbName),
zap.String("dataset_id", kb.ID))
return kb.ID
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
common.Warn("agent retrieval component: resolve dataset id by id failed",
zap.String("kb", kbName),
zap.Error(err))
}
if state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err == nil && state != nil {
common.Debug("agent retrieval component: resolve dataset id context",
zap.String("kb", kbName),
zap.Any("sys_query", state.Sys["query"]),
zap.Any("tenant_id", state.Sys["tenant_id"]),
zap.Any("user_id", state.Sys["user_id"]))
if tenantID, _ := state.Sys["tenant_id"].(string); tenantID != "" {
if kb, lookupErr := dao.NewKnowledgebaseDAO().GetByName(kbName, tenantID); lookupErr == nil && kb != nil {
common.Debug("agent retrieval component: resolved dataset id by tenant",
zap.String("kb", kbName),
zap.String("tenant_id", tenantID),
zap.String("dataset_id", kb.ID))
return kb.ID
} else if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
common.Warn("agent retrieval component: resolve dataset id by tenant failed",
zap.String("kb", kbName),
zap.String("tenant_id", tenantID),
zap.Error(lookupErr))
} else {
common.Debug("agent retrieval component: tenant lookup missed",
zap.String("kb", kbName),
zap.String("tenant_id", tenantID))
}
}
if userID, _ := state.Sys["user_id"].(string); userID != "" {
if kbs, lookupErr := dao.NewKnowledgebaseDAO().GetKBByNameAndUserID(kbName, userID); lookupErr == nil && len(kbs) > 0 {
for _, kb := range kbs {
if kb == nil || kb.Status == nil || *kb.Status != string(entity.StatusValid) {
continue
}
common.Debug("agent retrieval component: resolved dataset id by user visibility",
zap.String("kb", kbName),
zap.String("user_id", userID),
zap.String("dataset_id", kb.ID))
return kb.ID
}
} else if lookupErr != nil {
common.Warn("agent retrieval component: resolve dataset id by name failed",
zap.String("kb", kbName),
zap.String("user_id", userID),
zap.Error(lookupErr))
} else {
common.Debug("agent retrieval component: user visibility lookup missed",
zap.String("kb", kbName),
zap.String("user_id", userID))
}
}
} else {
common.Debug("agent retrieval component: resolve dataset id missing canvas state",
zap.String("kb", kbName),
zap.Error(err))
}
common.Debug("agent retrieval component: dataset id unresolved",
zap.String("kb", kbName))
return ""
}
// exesqlComponent delegates to internal/agent/tool/ExeSQLTool. The // exesqlComponent delegates to internal/agent/tool/ExeSQLTool. The
// connection params (db_type, host, port, database, username, // connection params (db_type, host, port, database, username,
// password) are passed via the canvas node's params map at build // password) are passed via the canvas node's params map at build
+4 -4
View File
@@ -20,7 +20,7 @@
// The Python provider uses the high-level `agentrun-sdk` Python // The Python provider uses the high-level `agentrun-sdk` Python
// package, which exposes a `Sandbox` class with `create()` / // package, which exposes a `Sandbox` class with `create()` /
// `connect()` / `context.execute()` / `delete_by_id()` methods. // `connect()` / `context.execute()` / `delete_by_id()` methods.
// The Go SDK at v1.1.0 is the OpenAPI stub — it has lifecycle // The Go SDK at v5.8.4 is the OpenAPI stub — it has lifecycle
// operations (CreateCodeInterpreter / DeleteCodeInterpreter / // operations (CreateCodeInterpreter / DeleteCodeInterpreter /
// ListCodeInterpreters / GetCodeInterpreter) but does NOT expose // ListCodeInterpreters / GetCodeInterpreter) but does NOT expose
// the execute endpoint. // the execute endpoint.
@@ -48,8 +48,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/alibabacloud-go/agentrun-20250910/client" "github.com/alibabacloud-go/agentrun-20250910/v5/client"
agentrun "github.com/alibabacloud-go/agentrun-20250910/client" agentrun "github.com/alibabacloud-go/agentrun-20250910/v5/client"
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils" openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
) )
@@ -213,7 +213,7 @@ func (p *AliyunCodeInterpreterProvider) CreateInstance(ctx context.Context, temp
templateName = fmt.Sprintf("ragflow-%s-default", lang) templateName = fmt.Sprintf("ragflow-%s-default", lang)
} }
// NOTE: Go SDK v1.1.0's CreateCodeInterpreterInput does not // NOTE: Go SDK v5.8.4's CreateCodeInterpreterInput does not
// expose a TemplateName field. The Python SDK creates the // expose a TemplateName field. The Python SDK creates the
// template via the high-level `Template.create()` API and // template via the high-level `Template.create()` API and
// then references it from `Sandbox.create(template_name=...)`. // then references it from `Sandbox.create(template_name=...)`.
+312
View File
@@ -0,0 +1,312 @@
//
// 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.
//
package tool
import (
"context"
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"os"
"strings"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
const keenableToolName = "keenable"
// keenableToolDescription follows the upstream Python tool's description,
// trimmed for the chat model. The "no API key required" line is the
// differentiator from Tavily/DuckDuckGo/SearXNG.
const keenableToolDescription = `Keenable is a web search API built for AI agents. It returns fresh, relevant web results for a query and works without an API key by default (keyless free tier). When searching:
- Use a focused query of the most important terms (and synonyms).
- Optionally restrict to a single site/domain.`
// keenableParams is the JSON shape the model sends into InvokableRun.
// site is an optional single-domain filter. mode is "pro" (default,
// deeper) or "realtime" (requires a server-configured key). top_n caps
// how many results we keep from the upstream `results` array.
type keenableParams struct {
Query string `json:"query"`
Site string `json:"site"`
Mode string `json:"mode"`
TopN int `json:"top_n"`
}
// keenableRequestBody is the JSON body POSTed to the Keenable search
// endpoint. Mirrors the upstream Python tool — query, mode, and an
// optional site filter.
type keenableRequestBody struct {
Query string `json:"query"`
Mode string `json:"mode"`
Site string `json:"site,omitempty"`
}
// keenableResult mirrors one element of the upstream `results` array.
// The Python tool's _retrieve_chunks reads `title`, `url`, `description`,
// so we model those fields and pass everything else through verbatim
// when serializing to the model.
type keenableResult struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
}
// keenableResponse is the envelope returned by Keenable. We only model
// the fields we care about; the upstream API has more, but they are
// ignored.
type keenableResponse struct {
Results []keenableResult `json:"results"`
}
// keenableEnvelope is the shape the model actually sees, identical to
// the Python tool's output convention.
type keenableEnvelope struct {
Results []keenableResult `json:"results"`
Error string `json:"_ERROR,omitempty"`
}
// KeenableTool is the Keenable web search tool. It POSTs a search
// request to the public keyless endpoint by default and to the keyed
// endpoint (with X-API-Key) when an API key is provided. The upstream
// `results` array is returned as JSON.
type KeenableTool struct {
helper *HTTPHelper
apiKey string
// envBaseURL resolves the Keenable API base URL from the
// KEENABLE_API_URL env var (HTTPS enforced). Exposed as a
// function so tests can inject a fake without mutating process
// state — matches the envKey pattern used by TavilyTool.
envBaseURL func() string
}
// NewKeenableTool returns a KeenableTool using the default HTTPHelper
// and the KEENABLE_API_URL env var for base-URL resolution.
func NewKeenableTool() *KeenableTool {
return NewKeenableToolWith(NewHTTPHelper())
}
// NewKeenableToolWithAPIKey returns a KeenableTool that uses a
// server-provided API key instead of model-visible runtime args.
func NewKeenableToolWithAPIKey(h *HTTPHelper, apiKey string) *KeenableTool {
t := NewKeenableToolWith(h)
t.apiKey = strings.TrimSpace(apiKey)
return t
}
// NewKeenableToolWith returns a KeenableTool that uses the provided
// HTTPHelper. Useful for tests that want to inject a custom transport.
func NewKeenableToolWith(h *HTTPHelper) *KeenableTool {
if h == nil {
h = NewHTTPHelper()
}
return &KeenableTool{helper: h, envBaseURL: defaultKeenableEnvBaseURL}
}
// NewKeenableToolWithEnvBaseURL returns a KeenableTool with a custom
// base-URL resolver. Useful for tests that want to inject a fake env
// without mutating process state.
func NewKeenableToolWithEnvBaseURL(h *HTTPHelper, envBaseURL func() string) *KeenableTool {
if h == nil {
h = NewHTTPHelper()
}
if envBaseURL == nil {
envBaseURL = defaultKeenableEnvBaseURL
}
return &KeenableTool{helper: h, envBaseURL: envBaseURL}
}
// defaultKeenableEnvBaseURL is the production base-URL resolver.
// Pulled out as a named function (not a var) so tests cannot
// accidentally mutate it via package-var assignment.
func defaultKeenableEnvBaseURL() string {
if v := strings.TrimSpace(os.Getenv("KEENABLE_API_URL")); v != "" {
return v
}
return "https://api.keenable.ai"
}
// resolveKeenableBaseURL returns the validated Keenable base URL.
// HTTPS is required for any non-loopback host; loopback hosts may
// use plain http for local development. Mirrors the Python tool's
// _base_url() guard — a misconfigured URL fails fast at request time
// rather than silently making a request to the wrong host.
func resolveKeenableBaseURL(raw string) (string, error) {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return "", fmt.Errorf("keenable: empty base URL")
}
u, err := neturl.Parse(raw)
if err != nil {
return "", fmt.Errorf("keenable: parse KEENABLE_API_URL %q: %w", raw, err)
}
host := u.Hostname()
if host == "" {
return "", fmt.Errorf("keenable: KEENABLE_API_URL must have a host, got %q", raw)
}
if u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("keenable: KEENABLE_API_URL must not include query or fragment, got %q", raw)
}
switch u.Scheme {
case "https":
return raw, nil
case "http":
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return raw, nil
}
return "", fmt.Errorf("keenable: KEENABLE_API_URL must be https://, got %q", raw)
default:
return "", fmt.Errorf("keenable: KEENABLE_API_URL scheme %q not allowed (https required)", u.Scheme)
}
}
// Info returns the tool's metadata for the chat model. The description
// is the short prose above; the parameter schema lists the model-emitted
// fields with sane defaults documented inline.
func (k *KeenableTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: keenableToolName,
Desc: keenableToolDescription,
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"query": {
Type: schema.String,
Desc: "Search keywords to execute with Keenable. The most important words/terms (and synonyms) from the original request.",
Required: true,
},
"site": {
Type: schema.String,
Desc: "Optional. Restrict results to a single domain, e.g. 'techcrunch.com'. Defaults to '' (no filter).",
Required: false,
},
"mode": {
Type: schema.String,
Desc: `Search mode: "pro" (default, deeper) or "realtime" (low latency; requires a server-configured API key).`,
Required: false,
},
"top_n": {
Type: schema.Integer,
Desc: "Maximum number of results to return. Defaults to 10.",
Required: false,
},
}),
}, nil
}
// InvokableRun performs the Keenable search.
func (k *KeenableTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) {
var p keenableParams
if err := json.Unmarshal([]byte(argsJSON), &p); err != nil {
return keenableErrJSON(fmt.Errorf("keenable: parse arguments: %w", err)),
fmt.Errorf("keenable: parse arguments: %w", err)
}
if strings.TrimSpace(p.Query) == "" {
return keenableErrJSON(fmt.Errorf("query is required")),
fmt.Errorf("keenable: query is required")
}
mode := strings.TrimSpace(p.Mode)
if mode == "" {
mode = "pro"
}
if mode != "pro" && mode != "realtime" {
return keenableErrJSON(fmt.Errorf("keenable: mode %q must be one of [pro realtime]", p.Mode)),
fmt.Errorf("keenable: mode %q must be one of [pro realtime]", p.Mode)
}
// 'realtime' is only available on the keyed endpoint. Reject the
// invalid combination up front instead of letting the upstream
// return a confusing error — matches the Python tool's check().
if mode == "realtime" && strings.TrimSpace(k.apiKey) == "" {
return keenableErrJSON(fmt.Errorf("keenable: 'realtime' mode requires a configured api_key")),
fmt.Errorf("keenable: 'realtime' mode requires a configured api_key")
}
topN := p.TopN
if topN <= 0 {
topN = 10
}
baseURL, err := resolveKeenableBaseURL(k.envBaseURL())
if err != nil {
// Config/local error — won't be fixed by retrying, so fail fast
// (matches the Python tool's behavior for ValueError).
return keenableErrJSON(err), err
}
apiKey := strings.TrimSpace(k.apiKey)
path := "/v1/search/public"
headers := map[string]string{
"User-Agent": "keenable-ragflow",
"X-Keenable-Title": "RAGFlow",
}
if apiKey != "" {
path = "/v1/search"
headers["X-API-Key"] = apiKey
}
body := keenableRequestBody{
Query: p.Query,
Mode: mode,
}
if site := strings.TrimSpace(p.Site); site != "" {
body.Site = site
}
bodyJSON, _ := json.Marshal(body)
resp, err := k.helper.Do(ctx,
http.MethodPost, baseURL+path, string(bodyJSON), "application/json", headers,
)
if err != nil {
return keenableErrJSON(err), err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return keenableErrJSON(fmt.Errorf("keenable: upstream returned %d", resp.StatusCode)),
fmt.Errorf("keenable: upstream returned %d", resp.StatusCode)
}
var raw keenableResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return keenableErrJSON(fmt.Errorf("keenable: decode response: %w", err)),
fmt.Errorf("keenable: decode response: %w", err)
}
results := raw.Results
if len(results) > topN {
results = results[:topN]
}
return keenableJSON(keenableEnvelope{Results: results}), nil
}
// keenableJSON marshals the envelope to a JSON string for the model.
func keenableJSON(env keenableEnvelope) string {
b, err := json.Marshal(env)
if err != nil {
return fmt.Sprintf(`{"_ERROR":"keenable: marshal result: %s"}`, err)
}
return string(b)
}
// keenableErrJSON wraps an error in the standard envelope.
func keenableErrJSON(err error) string {
return keenableJSON(keenableEnvelope{Error: err.Error()})
}
+392
View File
@@ -0,0 +1,392 @@
//
// 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.
//
package tool
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestKeenable_KeylessPath verifies that when no api_key is supplied the
// tool POSTs to /v1/search/public with the attribution headers but
// without an X-API-Key header.
func TestKeenable_KeylessPath(t *testing.T) {
t.Parallel()
var gotMethod, gotPath, gotUA, gotTitle, gotAPIKey, gotCT string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotUA = r.Header.Get("User-Agent")
gotTitle = r.Header.Get("X-Keenable-Title")
gotAPIKey = r.Header.Get("X-API-Key")
gotCT = r.Header.Get("Content-Type")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
if _, err := tool.InvokableRun(context.Background(), `{"query":"ragflow"}`); err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %q, want POST", gotMethod)
}
if gotPath != "/v1/search/public" {
t.Errorf("path = %q, want /v1/search/public (keyless endpoint)", gotPath)
}
if gotUA != "keenable-ragflow" {
t.Errorf("User-Agent = %q, want keenable-ragflow", gotUA)
}
if gotTitle != "RAGFlow" {
t.Errorf("X-Keenable-Title = %q, want RAGFlow", gotTitle)
}
if gotAPIKey != "" {
t.Errorf("X-API-Key = %q, want empty on keyless path", gotAPIKey)
}
if !strings.HasPrefix(gotCT, "application/json") {
t.Errorf("Content-Type = %q, want application/json", gotCT)
}
if gotBody["query"] != "ragflow" {
t.Errorf("body.query = %v, want ragflow", gotBody["query"])
}
if gotBody["mode"] != "pro" {
t.Errorf("body.mode = %v, want pro (default)", gotBody["mode"])
}
}
// TestKeenable_KeyedPath verifies that a server-configured api_key
// switches the tool to the /v1/search endpoint and sets X-API-Key on
// the request.
func TestKeenable_KeyedPath(t *testing.T) {
t.Parallel()
var gotPath, gotAPIKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAPIKey = r.Header.Get("X-API-Key")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithAPIKey(helper, "key-xyz")
tool.envBaseURL = func() string { return "https://" + srv.URL[len("http://"):] }
if _, err := tool.InvokableRun(context.Background(),
`{"query":"ragflow","mode":"realtime"}`); err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotPath != "/v1/search" {
t.Errorf("path = %q, want /v1/search (keyed endpoint)", gotPath)
}
if gotAPIKey != "key-xyz" {
t.Errorf("X-API-Key = %q, want key-xyz", gotAPIKey)
}
}
// TestKeenable_SiteAndTopN verifies the site filter is forwarded and
// that the result list is truncated to top_n.
func TestKeenable_SiteAndTopN(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[
{"title":"A","url":"https://a","description":"alpha"},
{"title":"B","url":"https://b","description":"beta"},
{"title":"C","url":"https://c","description":"gamma"},
{"title":"D","url":"https://d","description":"delta"}
]}`))
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
out, err := tool.InvokableRun(context.Background(),
`{"query":"x","site":"example.com","top_n":2}`)
if err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotBody["site"] != "example.com" {
t.Errorf("body.site = %v, want example.com", gotBody["site"])
}
var env keenableEnvelope
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
t.Fatalf("output not valid JSON: %v (raw=%s)", jerr, out)
}
if env.Error != "" {
t.Errorf("Error = %q, want empty", env.Error)
}
if len(env.Results) != 2 {
t.Fatalf("Results len = %d, want 2 (capped by top_n)", len(env.Results))
}
if env.Results[0].Title != "A" || env.Results[1].Title != "B" {
t.Errorf("Results = %+v, want first 2 upstream items", env.Results)
}
}
// TestKeenable_DefaultTopN verifies that omitting top_n keeps up to 10
// results from the upstream response (the default in the Python tool).
func TestKeenable_DefaultTopN(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 12 results; default top_n is 10, so we expect 10 in the envelope.
var results []map[string]string
for range 12 {
results = append(results, map[string]string{
"title": "T",
"url": "https://u",
"description": "d",
})
}
b, _ := json.Marshal(map[string]any{"results": results})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(b)
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
out, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
if err != nil {
t.Fatalf("InvokableRun: %v", err)
}
var env keenableEnvelope
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
t.Fatalf("output not valid JSON: %v", jerr)
}
if len(env.Results) != 10 {
t.Errorf("Results len = %d, want 10 (default top_n)", len(env.Results))
}
}
// TestKeenable_MissingQuery verifies that an empty query is rejected
// before any HTTP request is made.
func TestKeenable_MissingQuery(t *testing.T) {
t.Parallel()
tool := NewKeenableTool()
_, err := tool.InvokableRun(context.Background(), `{}`)
if err == nil {
t.Fatal("expected error for missing query")
}
if !strings.Contains(err.Error(), "query") {
t.Errorf("err = %v, want to mention query", err)
}
}
// TestKeenable_RealtimeRequiresAPIKey verifies the config-time rejection
// of realtime mode without a configured api_key.
func TestKeenable_RealtimeRequiresAPIKey(t *testing.T) {
t.Parallel()
tool := NewKeenableTool()
_, err := tool.InvokableRun(context.Background(), `{"query":"x","mode":"realtime"}`)
if err == nil {
t.Fatal("expected error for realtime mode without api_key")
}
if !strings.Contains(err.Error(), "configured api_key") {
t.Errorf("err = %v, want to mention configured api_key", err)
}
}
// TestKeenable_InvalidMode verifies that an unknown mode is rejected
// up front instead of being forwarded to the upstream.
func TestKeenable_InvalidMode(t *testing.T) {
t.Parallel()
tool := NewKeenableTool()
_, err := tool.InvokableRun(context.Background(), `{"query":"x","mode":"bogus"}`)
if err == nil {
t.Fatal("expected error for invalid mode")
}
if !strings.Contains(err.Error(), "mode") {
t.Errorf("err = %v, want to mention mode", err)
}
}
// TestKeenable_ResolveBaseURL exercises the HTTPS-only / loopback-http
// guard around KEENABLE_API_URL.
func TestKeenable_ResolveBaseURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
wantOK bool
wantValue string
}{
{"https default", "https://api.keenable.ai", true, "https://api.keenable.ai"},
{"https trailing slash", "https://api.keenable.ai/", true, "https://api.keenable.ai"},
{"http loopback ok", "http://localhost:8080", true, "http://localhost:8080"},
{"http 127 ok", "http://127.0.0.1:8080", true, "http://127.0.0.1:8080"},
{"http ::1 ok", "http://[::1]:8080", true, "http://[::1]:8080"},
{"http non-loopback rejected", "http://example.com", false, ""},
{"ftp rejected", "ftp://api.keenable.ai", false, ""},
{"query rejected", "https://api.keenable.ai?x=1", false, ""},
{"fragment rejected", "https://api.keenable.ai#frag", false, ""},
{"no host rejected", "https:///path", false, ""},
{"empty rejected", "", false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := resolveKeenableBaseURL(tc.raw)
if tc.wantOK {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if got != tc.wantValue {
t.Errorf("got = %q, want %q", got, tc.wantValue)
}
return
}
if err == nil {
t.Fatalf("got = %q, want error", got)
}
})
}
}
// TestKeenable_BaseURLFromEnv verifies that the KEENABLE_API_URL env var
// is honored. We use a fake resolver that does NOT touch os.Getenv so
// the test does not depend on the host environment.
func TestKeenable_BaseURLFromEnv(t *testing.T) {
t.Parallel()
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string {
return "https://" + srv.URL[len("http://"):]
})
if _, err := tool.InvokableRun(context.Background(), `{"query":"x"}`); err != nil {
t.Fatalf("InvokableRun: %v", err)
}
if gotPath != "/v1/search/public" {
t.Errorf("path = %q, want /v1/search/public", gotPath)
}
}
// TestKeenable_BadBaseURL verifies that an invalid KEENABLE_API_URL is
// reported back to the caller instead of being silently sent.
func TestKeenable_BadBaseURL(t *testing.T) {
t.Parallel()
tool := NewKeenableToolWithEnvBaseURL(NewHTTPHelper(), func() string { return "http://example.com" })
_, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
if err == nil {
t.Fatal("expected error for non-https non-loopback base URL")
}
if !strings.Contains(err.Error(), "https") {
t.Errorf("err = %v, want to mention https requirement", err)
}
}
// TestKeenable_UpstreamError verifies that a non-2xx upstream response
// is surfaced as an error and an _ERROR envelope.
func TestKeenable_UpstreamError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv.Close()
helper := NewHTTPHelper().WithClient(&http.Client{
Transport: rewriteHostTransport(srv.URL),
})
tool := NewKeenableToolWithEnvBaseURL(helper, func() string { return "https://" + srv.URL[len("http://"):] })
out, err := tool.InvokableRun(context.Background(), `{"query":"x"}`)
if err == nil {
t.Fatal("expected error for 5xx response")
}
var env keenableEnvelope
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
t.Fatalf("output not valid JSON: %v (raw=%s)", jerr, out)
}
if env.Error == "" {
t.Errorf("envelope Error = %q, want non-empty", env.Error)
}
}
// TestKeenable_Info verifies the model-facing metadata.
func TestKeenable_Info(t *testing.T) {
t.Parallel()
tool := NewKeenableTool()
info, err := tool.Info(context.Background())
if err != nil {
t.Fatalf("Info: %v", err)
}
if info.Name != "keenable" {
t.Errorf("Name = %q, want keenable", info.Name)
}
if !strings.Contains(info.Desc, "Keenable") {
t.Errorf("Desc = %q, want to mention Keenable", info.Desc)
}
if info.ParamsOneOf == nil {
t.Fatal("ParamsOneOf = nil, want schema definition")
}
paramsJSON, err := json.Marshal(info.ParamsOneOf)
if err != nil {
t.Fatalf("marshal ParamsOneOf: %v", err)
}
if strings.Contains(string(paramsJSON), "api_key") {
t.Fatalf("Info ParamsOneOf unexpectedly exposes api_key: %s", string(paramsJSON))
}
}
-1
View File
@@ -31,7 +31,6 @@ func TestQWeather_BuildURL(t *testing.T) {
// `qweatherEndpoint` var, which other tests (running in parallel) // `qweatherEndpoint` var, which other tests (running in parallel)
// temporarily replace with a httptest.Server URL. // temporarily replace with a httptest.Server URL.
cases := []struct { cases := []struct {
name string name string
params qweatherParams params qweatherParams
+17
View File
@@ -42,6 +42,7 @@ var registry = map[string]Factory{
"google": noConfig("google", func() einotool.BaseTool { return NewGoogleTool() }), "google": noConfig("google", func() einotool.BaseTool { return NewGoogleTool() }),
"google_scholar": noConfig("google_scholar", func() einotool.BaseTool { return NewGoogleScholarTool() }), "google_scholar": noConfig("google_scholar", func() einotool.BaseTool { return NewGoogleScholarTool() }),
"jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }), "jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }),
"keenable": buildKeenableTool,
"pubmed": noConfig("pubmed", func() einotool.BaseTool { return NewPubMedTool() }), "pubmed": noConfig("pubmed", func() einotool.BaseTool { return NewPubMedTool() }),
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }), "qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
"retrieval": noConfig("retrieval", func() einotool.BaseTool { return NewRetrievalTool() }), "retrieval": noConfig("retrieval", func() einotool.BaseTool { return NewRetrievalTool() }),
@@ -112,6 +113,22 @@ func buildExeSQLTool(params map[string]any) (einotool.BaseTool, error) {
return NewExeSQLTool(conn), nil return NewExeSQLTool(conn), nil
} }
func buildKeenableTool(params map[string]any) (einotool.BaseTool, error) {
if len(params) == 0 {
return NewKeenableTool(), nil
}
for key := range params {
if key != "api_key" {
return nil, fmt.Errorf("agent tool: tool %q only accepts node-level param api_key", "keenable")
}
}
apiKey, ok := params["api_key"].(string)
if !ok || strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("agent tool: tool %q requires non-empty string node-level param api_key", "keenable")
}
return NewKeenableToolWithAPIKey(nil, apiKey), nil
}
func decodeExeSQLConnParams(params map[string]any) (exesqlConnParams, error) { func decodeExeSQLConnParams(params map[string]any) (exesqlConnParams, error) {
if len(params) == 0 { if len(params) == 0 {
return exesqlConnParams{}, fmt.Errorf( return exesqlConnParams{}, fmt.Errorf(
+25 -10
View File
@@ -49,12 +49,12 @@ func TestBuildAll_AllRegisteredTools(t *testing.T) {
} }
params := map[string]map[string]any{ params := map[string]map[string]any{
"execute_sql": { "execute_sql": {
"db_type": "mysql", "db_type": "mysql",
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 3306, "port": 3306,
"database": "demo", "database": "demo",
"username": "u", "username": "u",
"password": "p", "password": "p",
"max_records": 10, "max_records": 10,
}, },
} }
@@ -77,6 +77,18 @@ func TestBuildAll_ExeSQLRequiresNodeParams(t *testing.T) {
} }
} }
func TestBuildAll_KeenableRejectsEmptyNodeAPIKey(t *testing.T) {
_, err := BuildAll([]string{"keenable"}, map[string]map[string]any{
"keenable": {"api_key": ""},
})
if err == nil {
t.Fatal("expected keenable config error")
}
if !strings.Contains(err.Error(), "requires non-empty string node-level param api_key") {
t.Fatalf("err = %q, want keenable api_key validation error", err.Error())
}
}
// TestToolRegistry_SchemasAreComplete sweeps every name the public // TestToolRegistry_SchemasAreComplete sweeps every name the public
// registry advertises (including the execute_sql/exesql and // registry advertises (including the execute_sql/exesql and
// retrieval/search_my_dateset alias pairs), builds the tool, and // retrieval/search_my_dateset alias pairs), builds the tool, and
@@ -95,7 +107,7 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
names := []string{ names := []string{
"akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo", "akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo",
"email", "execute_sql", "exesql", "github", "google", "email", "execute_sql", "exesql", "github", "google",
"google_scholar", "jin10", "pubmed", "qweather", "retrieval", "google_scholar", "jin10", "keenable", "pubmed", "qweather", "retrieval",
"search_my_dateset", "searxng", "tavily", "tushare", "wencai", "search_my_dateset", "searxng", "tavily", "tushare", "wencai",
"wikipedia", "yahoo_finance", "wikipedia", "yahoo_finance",
} }
@@ -118,6 +130,9 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
"password": "p", "password": "p",
"max_records": 10, "max_records": 10,
}, },
"keenable": {
"api_key": "key-xyz",
},
} }
tools, err := BuildAll(names, params) tools, err := BuildAll(names, params)
if err != nil { if err != nil {
@@ -150,9 +165,9 @@ func TestToolRegistry_SchemasAreComplete(t *testing.T) {
// search_my_dateset. A bug here would mean an alias was // search_my_dateset. A bug here would mean an alias was
// accidentally pointed at a different tool. // accidentally pointed at a different tool.
canonicalByAlias := map[string]string{ canonicalByAlias := map[string]string{
"execute_sql": "execute_sql", "execute_sql": "execute_sql",
"exesql": "execute_sql", "exesql": "execute_sql",
"retrieval": "search_my_dateset", "retrieval": "search_my_dateset",
"search_my_dateset": "search_my_dateset", "search_my_dateset": "search_my_dateset",
} }
for _, name := range names { for _, name := range names {
+11
View File
@@ -25,8 +25,10 @@ import (
"github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
"go.uber.org/zap"
"ragflow/internal/agent/runtime" "ragflow/internal/agent/runtime"
"ragflow/internal/common"
) )
// ErrGraphRAGNotSupported is returned by the Retrieval tool when // ErrGraphRAGNotSupported is returned by the Retrieval tool when
@@ -134,6 +136,12 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string
return "", fmt.Errorf("retrieval: parse arguments: %w", err) return "", fmt.Errorf("retrieval: parse arguments: %w", err)
} }
} }
common.Debug("agent retrieval tool: parsed arguments",
zap.String("query", args.Query),
zap.Strings("dataset_ids", args.DatasetIDs),
zap.Int("top_n", args.TopN),
zap.Bool("use_kg", args.UseKG),
)
if args.UseKG { if args.UseKG {
// Plan + §9 Q3: GraphRAG is out of scope for the Go // Plan + §9 Q3: GraphRAG is out of scope for the Go
@@ -166,6 +174,9 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string
Error: err.Error(), Error: err.Error(),
}), err }), err
} }
common.Debug("agent retrieval tool: search result",
zap.Int("chunks_count", len(chunks)),
)
// Map the chunks into the result envelope. The retrievalResult // Map the chunks into the result envelope. The retrievalResult
// type carries the eino-tool envelope shape (chunkPayload, not // type carries the eino-tool envelope shape (chunkPayload, not
// RetrievalChunk), so we translate. // RetrievalChunk), so we translate.
+34 -3
View File
@@ -62,6 +62,7 @@ import (
"ragflow/internal/dao" "ragflow/internal/dao"
"ragflow/internal/engine" "ragflow/internal/engine"
"ragflow/internal/entity"
"ragflow/internal/service/nlp" "ragflow/internal/service/nlp"
) )
@@ -71,13 +72,21 @@ import (
// beyond its docEngine + documentDAO handles, both of which the // beyond its docEngine + documentDAO handles, both of which the
// nlp package treats as concurrent-safe. // nlp package treats as concurrent-safe.
type NLPRetrievalAdapter struct { type NLPRetrievalAdapter struct {
svc *nlp.RetrievalService svc *nlp.RetrievalService
kbDAO knowledgebaseLookup
}
type knowledgebaseLookup interface {
GetByIDs(ids []string) ([]*entity.Knowledgebase, error)
} }
// NewNLPRetrievalAdapter wraps an already-constructed // NewNLPRetrievalAdapter wraps an already-constructed
// *nlp.RetrievalService. // *nlp.RetrievalService.
func NewNLPRetrievalAdapter(svc *nlp.RetrievalService) *NLPRetrievalAdapter { func NewNLPRetrievalAdapter(svc *nlp.RetrievalService) *NLPRetrievalAdapter {
return &NLPRetrievalAdapter{svc: svc} return &NLPRetrievalAdapter{
svc: svc,
kbDAO: dao.NewKnowledgebaseDAO(),
}
} }
// NewNLPRetrievalAdapterFromDeps is the convenience constructor // NewNLPRetrievalAdapterFromDeps is the convenience constructor
@@ -88,7 +97,10 @@ func NewNLPRetrievalAdapter(svc *nlp.RetrievalService) *NLPRetrievalAdapter {
// matches chat_session.go's newChatSessionServiceWithRetrieval // matches chat_session.go's newChatSessionServiceWithRetrieval
// call site. // call site.
func NewNLPRetrievalAdapterFromDeps(docEngine engine.DocEngine, documentDAO *dao.DocumentDAO) *NLPRetrievalAdapter { func NewNLPRetrievalAdapterFromDeps(docEngine engine.DocEngine, documentDAO *dao.DocumentDAO) *NLPRetrievalAdapter {
return &NLPRetrievalAdapter{svc: nlp.NewRetrievalService(docEngine, documentDAO)} return &NLPRetrievalAdapter{
svc: nlp.NewRetrievalService(docEngine, documentDAO),
kbDAO: dao.NewKnowledgebaseDAO(),
}
} }
// Search implements RetrievalService. The translation rules live // Search implements RetrievalService. The translation rules live
@@ -120,6 +132,7 @@ func (a *NLPRetrievalAdapter) Search(ctx context.Context, req RetrievalRequest)
// headroom — matches the chat_session.go call pattern). // headroom — matches the chat_session.go call pattern).
nlpReq := &nlp.RetrievalRequest{ nlpReq := &nlp.RetrievalRequest{
Question: req.Query, Question: req.Query,
TenantIDs: a.resolveTenantIDs(req),
KbIDs: append([]string(nil), req.DatasetIDs...), KbIDs: append([]string(nil), req.DatasetIDs...),
Page: 1, Page: 1,
PageSize: topN, PageSize: topN,
@@ -148,6 +161,24 @@ func (a *NLPRetrievalAdapter) Search(ctx context.Context, req RetrievalRequest)
return out, nil return out, nil
} }
func (a *NLPRetrievalAdapter) resolveTenantIDs(req RetrievalRequest) []string {
seen := map[string]struct{}{}
tenantIDs := make([]string, 0, 1)
appendTenantID := func(tenantID string) {
if tenantID == "" {
return
}
if _, ok := seen[tenantID]; ok {
return
}
seen[tenantID] = struct{}{}
tenantIDs = append(tenantIDs, tenantID)
}
appendTenantID(req.TenantID)
return tenantIDs
}
// translateChunk converts one nlp chunk map into a RetrievalChunk. // translateChunk converts one nlp chunk map into a RetrievalChunk.
// Tolerates missing fields (returns zero values) and wrong types // Tolerates missing fields (returns zero values) and wrong types
// (returns zero values) so a single bad chunk from the doc engine // (returns zero values) so a single bad chunk from the doc engine
+15
View File
@@ -207,3 +207,18 @@ func TestNewNLPRetrievalAdapter_NilService(t *testing.T) {
t.Errorf("err = %v, want ErrRetrievalServiceMissing", err) t.Errorf("err = %v, want ErrRetrievalServiceMissing", err)
} }
} }
func TestNLPRetrievalAdapter_ResolveTenantIDsStaysWithinRequestTenant(t *testing.T) {
a := &NLPRetrievalAdapter{}
got := a.resolveTenantIDs(RetrievalRequest{
TenantID: "tenant-a",
DatasetIDs: []string{"kb-1", "kb-2", "kb-missing"},
})
if len(got) != 1 {
t.Fatalf("tenantIDs len=%d want 1, got=%v", len(got), got)
}
if got[0] != "tenant-a" {
t.Fatalf("tenantIDs=%v want [tenant-a]", got)
}
}
+23 -23
View File
@@ -24,7 +24,7 @@
// a normal workflow node. See the .claude/plans/eino-workflow-loop.md // a normal workflow node. See the .claude/plans/eino-workflow-loop.md
// plan for the design rationale. // plan for the design rationale.
// //
// Foundation for the canvas Loop component // # Foundation for the canvas Loop component
// //
// AddLoopNode is also the runtime driver for the RAGFlow agent canvas's // AddLoopNode is also the runtime driver for the RAGFlow agent canvas's
// "Loop" component (internal/agent/component/loop.go). The canvas engine // "Loop" component (internal/agent/component/loop.go). The canvas engine
@@ -141,11 +141,11 @@ var (
type LoopOption func(*loopOptions) type LoopOption func(*loopOptions)
type loopOptions struct { type loopOptions struct {
maxIterations int maxIterations int
compileOpts []compose.GraphCompileOption compileOpts []compose.GraphCompileOption
runOpts []compose.Option runOpts []compose.Option
streamMode LoopStreamMode streamMode LoopStreamMode
checkpointBuilder func(nodeKey string, iteration int) string checkpointBuilder func(nodeKey string, iteration int) string
enableSubCheckpoint bool enableSubCheckpoint bool
} }
@@ -237,8 +237,8 @@ func defaultCheckpointBuilder(nodeKey string, iteration int) string {
func getLoopOptions(opts []LoopOption) *loopOptions { func getLoopOptions(opts []LoopOption) *loopOptions {
o := &loopOptions{ o := &loopOptions{
streamMode: LoopStreamFinalOnly, streamMode: LoopStreamFinalOnly,
checkpointBuilder: defaultCheckpointBuilder, checkpointBuilder: defaultCheckpointBuilder,
enableSubCheckpoint: true, enableSubCheckpoint: true,
} }
for _, opt := range opts { for _, opt := range opts {
@@ -260,12 +260,12 @@ func getLoopOptions(opts []LoopOption) *loopOptions {
// by the loop itself) sidesteps the need for callers to register // by the loop itself) sidesteps the need for callers to register
// generic types with the schema package — see plan §"Type shape". // generic types with the schema package — see plan §"Type shape".
type loopInterruptState struct { type loopInterruptState struct {
Iteration int `json:"iteration"` Iteration int `json:"iteration"`
CurrentInput []byte `json:"current_input"` CurrentInput []byte `json:"current_input"`
StreamMode LoopStreamMode `json:"stream_mode"` StreamMode LoopStreamMode `json:"stream_mode"`
SubCheckpointID string `json:"sub_checkpoint_id"` SubCheckpointID string `json:"sub_checkpoint_id"`
SubCheckpoints map[string][]byte `json:"sub_checkpoints,omitempty"` SubCheckpoints map[string][]byte `json:"sub_checkpoints,omitempty"`
ReplayChunks [][]byte `json:"replay_chunks,omitempty"` ReplayChunks [][]byte `json:"replay_chunks,omitempty"`
} }
// AddLoopNode appends a loop node to the outer workflow `wf`. The // AddLoopNode appends a loop node to the outer workflow `wf`. The
@@ -375,15 +375,15 @@ func loadLoopSnapshot[T any](ctx context.Context, defaultMode LoopStreamMode) (l
if streamMode == "" { if streamMode == "" {
streamMode = defaultMode streamMode = defaultMode
} }
return loopSnapshot{ return loopSnapshot{
startIteration: st.Iteration, startIteration: st.Iteration,
current: st.CurrentInput, current: st.CurrentInput,
streamMode: streamMode, streamMode: streamMode,
subCheckID: st.SubCheckpointID, subCheckID: st.SubCheckpointID,
subCheckpoints: cloneCheckpointMap(st.SubCheckpoints), subCheckpoints: cloneCheckpointMap(st.SubCheckpoints),
replayChunks: cloneByteSlices(st.ReplayChunks), replayChunks: cloneByteSlices(st.ReplayChunks),
}, nil }, nil
} }
// encodeState marshals a loop snapshot to the persisted form. // encodeState marshals a loop snapshot to the persisted form.
func encodeState(s loopSnapshot) ([]byte, error) { func encodeState(s loopSnapshot) ([]byte, error) {
@@ -313,9 +313,9 @@ func TestOptions_CompileFailureIsolated(t *testing.T) {
func TestOptions_SentinelErrorsExist(t *testing.T) { func TestOptions_SentinelErrorsExist(t *testing.T) {
sentinels := map[string]error{ sentinels := map[string]error{
"ErrLoopMaxIterationsExceeded": ErrLoopMaxIterationsExceeded, "ErrLoopMaxIterationsExceeded": ErrLoopMaxIterationsExceeded,
"ErrLoopSubGraphInterrupted": ErrLoopSubGraphInterrupted, "ErrLoopSubGraphInterrupted": ErrLoopSubGraphInterrupted,
"ErrLoopResumeStateInvalid": ErrLoopResumeStateInvalid, "ErrLoopResumeStateInvalid": ErrLoopResumeStateInvalid,
"ErrLoopQuitConditionFailed": ErrLoopQuitConditionFailed, "ErrLoopQuitConditionFailed": ErrLoopQuitConditionFailed,
} }
for name, e := range sentinels { for name, e := range sentinels {
if e == nil { if e == nil {
+2 -2
View File
@@ -104,8 +104,8 @@ func (e *Engine) resolveProvider(path string) (Provider, string, error) {
// List lists nodes at the given path // List lists nodes at the given path
// If path is empty, returns: // If path is empty, returns:
// 1. Built-in providers (e.g., datasets) // 1. Built-in providers (e.g., datasets)
// 2. Top-level directories from files provider (if any) // 2. Top-level directories from files provider (if any)
func (e *Engine) List(ctx stdctx.Context, path string, opts *ListOptions) (*Result, error) { func (e *Engine) List(ctx stdctx.Context, path string, opts *ListOptions) (*Result, error) {
// Normalize path // Normalize path
path = normalizePath(path) path = normalizePath(path)
@@ -18,7 +18,7 @@ package security
// ThreatPattern represents a security threat detection pattern // ThreatPattern represents a security threat detection pattern
// Inspired by hermes-agent's skills_guard.py // Inspired by hermes-agent's skills_guard.py
type ThreatPattern struct { type ThreatPattern struct {
Pattern string // Regular expression pattern Pattern string // Regular expression pattern
PatternID string // Unique identifier for this pattern PatternID string // Unique identifier for this pattern
Severity string // critical | high | medium | low Severity string // critical | high | medium | low
@@ -271,9 +271,9 @@ var TrustedRepos = map[string]bool{
// Format: [safe, caution, dangerous] -> action // Format: [safe, caution, dangerous] -> action
// Actions: allow, block, ask // Actions: allow, block, ask
var InstallPolicy = map[string][3]string{ var InstallPolicy = map[string][3]string{
"builtin": {"allow", "allow", "allow"}, // Official skills: always allow "builtin": {"allow", "allow", "allow"}, // Official skills: always allow
"trusted": {"allow", "allow", "block"}, // Trusted repos: caution allowed, dangerous blocked "trusted": {"allow", "allow", "block"}, // Trusted repos: caution allowed, dangerous blocked
"community": {"allow", "block", "block"}, // Community: only safe allowed "community": {"allow", "block", "block"}, // Community: only safe allowed
} }
// VerdictIndex maps verdict to array index // VerdictIndex maps verdict to array index
@@ -36,8 +36,8 @@ type Finding struct {
type ScanResult struct { type ScanResult struct {
SkillName string SkillName string
Source string Source string
TrustLevel string // builtin | trusted | community TrustLevel string // builtin | trusted | community
Verdict string // safe | caution | dangerous Verdict string // safe | caution | dangerous
Findings []Finding Findings []Finding
} }
@@ -897,12 +897,12 @@ func extractQueryTerms(query string) []string {
func isSafePath(path string) bool { func isSafePath(path string) bool {
// Clean the path // Clean the path
clean := filepath.Clean(path) clean := filepath.Clean(path)
// Check for absolute paths // Check for absolute paths
if filepath.IsAbs(clean) { if filepath.IsAbs(clean) {
return false return false
} }
// Check for parent directory references // Check for parent directory references
parts := strings.Split(clean, string(filepath.Separator)) parts := strings.Split(clean, string(filepath.Separator))
for _, part := range parts { for _, part := range parts {
@@ -910,7 +910,7 @@ func isSafePath(path string) bool {
return false return false
} }
} }
return true return true
} }
@@ -149,7 +149,7 @@ func (s *GitHubSource) fetchFileContent(owner, repo, filePath string) (string, e
} }
var result struct { var result struct {
Content string `json:"content"` Content string `json:"content"`
Encoding string `json:"encoding"` Encoding string `json:"encoding"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -194,9 +194,9 @@ func (s *GitHubSource) fetchDirectoryContents(owner, repo, dirPath string) (map[
} }
var items []struct { var items []struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Type string `json:"type"` Type string `json:"type"`
DownloadURL string `json:"download_url"` DownloadURL string `json:"download_url"`
} }
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
+4 -4
View File
@@ -58,7 +58,7 @@ type SkillInstallCommand struct {
// sourceHTTPClientAdapter adapts filesystem.HTTPClientInterface to source.HTTPClientInterface // sourceHTTPClientAdapter adapts filesystem.HTTPClientInterface to source.HTTPClientInterface
// This allows us to use the existing HTTP client infrastructure with the source package // This allows us to use the existing HTTP client infrastructure with the source package
type sourceHTTPClientAdapter struct { type sourceHTTPClientAdapter struct {
client HTTPClientInterface client HTTPClientInterface
httpClient *http.Client httpClient *http.Client
} }
@@ -121,9 +121,9 @@ func NewInstallSkillCommand(client HTTPClientInterface, fileProvider *FileProvid
adaptedClient := &sourceHTTPClientAdapter{ adaptedClient := &sourceHTTPClientAdapter{
client: client, client: client,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
Transport: transport, Transport: transport,
Jar: jar, Jar: jar,
}, },
} }
+24 -24
View File
@@ -34,13 +34,13 @@ const (
// Node represents a node in the context filesystem // Node represents a node in the context filesystem
// This is the unified output format for all providers // This is the unified output format for all providers
type Node struct { type Node struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Type NodeType `json:"type"` Type NodeType `json:"type"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"`
} }
// CommandType represents the type of command // CommandType represents the type of command
@@ -70,12 +70,12 @@ type ListOptions struct {
// SearchOptions represents options for search operations // SearchOptions represents options for search operations
type SearchOptions struct { type SearchOptions struct {
Query string `json:"query"` Query string `json:"query"`
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"` Offset int `json:"offset,omitempty"`
Recursive bool `json:"recursive,omitempty"` Recursive bool `json:"recursive,omitempty"`
TopK int `json:"top_k,omitempty"` // Number of top results to return (default: 10) TopK int `json:"top_k,omitempty"` // Number of top results to return (default: 10)
Threshold float64 `json:"threshold,omitempty"` // Similarity threshold (default: 0.2) Threshold float64 `json:"threshold,omitempty"` // Similarity threshold (default: 0.2)
Dirs []string `json:"dirs,omitempty"` // List of directories to search in Dirs []string `json:"dirs,omitempty"` // List of directories to search in
} }
@@ -90,12 +90,12 @@ type Result struct {
// PathInfo represents parsed path information // PathInfo represents parsed path information
type PathInfo struct { type PathInfo struct {
Provider string // The provider name (e.g., "datasets", "chats") Provider string // The provider name (e.g., "datasets", "chats")
Path string // The full path Path string // The full path
Components []string // Path components Components []string // Path components
IsRoot bool // Whether this is the root path for the provider IsRoot bool // Whether this is the root path for the provider
ResourceID string // Resource ID if applicable ResourceID string // Resource ID if applicable
ResourceName string // Resource name if applicable ResourceName string // Resource name if applicable
} }
// ProviderInfo holds metadata about a provider // ProviderInfo holds metadata about a provider
@@ -107,10 +107,10 @@ type ProviderInfo struct {
// Common error messages // Common error messages
const ( const (
ErrInvalidPath = "invalid path" ErrInvalidPath = "invalid path"
ErrProviderNotFound = "provider not found for path" ErrProviderNotFound = "provider not found for path"
ErrNotSupported = "operation not supported" ErrNotSupported = "operation not supported"
ErrNotFound = "resource not found" ErrNotFound = "resource not found"
ErrUnauthorized = "unauthorized" ErrUnauthorized = "unauthorized"
ErrInternal = "internal error" ErrInternal = "internal error"
) )
+4 -4
View File
@@ -36,10 +36,10 @@ func FormatNode(node *Node, format string) map[string]interface{} {
} }
case "table": case "table":
return map[string]interface{}{ return map[string]interface{}{
"name": node.Name, "name": node.Name,
"path": node.Path, "path": node.Path,
"type": string(node.Type), "type": string(node.Type),
"size": formatSize(node.Size), "size": formatSize(node.Size),
"created_at": formatTime(node.CreatedAt), "created_at": formatTime(node.CreatedAt),
"updated_at": formatTime(node.UpdatedAt), "updated_at": formatTime(node.UpdatedAt),
} }
+4 -4
View File
@@ -97,10 +97,10 @@ func ConvertFloatsToPyFormat(v interface{}) interface{} {
// the O(n * eps) of a naive left-to-right loop. // the O(n * eps) of a naive left-to-right loop.
// //
// This implementation matches numpy's exact pairwise summation algorithm: // This implementation matches numpy's exact pairwise summation algorithm:
// - For n < 16: uses naive left-to-right sum (matching numpy's small-array optimization) // - For n < 16: uses naive left-to-right sum (matching numpy's small-array optimization)
// - For n >= 16: processes pairs left-to-right, carrying any odd element to the end // - For n >= 16: processes pairs left-to-right, carrying any odd element to the end
// of the next level. This matches numpy's pairwise reduction in // of the next level. This matches numpy's pairwise reduction in
// numpy/core/src/umath/reduction.c. // numpy/core/src/umath/reduction.c.
// //
// xs is modified in place. Pass a copy if the caller still needs the input. // xs is modified in place. Pass a copy if the caller still needs the input.
// //
+7 -4
View File
@@ -27,14 +27,17 @@ import (
// - ISO 8601 / RFC3339 (e.g., "2026-04-09T18:55:46+08:00") // - ISO 8601 / RFC3339 (e.g., "2026-04-09T18:55:46+08:00")
// //
// Args: // Args:
// dateString: Date string in supported format //
// dateString: Date string in supported format
// //
// Returns: // Returns:
// float64: Number of seconds between the given date and current time //
// float64: Number of seconds between the given date and current time
// //
// Example: // Example:
// DeltaSeconds("2024-01-01 12:00:00") //
// DeltaSeconds("2026-04-09T18:55:46+08:00") // DeltaSeconds("2024-01-01 12:00:00")
// DeltaSeconds("2026-04-09T18:55:46+08:00")
func DeltaSeconds(dateString string) (float64, error) { func DeltaSeconds(dateString string) (float64, error) {
// Try RFC3339 format first (ISO 8601 with timezone, e.g., "2026-04-09T18:55:46+08:00") // Try RFC3339 format first (ISO 8601 with timezone, e.g., "2026-04-09T18:55:46+08:00")
dt, err := time.Parse(time.RFC3339, dateString) dt, err := time.Parse(time.RFC3339, dateString)
+2 -2
View File
@@ -210,8 +210,8 @@ func (c *Client) doPost(ctx context.Context, url string, buildBody bodyBuilder,
// httpError carries the HTTP status + body so callers can inspect. // httpError carries the HTTP status + body so callers can inspect.
// retryable=true means the doPost loop already exhausted retries. // retryable=true means the doPost loop already exhausted retries.
type httpError struct { type httpError struct {
Status string Status string
Body string Body string
retryable bool retryable bool
} }
+10 -10
View File
@@ -50,16 +50,16 @@ type DLAResult struct {
// at indices 4/6/7/9 are kept verbatim for backward compatibility // at indices 4/6/7/9 are kept verbatim for backward compatibility
// with existing inference servers. // with existing inference servers.
var DLAClasses = []string{ var DLAClasses = []string{
"title", // 0 "title", // 0
"text", // 1 "text", // 1
"reference", // 2 "reference", // 2
"figure", // 3 "figure", // 3
"figure caption", // 4 "figure caption", // 4
"table", // 5 "table", // 5
"table caption", // 6 "table caption", // 6
"table caption", // 7 duplicate "table caption", // 7 duplicate
"equation", // 8 "equation", // 8
"figure caption", // 9 duplicate "figure caption", // 9 duplicate
} }
// rawDLA is the wire format the DLA server returns // rawDLA is the wire format the DLA server returns
+1 -1
View File
@@ -106,7 +106,7 @@ func TestDLA_SuccessfulResponse(t *testing.T) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"bboxes": [][]float64{ "bboxes": [][]float64{
{10, 20, 100, 200, 0.95, 0}, // title {10, 20, 100, 200, 0.95, 0}, // title
{10, 220, 500, 400, 0.88, 1}, // text {10, 220, 500, 400, 0.88, 1}, // text
{50, 420, 600, 700, 0.77, 3}, // figure {50, 420, 600, 700, 0.77, 3}, // figure
}, },
+1 -1
View File
@@ -95,4 +95,4 @@ func (e *elasticsearchEngine) indexExists(ctx context.Context, indexName string)
// buildMetadataIndexName returns the metadata index name for a tenant // buildMetadataIndexName returns the metadata index name for a tenant
func buildMetadataIndexName(tenantID string) string { func buildMetadataIndexName(tenantID string) string {
return fmt.Sprintf("ragflow_doc_meta_%s", tenantID) return fmt.Sprintf("ragflow_doc_meta_%s", tenantID)
} }
+25 -25
View File
@@ -35,33 +35,33 @@ var dateRegex = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
// Supported operators // Supported operators
var supportedOperators = map[string]bool{ var supportedOperators = map[string]bool{
"=": true, "=": true,
"≠": true, "≠": true,
">": true, ">": true,
"<": true, "<": true,
"≥": true, "≥": true,
"≤": true, "≤": true,
"in": true, "in": true,
"not in": true, "not in": true,
"contains": true, "contains": true,
"not contains": true, "not contains": true,
"start with": true, "start with": true,
"end with": true, "end with": true,
"empty": true, "empty": true,
"not empty": true, "not empty": true,
} }
// Range operators mapping // Range operators mapping
var rangeOps = map[string]string{ var rangeOps = map[string]string{
">": "gt", ">": "gt",
"<": "lt", "<": "lt",
"≥": "gte", "≥": "gte",
"≤": "lte", "≤": "lte",
} }
// Negative operators unsafe for multi-valued fields // Negative operators unsafe for multi-valued fields
var multivalueUnsafeNegativeOps = map[string]bool{ var multivalueUnsafeNegativeOps = map[string]bool{
"≠": true, "≠": true,
"not in": true, "not in": true,
} }
@@ -77,8 +77,8 @@ func (e *UnsupportedMetaFilterError) Error() string {
// TranslatedFilter represents a single filter rendered as ES bool clauses // TranslatedFilter represents a single filter rendered as ES bool clauses
type TranslatedFilter struct { type TranslatedFilter struct {
Must []map[string]interface{} Must []map[string]interface{}
MustNot []map[string]interface{} MustNot []map[string]interface{}
} }
// ToClauses converts to ES clauses // ToClauses converts to ES clauses
@@ -97,7 +97,7 @@ func (f *TranslatedFilter) ToClauses() []map[string]interface{} {
// MetaFilterPushdownPlan represents composed ES bool query body // MetaFilterPushdownPlan represents composed ES bool query body
type MetaFilterPushdownPlan struct { type MetaFilterPushdownPlan struct {
Logic string Logic string
translated []*TranslatedFilter translated []*TranslatedFilter
} }
@@ -522,7 +522,7 @@ func termOrMatch(fieldPath string, value interface{}) map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"term": map[string]interface{}{ "term": map[string]interface{}{
keywordPath(fieldPath): map[string]interface{}{ keywordPath(fieldPath): map[string]interface{}{
"value": s, "value": s,
"case_insensitive": true, "case_insensitive": true,
}, },
}, },
@@ -556,7 +556,7 @@ func termsStringOrNumeric(fieldPath string, members []interface{}) map[string]in
return map[string]interface{}{ return map[string]interface{}{
"bool": map[string]interface{}{ "bool": map[string]interface{}{
"should": shouldClauses, "should": shouldClauses,
"minimum_should_match": 1, "minimum_should_match": 1,
}, },
} }
@@ -567,7 +567,7 @@ func wildcard(fieldPath string, pattern string) map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"wildcard": map[string]interface{}{ "wildcard": map[string]interface{}{
keywordPath(fieldPath): map[string]interface{}{ keywordPath(fieldPath): map[string]interface{}{
"value": pattern, "value": pattern,
"case_insensitive": true, "case_insensitive": true,
}, },
}, },
@@ -679,4 +679,4 @@ func splitJSONParts(s string) []string {
} }
return parts return parts
} }
+19 -19
View File
@@ -30,28 +30,28 @@ var keyPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// Supported operators // Supported operators
var supportedOperators = map[string]bool{ var supportedOperators = map[string]bool{
"=": true, "=": true,
"≠": true, "≠": true,
">": true, ">": true,
"<": true, "<": true,
"≥": true, "≥": true,
"≤": true, "≤": true,
"in": true, "in": true,
"not in": true, "not in": true,
"contains": true, "contains": true,
"not contains": true, "not contains": true,
"start with": true, "start with": true,
"end with": true, "end with": true,
"empty": true, "empty": true,
"not empty": true, "not empty": true,
} }
// Range operators mapping // Range operators mapping
var rangeOps = map[string]string{ var rangeOps = map[string]string{
">": ">", ">": ">",
"<": "<", "<": "<",
"≥": ">=", "≥": ">=",
"≤": "<=", "≤": "<=",
} }
// MetaFilterTranslator translates filter clauses to Infinity SQL // MetaFilterTranslator translates filter clauses to Infinity SQL
@@ -150,7 +150,7 @@ func (t *MetaFilterTranslator) translateIn(key string, value interface{}, flt ma
coerced := coerceRangeValue(m, flt) coerced := coerceRangeValue(m, flt)
if num, ok := coerceToFloat(coerced); ok { if num, ok := coerceToFloat(coerced); ok {
numParts = append(numParts, fmt.Sprintf("JSON_CONTAINS(meta_fields, '$.%s', %v)", key, num)) numParts = append(numParts, fmt.Sprintf("JSON_CONTAINS(meta_fields, '$.%s', %v)", key, num))
} else if s, ok := coerced.(string); ok { } else if s, ok := coerced.(string); ok {
escaped := escapeSQLString(s) escaped := escapeSQLString(s)
stringParts = append(stringParts, fmt.Sprintf("JSON_CONTAINS(meta_fields, '$.%s', '\"%s\"')", key, escaped)) stringParts = append(stringParts, fmt.Sprintf("JSON_CONTAINS(meta_fields, '$.%s', '\"%s\"')", key, escaped))
} }
@@ -579,4 +579,4 @@ func splitJSONParts(s string) []string {
} }
return parts return parts
} }
+26 -26
View File
@@ -55,36 +55,36 @@ func (FileCommitItem) TableName() string {
// TreeNode represents a file node in the commit tree state snapshot. // TreeNode represents a file node in the commit tree state snapshot.
type TreeNode struct { type TreeNode struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` // "file" or "folder" Type string `json:"type"` // "file" or "folder"
Hash string `json:"hash,omitempty"` Hash string `json:"hash,omitempty"`
Location string `json:"location,omitempty"` Location string `json:"location,omitempty"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
Status string `json:"status"` // "1" = active, "0" = deleted Status string `json:"status"` // "1" = active, "0" = deleted
Children []*TreeNode `json:"children,omitempty"` Children []*TreeNode `json:"children,omitempty"`
} }
// FileChange represents a file change in a commit request. // FileChange represents a file change in a commit request.
type FileChange struct { type FileChange struct {
FileID string `json:"file_id"` FileID string `json:"file_id"`
FileName string `json:"file_name"` FileName string `json:"file_name"`
Operation string `json:"operation"` // "add", "modify", "delete", "rename" Operation string `json:"operation"` // "add", "modify", "delete", "rename"
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
OldName string `json:"old_name,omitempty"` OldName string `json:"old_name,omitempty"`
NewName string `json:"new_name,omitempty"` NewName string `json:"new_name,omitempty"`
} }
// CommitResponse is the API response for a commit. // CommitResponse is the API response for a commit.
type CommitResponse struct { type CommitResponse struct {
ID string `json:"id"` ID string `json:"id"`
FolderID string `json:"folder_id"` FolderID string `json:"folder_id"`
ParentID *string `json:"parent_id,omitempty"` ParentID *string `json:"parent_id,omitempty"`
Message string `json:"message"` Message string `json:"message"`
AuthorID string `json:"author_id"` AuthorID string `json:"author_id"`
FileCount int `json:"file_count"` FileCount int `json:"file_count"`
TreeState *string `json:"tree_state,omitempty"` TreeState *string `json:"tree_state,omitempty"`
CreateTime *int64 `json:"create_time,omitempty"` CreateTime *int64 `json:"create_time,omitempty"`
} }
// DiffEntry represents a single diff entry between two commits. // DiffEntry represents a single diff entry between two commits.
@@ -100,9 +100,9 @@ type DiffEntry struct {
// VersionEntry represents a single version in a file's version history. // VersionEntry represents a single version in a file's version history.
type VersionEntry struct { type VersionEntry struct {
CommitID string `json:"commit_id"` CommitID string `json:"commit_id"`
Operation string `json:"operation"` Operation string `json:"operation"`
Hash string `json:"hash"` Hash string `json:"hash"`
CreateTime *int64 `json:"create_time,omitempty"` CreateTime *int64 `json:"create_time,omitempty"`
Message string `json:"message"` Message string `json:"message"`
} }
-1
View File
@@ -364,4 +364,3 @@ func (a *AvianModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) {
func (a *AvianModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) { func (a *AvianModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) {
return nil, fmt.Errorf("%s, no such method", a.Name()) return nil, fmt.Errorf("%s, no such method", a.Name())
} }
+13 -13
View File
@@ -44,18 +44,18 @@ func DefaultFieldConfig() FieldConfig {
// SkillSearchConfig represents the search configuration for skills // SkillSearchConfig represents the search configuration for skills
type SkillSearchConfig struct { type SkillSearchConfig struct {
ID string `gorm:"column:id;primaryKey;size:32" json:"id"` ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"` TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
SpaceID string `gorm:"column:space_id;size:128;not null;default:'default';index" json:"space_id"` SpaceID string `gorm:"column:space_id;size:128;not null;default:'default';index" json:"space_id"`
EmbdID string `gorm:"column:embd_id;size:128;not null" json:"embd_id"` EmbdID string `gorm:"column:embd_id;size:128;not null" json:"embd_id"`
Status string `gorm:"column:status;size:1;default:1" json:"status"` Status string `gorm:"column:status;size:1;default:1" json:"status"`
VectorSimilarityWeight float64 `gorm:"column:vector_similarity_weight;default:0.3" json:"vector_similarity_weight"` VectorSimilarityWeight float64 `gorm:"column:vector_similarity_weight;default:0.3" json:"vector_similarity_weight"`
SimilarityThreshold float64 `gorm:"column:similarity_threshold;default:0.2" json:"similarity_threshold"` SimilarityThreshold float64 `gorm:"column:similarity_threshold;default:0.2" json:"similarity_threshold"`
FieldConfig JSONMap `gorm:"column:field_config;type:json" json:"field_config"` FieldConfig JSONMap `gorm:"column:field_config;type:json" json:"field_config"`
RerankID *string `gorm:"column:rerank_id;size:128" json:"rerank_id,omitempty"` RerankID *string `gorm:"column:rerank_id;size:128" json:"rerank_id,omitempty"`
TenantRerankID *int64 `gorm:"column:tenant_rerank_id" json:"tenant_rerank_id,omitempty"` TenantRerankID *int64 `gorm:"column:tenant_rerank_id" json:"tenant_rerank_id,omitempty"`
TopK int64 `gorm:"column:top_k;default:10" json:"top_k"` TopK int64 `gorm:"column:top_k;default:10" json:"top_k"`
IndexVersion string `gorm:"column:index_version;size:32;default:'1.0.0'" json:"index_version"` IndexVersion string `gorm:"column:index_version;size:32;default:'1.0.0'" json:"index_version"`
BaseModel BaseModel
} }
@@ -69,7 +69,7 @@ func (s *SkillSearchConfig) ToMap() map[string]interface{} {
result := map[string]interface{}{ result := map[string]interface{}{
"id": s.ID, "id": s.ID,
"tenant_id": s.TenantID, "tenant_id": s.TenantID,
"space_id": s.SpaceID, "space_id": s.SpaceID,
"embd_id": s.EmbdID, "embd_id": s.EmbdID,
"vector_similarity_weight": s.VectorSimilarityWeight, "vector_similarity_weight": s.VectorSimilarityWeight,
"similarity_threshold": s.SimilarityThreshold, "similarity_threshold": s.SimilarityThreshold,
+15 -15
View File
@@ -27,15 +27,15 @@ const (
// SkillSpace represents a skills space (library) that contains skills // SkillSpace represents a skills space (library) that contains skills
type SkillSpace struct { type SkillSpace struct {
ID string `gorm:"column:id;primaryKey;size:32" json:"id"` ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"` TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
Name string `gorm:"column:name;size:128;not null" json:"name"` Name string `gorm:"column:name;size:128;not null" json:"name"`
FolderID string `gorm:"column:folder_id;size:32;not null" json:"folder_id"` FolderID string `gorm:"column:folder_id;size:32;not null" json:"folder_id"`
Description string `gorm:"column:description;type:text" json:"description"` Description string `gorm:"column:description;type:text" json:"description"`
EmbdID string `gorm:"column:embd_id;size:128" json:"embd_id"` EmbdID string `gorm:"column:embd_id;size:128" json:"embd_id"`
RerankID string `gorm:"column:rerank_id;size:128" json:"rerank_id"` RerankID string `gorm:"column:rerank_id;size:128" json:"rerank_id"`
TopK int `gorm:"column:top_k;default:10" json:"top_k"` TopK int `gorm:"column:top_k;default:10" json:"top_k"`
Status string `gorm:"column:status;size:1;default:1" json:"status"` Status string `gorm:"column:status;size:1;default:1" json:"status"`
BaseModel BaseModel
} }
@@ -61,12 +61,12 @@ func (s *SkillSpace) StatusDescription() string {
// ToMap converts SkillSpace to a map for JSON response // ToMap converts SkillSpace to a map for JSON response
func (s *SkillSpace) ToMap() map[string]interface{} { func (s *SkillSpace) ToMap() map[string]interface{} {
result := map[string]interface{}{ result := map[string]interface{}{
"id": s.ID, "id": s.ID,
"tenant_id": s.TenantID, "tenant_id": s.TenantID,
"name": s.Name, "name": s.Name,
"folder_id": s.FolderID, "folder_id": s.FolderID,
"top_k": s.TopK, "top_k": s.TopK,
"status": s.StatusDescription(), "status": s.StatusDescription(),
} }
if s.Description != "" { if s.Description != "" {
+131 -6
View File
@@ -29,6 +29,7 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"ragflow/internal/agent/canvas" "ragflow/internal/agent/canvas"
"ragflow/internal/common" "ragflow/internal/common"
@@ -53,7 +54,7 @@ type agentFileService interface {
// NewAgentHandler assigns the concrete *service.AgentService — which // NewAgentHandler assigns the concrete *service.AgentService — which
// satisfies this interface because its RunAgent signature matches. // satisfies this interface because its RunAgent signature matches.
type chatAgentService interface { type chatAgentService interface {
RunAgent(ctx context.Context, userID, canvasID, sessionID, version, userInput string) (<-chan canvas.RunEvent, error) RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any) (<-chan canvas.RunEvent, error)
} }
// AgentHandler agent handler // AgentHandler agent handler
@@ -869,6 +870,7 @@ func (h *AgentHandler) DeleteAgentSession(c *gin.Context) {
type agentChatCompletionsRequest struct { type agentChatCompletionsRequest struct {
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
Query string `json:"query"` Query string `json:"query"`
Inputs map[string]interface{} `json:"inputs"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
OpenAICompat bool `json:"openai-compatible"` OpenAICompat bool `json:"openai-compatible"`
@@ -895,6 +897,76 @@ func extractLastUserContent(messages []map[string]interface{}) string {
return "" return ""
} }
// extractUserInputFromFormInputs mirrors the front-end's wait-for-user submit
// shape: `inputs` is an object keyed by form field name, and each entry carries
// a nested `value`. The current chat-completion resume path consumes a single
// string payload, so we lift the first field's value and stringify it.
func extractUserInputFromFormInputs(inputs map[string]interface{}) interface{} {
if len(inputs) == 0 {
return nil
}
if len(inputs) == 1 {
for _, raw := range inputs {
if field, ok := raw.(map[string]interface{}); ok {
if v, ok := field["value"]; ok {
return v
}
}
return raw
}
}
out := make(map[string]any, len(inputs))
for name, raw := range inputs {
if field, ok := raw.(map[string]interface{}); ok {
if v, ok := field["value"]; ok {
out[name] = v
continue
}
}
out[name] = raw
}
return out
}
func countInputValues(inputs map[string]interface{}) int {
count := 0
for _, raw := range inputs {
if field, ok := raw.(map[string]interface{}); ok {
if _, exists := field["value"]; exists {
count++
}
continue
}
if raw != nil {
count++
}
}
return count
}
func userInputMeta(userInput any) []zap.Field {
fields := []zap.Field{zap.String("user_input_type", fmt.Sprintf("%T", userInput))}
switch v := userInput.(type) {
case nil:
fields = append(fields, zap.Bool("user_input_present", false))
case string:
fields = append(fields,
zap.Bool("user_input_present", true),
zap.Int("user_input_length", len(v)),
zap.Bool("user_input_blank", v == ""),
)
case map[string]interface{}:
fields = append(fields,
zap.Bool("user_input_present", true),
zap.Int("user_input_keys", len(v)),
)
default:
fields = append(fields, zap.Bool("user_input_present", true))
}
return fields
}
func (h *AgentHandler) AgentChatCompletions(c *gin.Context) { func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
user, code, msg := GetUser(c) user, code, msg := GetUser(c)
if code != common.CodeSuccess { if code != common.CodeSuccess {
@@ -914,6 +986,18 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
jsonError(c, common.CodeDataError, "at least one message is required in openai-compatible mode.") jsonError(c, common.CodeDataError, "at least one message is required in openai-compatible mode.")
return return
} }
common.Debug("agent chat completions: request received",
zap.String("user_id", user.ID),
zap.String("agent_id", req.AgentID),
zap.String("session_id", req.SessionID),
zap.Bool("stream", req.Stream),
zap.Bool("openai_compatible", req.OpenAICompat),
zap.Bool("query_present", req.Query != ""),
zap.Int("query_length", len(req.Query)),
zap.Int("inputs_count", len(req.Inputs)),
zap.Int("inputs_with_values_count", countInputValues(req.Inputs)),
zap.Int("messages_count", len(req.Messages)),
)
// TODO(phase5-openai-framing): the openai-compat branches below are // TODO(phase5-openai-framing): the openai-compat branches below are
// stubs. They keep the existing "choices"-shape contract for the // stubs. They keep the existing "choices"-shape contract for the
@@ -936,13 +1020,31 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
// Real canvas run — derive userInput from `query` first, then fall // Real canvas run — derive userInput from `query` first, then fall
// back to the last user message (covers the front-end that posts // back to the last user message (covers the front-end that posts
// running_hint_text without a top-level `query`). // running_hint_text without a top-level `query`).
userInput := req.Query var userInput any = req.Query
if userInput == "" { if req.Query == "" {
userInput = extractLastUserContent(req.Messages) if extracted := extractUserInputFromFormInputs(req.Inputs); extracted != nil {
userInput = extracted
} else if extracted := extractLastUserContent(req.Messages); extracted != "" {
userInput = extracted
}
} }
common.Debug("agent chat completions: derived user input",
append([]zap.Field{
zap.String("agent_id", req.AgentID),
zap.String("session_id", req.SessionID),
}, userInputMeta(userInput)...)...,
)
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput) events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput)
if err != nil { if err != nil {
common.Warn("agent chat completions: RunAgent failed",
append([]zap.Field{
zap.String("user_id", user.ID),
zap.String("agent_id", req.AgentID),
zap.String("session_id", req.SessionID),
zap.Error(err),
}, userInputMeta(userInput)...)...,
)
ec, em := mapAgentError(err) ec, em := mapAgentError(err)
jsonError(c, ec, em) jsonError(c, ec, em)
return return
@@ -963,8 +1065,19 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
// /api/v1/agents/{id}/run endpoint's wire format — see // /api/v1/agents/{id}/run endpoint's wire format — see
// writeRunEventSSE at agent.go for that path. // writeRunEventSSE at agent.go for that path.
for ev := range events { for ev := range events {
writeChatCompletionSSE(c.Writer, flusher, ev) common.Debug("agent chat completions: streaming event",
zap.String("agent_id", req.AgentID),
zap.String("session_id", req.SessionID),
zap.String("event_type", ev.Type),
zap.String("message_id", ev.MessageID),
zap.String("task_id", ev.TaskID),
)
writeChatCompletionSSE(c.Writer, flusher, req.AgentID, ev)
} }
common.Debug("agent chat completions: stream closed",
zap.String("agent_id", req.AgentID),
zap.String("session_id", req.SessionID),
)
} }
// writeChatCompletionSSE emits one canvas.RunEvent in the // writeChatCompletionSSE emits one canvas.RunEvent in the
@@ -973,8 +1086,13 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
// data:{"event":"<ev.Type>","message_id":"<ev.MessageID>","created_at":<ev.CreatedAt>,"task_id":"<ev.TaskID>","session_id":"<ev.SessionID>","data":<ev.Data>} // data:{"event":"<ev.Type>","message_id":"<ev.MessageID>","created_at":<ev.CreatedAt>,"task_id":"<ev.TaskID>","session_id":"<ev.SessionID>","data":<ev.Data>}
// //
// The special "done" type sends `data: [DONE]\n\n` (no JSON envelope). // The special "done" type sends `data: [DONE]\n\n` (no JSON envelope).
func writeChatCompletionSSE(w io.Writer, flusher http.Flusher, ev canvas.RunEvent) { func writeChatCompletionSSE(w io.Writer, flusher http.Flusher, agentID string, ev canvas.RunEvent) {
if ev.Type == "done" { if ev.Type == "done" {
common.Debug("agent chat completions: writing done sentinel",
zap.String("agent_id", agentID),
zap.String("session_id", ev.SessionID),
zap.String("task_id", ev.TaskID),
)
fmt.Fprint(w, "data: [DONE]\n\n") fmt.Fprint(w, "data: [DONE]\n\n")
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
@@ -985,6 +1103,13 @@ func writeChatCompletionSSE(w io.Writer, flusher http.Flusher, ev canvas.RunEven
if data == "" { if data == "" {
data = "{}" data = "{}"
} }
common.Debug("agent chat completions: writing sse frame",
zap.String("agent_id", agentID),
zap.String("event_type", ev.Type),
zap.String("message_id", ev.MessageID),
zap.String("session_id", ev.SessionID),
zap.String("task_id", ev.TaskID),
)
envelope := sseEnvelope(ev.Type, ev.MessageID, ev.CreatedAt, ev.TaskID, ev.SessionID, data) envelope := sseEnvelope(ev.Type, ev.MessageID, ev.CreatedAt, ev.TaskID, ev.SessionID, data)
fmt.Fprintf(w, "data: %s\n\n", envelope) fmt.Fprintf(w, "data: %s\n\n", envelope)
if flusher != nil { if flusher != nil {
+54 -6
View File
@@ -447,7 +447,7 @@ func (f *fullFakeAgentService) UpdateAgent(context.Context, string, string, enti
func (f *fullFakeAgentService) DeleteAgent(context.Context, string, string) error { func (f *fullFakeAgentService) DeleteAgent(context.Context, string, string) error {
return nil return nil
} }
func (f *fullFakeAgentService) RunAgent(context.Context, string, string, string, string, string) (<-chan canvas.RunEvent, error) { func (f *fullFakeAgentService) RunAgent(context.Context, string, string, string, string, any) (<-chan canvas.RunEvent, error) {
ch := make(chan canvas.RunEvent) ch := make(chan canvas.RunEvent)
close(ch) close(ch)
return ch, nil return ch, nil
@@ -715,7 +715,7 @@ type stubChatRunner struct {
err error err error
} }
func (s *stubChatRunner) RunAgent(_ context.Context, _, _, _, _, _ string) (<-chan canvas.RunEvent, error) { func (s *stubChatRunner) RunAgent(_ context.Context, _, _, _, _ string, _ any) (<-chan canvas.RunEvent, error) {
if s.err != nil { if s.err != nil {
return nil, s.err return nil, s.err
} }
@@ -815,13 +815,61 @@ func TestAgentChatCompletions_DerivesUserInputFromMessages(t *testing.T) {
c.Set("user", &entity.User{ID: "u1"}) c.Set("user", &entity.User{ID: "u1"})
c.Set("user_id", "u1") c.Set("user_id", "u1")
var captured string var captured any
runner := &captureChatRunner{captured: &captured} runner := &captureChatRunner{captured: &captured}
h := &AgentHandler{chatRunner: runner} h := &AgentHandler{chatRunner: runner}
h.AgentChatCompletions(c) h.AgentChatCompletions(c)
if captured != "from-messages" { if captured != "from-messages" {
t.Errorf("userInput = %q, want %q (last user message content)", captured, "from-messages") t.Errorf("userInput = %#v, want %q (last user message content)", captured, "from-messages")
}
}
// TestAgentChatCompletions_DerivesUserInputFromInputs covers the wait-for-user
// resume path used by the front-end: the follow-up submit posts `inputs`
// instead of a top-level `query`. The handler must lift the nested field value
// and pass it through as the resumed user input.
func TestAgentChatCompletions_DerivesUserInputFromInputs(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/agents/chat/completions",
strings.NewReader(`{"agent_id":"a1","session_id":"s1","inputs":{"text":{"name":"text","value":"a b c d e","type":"line"}}}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("user", &entity.User{ID: "u1"})
c.Set("user_id", "u1")
var captured any
runner := &captureChatRunner{captured: &captured}
h := &AgentHandler{chatRunner: runner}
h.AgentChatCompletions(c)
if captured != "a b c d e" {
t.Errorf("userInput = %#v, want %q (nested inputs.value)", captured, "a b c d e")
}
}
func TestAgentChatCompletions_DerivesStructuredUserInputFromInputs(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/agents/chat/completions",
strings.NewReader(`{"agent_id":"a1","session_id":"s1","inputs":{"kb":{"name":"KB","value":"da1","type":"line"},"query":{"name":"Query","value":"合同","type":"line"}}}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("user", &entity.User{ID: "u1"})
c.Set("user_id", "u1")
var captured any
runner := &captureChatRunner{captured: &captured}
h := &AgentHandler{chatRunner: runner}
h.AgentChatCompletions(c)
got, ok := captured.(map[string]any)
if !ok {
t.Fatalf("userInput type = %T, want map[string]any", captured)
}
if got["kb"] != "da1" || got["query"] != "合同" {
t.Fatalf("userInput = %#v, want kb=da1 query=合同", got)
} }
} }
@@ -829,10 +877,10 @@ func TestAgentChatCompletions_DerivesUserInputFromMessages(t *testing.T) {
// returns an empty (closed) channel. Used to assert on argument // returns an empty (closed) channel. Used to assert on argument
// derivation without exercising the runner. // derivation without exercising the runner.
type captureChatRunner struct { type captureChatRunner struct {
captured *string captured *any
} }
func (c *captureChatRunner) RunAgent(_ context.Context, _, _, _, _, userInput string) (<-chan canvas.RunEvent, error) { func (c *captureChatRunner) RunAgent(_ context.Context, _, _, _, _ string, userInput any) (<-chan canvas.RunEvent, error) {
*c.captured = userInput *c.captured = userInput
ch := make(chan canvas.RunEvent) ch := make(chan canvas.RunEvent)
close(ch) close(ch)
+5 -5
View File
@@ -24,8 +24,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/glebarez/sqlite"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"ragflow/internal/common" "ragflow/internal/common"
@@ -58,10 +58,10 @@ func setupUploadTestDB(t *testing.T) *gorm.DB {
// fakeUploadFileService implements fileUploader for tests. // fakeUploadFileService implements fileUploader for tests.
type fakeUploadFileService struct { type fakeUploadFileService struct {
uploaded []map[string]interface{} uploaded []map[string]interface{}
err error err error
lastTenantID string lastTenantID string
lastParentID string lastParentID string
} }
func (f *fakeUploadFileService) UploadFile(tenantID, parentID string, files []*multipart.FileHeader) ([]map[string]interface{}, error) { func (f *fakeUploadFileService) UploadFile(tenantID, parentID string, files []*multipart.FileHeader) ([]map[string]interface{}, error) {
+1 -1
View File
@@ -94,7 +94,7 @@ func (f *waitFakeAgentService) DeleteAgent(context.Context, string, string) erro
// RunAgent mimics service.AgentService.RunAgent for the test // RunAgent mimics service.AgentService.RunAgent for the test
// driver. It loads the canvas (a no-op in tests), builds a RunFunc // driver. It loads the canvas (a no-op in tests), builds a RunFunc
// from the supplied stub, and hands off to the orchestrator. // from the supplied stub, and hands off to the orchestrator.
func (f *waitFakeAgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version, userInput string) (<-chan canvas.RunEvent, error) { func (f *waitFakeAgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any) (<-chan canvas.RunEvent, error) {
_ = ctx _ = ctx
_ = userID _ = userID
_ = version _ = version
@@ -20,7 +20,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"gorm.io/gorm" "gorm.io/gorm"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -28,10 +28,10 @@ import (
"ragflow/internal/common" "ragflow/internal/common"
"ragflow/internal/engine" "ragflow/internal/engine"
"ragflow/internal/engine/types"
"ragflow/internal/entity" "ragflow/internal/entity"
modelModule "ragflow/internal/entity/models" modelModule "ragflow/internal/entity/models"
"ragflow/internal/service/nlp" "ragflow/internal/service/nlp"
"ragflow/internal/engine/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -135,12 +135,12 @@ type mockDocEngine struct {
engine.DocEngine engine.DocEngine
} }
func (m *mockDocEngine) Close() error { return nil } func (m *mockDocEngine) Close() error { return nil }
func (m *mockDocEngine) Ping(ctx context.Context) error { return nil } func (m *mockDocEngine) Ping(ctx context.Context) error { return nil }
func (m *mockDocEngine) GetType() string { return "mock" } func (m *mockDocEngine) GetType() string { return "mock" }
func (m *mockDocEngine) Search(ctx context.Context, req *types.SearchRequest) (*types.SearchResult, error) { func (m *mockDocEngine) Search(ctx context.Context, req *types.SearchRequest) (*types.SearchResult, error) {
return &types.SearchResult{}, nil return &types.SearchResult{}, nil
} }
func (m *mockDocEngine) GetChunk(ctx context.Context, _, _ string, _ []string) (interface{}, error) { func (m *mockDocEngine) GetChunk(ctx context.Context, _, _ string, _ []string) (interface{}, error) {
return map[string]interface{}{}, nil return map[string]interface{}{}, nil
} }
+7 -7
View File
@@ -300,14 +300,14 @@ func (h *FileCommitHandler) GetCommit(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess, "code": common.CodeSuccess,
"data": gin.H{ "data": gin.H{
"id": commit.ID, "id": commit.ID,
"folder_id": commit.FolderID, "folder_id": commit.FolderID,
"parent_id": commit.ParentID, "parent_id": commit.ParentID,
"message": commit.Message, "message": commit.Message,
"author_id": commit.AuthorID, "author_id": commit.AuthorID,
"file_count": commit.FileCount, "file_count": commit.FileCount,
"create_time": ct, "create_time": ct,
"files": items, "files": items,
}, },
"message": common.CodeSuccess.Message(), "message": common.CodeSuccess.Message(),
}) })
+9 -9
View File
@@ -31,15 +31,15 @@ import (
// mockFileCommitSvc implements FileCommitServiceInterface for testing // mockFileCommitSvc implements FileCommitServiceInterface for testing
type mockFileCommitSvc struct { type mockFileCommitSvc struct {
createCommitFn func(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) createCommitFn func(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error)
listCommitsFn func(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error) listCommitsFn func(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error)
getCommitFn func(commitID string) (*entity.FileCommit, error) getCommitFn func(commitID string) (*entity.FileCommit, error)
listCommitFilesFn func(commitID string) ([]*entity.FileCommitItem, error) listCommitFilesFn func(commitID string) ([]*entity.FileCommitItem, error)
diffCommitsFn func(fromID, toID string) ([]entity.DiffEntry, error) diffCommitsFn func(fromID, toID string) ([]entity.DiffEntry, error)
getUncommittedChangesFn func(folderID string) ([]entity.DiffEntry, error) getUncommittedChangesFn func(folderID string) ([]entity.DiffEntry, error)
getCommitTreeFn func(commitID string) (map[string]interface{}, error) getCommitTreeFn func(commitID string) (map[string]interface{}, error)
getCommitFileContentFn func(folderID, commitID, fileID string) ([]byte, error) getCommitFileContentFn func(folderID, commitID, fileID string) ([]byte, error)
getFileVersionHistoryFn func(fileID string) ([]entity.VersionEntry, error) getFileVersionHistoryFn func(fileID string) ([]entity.VersionEntry, error)
} }
func (m *mockFileCommitSvc) CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) { func (m *mockFileCommitSvc) CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) {
-1
View File
@@ -188,7 +188,6 @@ func mcpDetailError(c *gin.Context, code common.ErrorCode, err error) {
}) })
} }
// UpdateMCPServer updates an MCP server for the current user. // UpdateMCPServer updates an MCP server for the current user.
func (h *MCPHandler) UpdateMCPServer(c *gin.Context) { func (h *MCPHandler) UpdateMCPServer(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c) user, errorCode, errorMessage := GetUser(c)
+10 -5
View File
@@ -377,15 +377,21 @@ func jsonDecodeMessage(t *testing.T, body []byte) string {
} }
func nullableInt(p *int) string { func nullableInt(p *int) string {
if p == nil { return "nil" } if p == nil {
return "nil"
}
return fmt.Sprintf("%d", *p) return fmt.Sprintf("%d", *p)
} }
func nullableBool(p *bool) string { func nullableBool(p *bool) string {
if p == nil { return "nil" } if p == nil {
return "nil"
}
return fmt.Sprintf("%v", *p) return fmt.Sprintf("%v", *p)
} }
func nullableFloat(p *float64) string { func nullableFloat(p *float64) string {
if p == nil { return "nil" } if p == nil {
return "nil"
}
return fmt.Sprintf("%v", *p) return fmt.Sprintf("%v", *p)
} }
func TestSearchBotsRetrieval_EmptyQuestion(t *testing.T) { func TestSearchBotsRetrieval_EmptyQuestion(t *testing.T) {
@@ -404,6 +410,7 @@ func TestSearchBotsRetrieval_EmptyQuestion(t *testing.T) {
t.Errorf("expected validation error mentioning Question and required, got %q", msg) t.Errorf("expected validation error mentioning Question and required, got %q", msg)
} }
} }
// fakeSearchbotLLM implements searchbotLLM for testing. // fakeSearchbotLLM implements searchbotLLM for testing.
type fakeSearchbotLLM struct { type fakeSearchbotLLM struct {
response string response string
@@ -899,8 +906,6 @@ func TestAskHandler_WhitespaceKbIDFiltered(t *testing.T) {
} }
} }
// ---- SSE helper direct tests ---- // ---- SSE helper direct tests ----
func TestSseAnswer_Final(t *testing.T) { func TestSseAnswer_Final(t *testing.T) {
+13 -9
View File
@@ -560,7 +560,7 @@ func (s *AgentService) DeleteVersion(ctx context.Context, userID, canvasID, vers
// The per-run RunFunc is built by buildRunFunc — see its doc comment // The per-run RunFunc is built by buildRunFunc — see its doc comment
// for the full production chain (real Compile/Invoke, resume path, // for the full production chain (real Compile/Invoke, resume path,
// error-layering contract). // error-layering contract).
func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version, userInput string) (<-chan canvas.RunEvent, error) { func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any) (<-chan canvas.RunEvent, error) {
canvasRow, err := s.loadCanvasForUser(ctx, userID, canvasID) canvasRow, err := s.loadCanvasForUser(ctx, userID, canvasID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -664,7 +664,7 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
"session_id": sessionID, "session_id": sessionID,
"user_id": userID, "user_id": userID,
} }
if userInput != "" { if userInput != nil {
root["user_input"] = userInput root["user_input"] = userInput
} }
if dsl != nil { if dsl != nil {
@@ -693,7 +693,7 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
zap.String("userID", userID), zap.String("userID", userID),
zap.String("sessionID", sessionID), zap.String("sessionID", sessionID),
zap.Any("tenantID", root["tenant_id"]), zap.Any("tenantID", root["tenant_id"]),
zap.Int("userInput_len", func() int { s, _ := root["user_input"].(string); return len(s) }())) zap.Any("userInput", root["user_input"]))
return s.runner.Run(ctx, run, canvasID, sessionID, userInput, root), nil return s.runner.Run(ctx, run, canvasID, sessionID, userInput, root), nil
} }
@@ -755,9 +755,10 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
startedAt := float64(time.Now().UnixNano()) / 1e9 startedAt := float64(time.Now().UnixNano()) / 1e9
userInput := "" userInput := root["user_input"]
if v, ok := root["user_input"].(string); ok { userInputText := ""
userInput = v if v, ok := userInput.(string); ok {
userInputText = v
} }
resumeID, isResume := root["__resume_interrupt_id__"].(string) resumeID, isResume := root["__resume_interrupt_id__"].(string)
@@ -824,6 +825,9 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
} }
} }
state.Sys["query"] = userInput state.Sys["query"] = userInput
if uid, ok := root["user_id"].(string); ok && uid != "" {
state.Sys["user_id"] = uid
}
if tid, ok := root["tenant_id"].(string); ok && tid != "" { if tid, ok := root["tenant_id"].(string); ok && tid != "" {
state.Sys["tenant_id"] = tid state.Sys["tenant_id"] = tid
} }
@@ -853,7 +857,7 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
if s.runTracker != nil { if s.runTracker != nil {
_ = s.runTracker.Start(ctx2, runID, canvasID, _ = s.runTracker.Start(ctx2, runID, canvasID,
tenantIDFromRoot(root), userInput) tenantIDFromRoot(root), userInputText)
} }
// Compile. // Compile.
@@ -975,7 +979,7 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
emit("message_end", string(meData)) emit("message_end", string(meData))
wfData, _ := json.Marshal(map[string]interface{}{ wfData, _ := json.Marshal(map[string]interface{}{
"inputs": map[string]string{"query": userInput}, "inputs": map[string]any{"query": userInput},
"outputs": answer, "outputs": answer,
"elapsed_time": now - startedAt, "elapsed_time": now - startedAt,
"created_at": now, "created_at": now,
@@ -1003,7 +1007,7 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
// Emit workflow_finished with the final outputs. // Emit workflow_finished with the final outputs.
wfData, _ := json.Marshal(map[string]interface{}{ wfData, _ := json.Marshal(map[string]interface{}{
"inputs": map[string]string{"query": userInput}, "inputs": map[string]any{"query": userInput},
"outputs": answer, "outputs": answer,
"elapsed_time": now - startedAt, "elapsed_time": now - startedAt,
"created_at": now, "created_at": now,
+2 -2
View File
@@ -644,9 +644,9 @@ type CreateAgentSessionRequest struct {
// - user_id : caller's id // - user_id : caller's id
// - message : JSON array (default []); GET path normalises it // - message : JSON array (default []); GET path normalises it
// - reference : JSON object (default {}) so GET-side parsing // - reference : JSON object (default {}) so GET-side parsing
// does not crash on .chunks // does not crash on .chunks
// - dsl : JSON map; copied from user_canvas.dsl if the // - dsl : JSON map; copied from user_canvas.dsl if the
// caller did not pass one // caller did not pass one
// - create_time : unix-millis // - create_time : unix-millis
// - update_time : unix-millis // - update_time : unix-millis
// - create_date : local-time.Truncate(time.Second) // - create_date : local-time.Truncate(time.Second)
+1 -1
View File
@@ -23,8 +23,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"ragflow/internal/engine/types"
"ragflow/internal/common" "ragflow/internal/common"
"ragflow/internal/engine/types"
"go.uber.org/zap" "go.uber.org/zap"
) )
+1 -1
View File
@@ -17,11 +17,11 @@
package chunk package chunk
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"reflect" "reflect"
"testing" "testing"
"context"
"ragflow/internal/engine/types" "ragflow/internal/engine/types"
) )
+15 -15
View File
@@ -23,11 +23,11 @@ import (
"strings" "strings"
"sync" "sync"
"go.uber.org/zap"
"ragflow/internal/common" "ragflow/internal/common"
"ragflow/internal/engine" "ragflow/internal/engine"
"ragflow/internal/engine/types" "ragflow/internal/engine/types"
modelModule "ragflow/internal/entity/models" modelModule "ragflow/internal/entity/models"
"go.uber.org/zap"
) )
// Pipeline encapsulates the knowledge graph retrieval pipeline. // Pipeline encapsulates the knowledge graph retrieval pipeline.
@@ -157,7 +157,7 @@ func (p *Pipeline) Retrieval(ctx context.Context) (map[string]interface{}, error
wg.Wait() wg.Wait()
if entsErr != nil { if entsErr != nil {
return nil, entsErr return nil, entsErr
}// 5. N-hop analysis + score fusion } // 5. N-hop analysis + score fusion
nhopPathes := AnalyzeNHopPaths(entsFromQuery) nhopPathes := AnalyzeNHopPaths(entsFromQuery)
DoubleHitBoost(entsFromQuery, entsFromTypes) DoubleHitBoost(entsFromQuery, entsFromTypes)
FuseRelationScores(relsFromText, entsFromTypes, nhopPathes) FuseRelationScores(relsFromText, entsFromTypes, nhopPathes)
@@ -175,19 +175,19 @@ func (p *Pipeline) Retrieval(ctx context.Context) (map[string]interface{}, error
// 9. Build synthetic chunk // 9. Build synthetic chunk
return map[string]interface{}{ return map[string]interface{}{
"chunk_id": "", "chunk_id": "",
"content_ltks": "", "content_ltks": "",
"content_with_weight": entsRelsContent + communityContent, "content_with_weight": entsRelsContent + communityContent,
"doc_id": "", "doc_id": "",
"docnm_kwd": "Related content in Knowledge Graph", "docnm_kwd": "Related content in Knowledge Graph",
"kb_id": p.kbIDs, "kb_id": p.kbIDs,
"important_kwd": []string{}, "important_kwd": []string{},
"image_id": "", "image_id": "",
"similarity": 1.0, "similarity": 1.0,
"vector_similarity": 1.0, "vector_similarity": 1.0,
"term_similarity": 0, "term_similarity": 0,
"vector": []float64{}, "vector": []float64{},
"positions": []interface{}{}, "positions": []interface{}{},
}, nil }, nil
} }
+3 -4
View File
@@ -100,9 +100,9 @@ func TestEntityFromChunk_MissingFields(t *testing.T) {
func TestRelationFromChunk_Basic(t *testing.T) { func TestRelationFromChunk_Basic(t *testing.T) {
chunk := map[string]interface{}{ chunk := map[string]interface{}{
"from_entity_kwd": "Elon Musk", "from_entity_kwd": "Elon Musk",
"to_entity_kwd": "SpaceX", "to_entity_kwd": "SpaceX",
"weight_int": float64(5), "weight_int": float64(5),
"content_with_weight": "Founder", "content_with_weight": "Founder",
} }
edge, rel := relationFromChunk(chunk) edge, rel := relationFromChunk(chunk)
@@ -489,7 +489,6 @@ type assertError string
func (e assertError) Error() string { return string(e) } func (e assertError) Error() string { return string(e) }
// --- indexName --- // --- indexName ---
func TestIndexName_Normal(t *testing.T) { func TestIndexName_Normal(t *testing.T) {
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"ragflow/internal/engine"
"encoding/json" "encoding/json"
"ragflow/internal/engine"
"ragflow/internal/engine/types" "ragflow/internal/engine/types"
modelModule "ragflow/internal/entity/models" modelModule "ragflow/internal/entity/models"
) )
-1
View File
@@ -441,4 +441,3 @@ func TestSearchTypeSamples_WithMock(t *testing.T) {
t.Errorf("expected empty, got %d", len(samples)) t.Errorf("expected empty, got %d", len(samples))
} }
} }
+49 -4
View File
@@ -32,6 +32,39 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
var providerCatalogNameByCanonical = map[string]string{
"SILICONFLOW": "SiliconFlow",
}
func canonicalProviderName(name string) (string, error) {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "", fmt.Errorf("provider name is required")
}
factoryDAO := dao.NewLLMFactoryDAO()
if _, err := factoryDAO.GetByName(trimmed); err == nil {
return trimmed, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
for canonical, catalog := range providerCatalogNameByCanonical {
if trimmed == catalog {
if _, err := factoryDAO.GetByName(canonical); err != nil {
return "", err
}
return canonical, nil
}
}
return "", fmt.Errorf("provider '%s' not found", name)
}
func catalogProviderName(name string) string {
if mapped, ok := providerCatalogNameByCanonical[name]; ok {
return mapped
}
return name
}
// parseModelName parses a composite model name in format "model@instance@provider" or "model@provider" // parseModelName parses a composite model name in format "model@instance@provider" or "model@provider"
// Returns modelName, instanceName, providerName separately // Returns modelName, instanceName, providerName separately
func parseModelName(compositeName string) (modelName, instanceName, providerName string, err error) { func parseModelName(compositeName string) (modelName, instanceName, providerName string, err error) {
@@ -102,8 +135,7 @@ type CheckConnectionRequest struct {
} }
func (m *ModelProviderService) AddModelProvider(providerName, userID string) (common.ErrorCode, error) { func (m *ModelProviderService) AddModelProvider(providerName, userID string) (common.ErrorCode, error) {
canonicalName, err := canonicalProviderName(providerName)
_, err := dao.GetModelProviderManager().GetProviderByName(providerName)
if err != nil { if err != nil {
return common.CodeNotFound, err return common.CodeNotFound, err
} }
@@ -123,7 +155,7 @@ func (m *ModelProviderService) AddModelProvider(providerName, userID string) (co
tenantModelProvider := &entity.TenantModelProvider{ tenantModelProvider := &entity.TenantModelProvider{
ID: providerID, ID: providerID,
ProviderName: providerName, ProviderName: canonicalName,
TenantID: tenantID, TenantID: tenantID,
} }
err = m.modelProviderDAO.Create(tenantModelProvider) err = m.modelProviderDAO.Create(tenantModelProvider)
@@ -170,6 +202,7 @@ func (m *ModelProviderService) ListProvidersOfTenant(userID string) ([]map[strin
} }
return nil, common.CodeServerError, err return nil, common.CodeServerError, err
} }
provider["name"] = providerName
result = append(result, provider) result = append(result, provider)
} }
@@ -206,6 +239,10 @@ func (m *ModelProviderService) DeleteModelProvider(providerName, userID string)
} }
func (m *ModelProviderService) ListSupportedModels(providerName, instanceName, userID string) ([]map[string]interface{}, error) { func (m *ModelProviderService) ListSupportedModels(providerName, instanceName, userID string) ([]map[string]interface{}, error) {
providerName, err := canonicalProviderName(providerName)
if err != nil {
return nil, err
}
// Get tenant ID from user // Get tenant ID from user
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner") tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
@@ -230,7 +267,7 @@ func (m *ModelProviderService) ListSupportedModels(providerName, instanceName, u
return nil, err return nil, err
} }
providerInfo := dao.GetModelProviderManager().FindProvider(providerName) providerInfo := dao.GetModelProviderManager().FindProvider(catalogProviderName(providerName))
if providerInfo == nil { if providerInfo == nil {
return nil, fmt.Errorf("provider %s not found", providerName) return nil, fmt.Errorf("provider %s not found", providerName)
} }
@@ -280,6 +317,10 @@ func (m *ModelProviderService) ListSupportedModels(providerName, instanceName, u
} }
func (m *ModelProviderService) CreateProviderInstance(providerName, instanceName, apiKey, baseURL, region, userID string) (common.ErrorCode, error) { func (m *ModelProviderService) CreateProviderInstance(providerName, instanceName, apiKey, baseURL, region, userID string) (common.ErrorCode, error) {
providerName, err := canonicalProviderName(providerName)
if err != nil {
return common.CodeNotFound, err
}
// Get tenant ID from user // Get tenant ID from user
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner") tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
if err != nil { if err != nil {
@@ -327,6 +368,10 @@ func (m *ModelProviderService) CreateProviderInstance(providerName, instanceName
} }
func (m *ModelProviderService) ListProviderInstances(providerName, userID string) ([]map[string]interface{}, common.ErrorCode, error) { func (m *ModelProviderService) ListProviderInstances(providerName, userID string) ([]map[string]interface{}, common.ErrorCode, error) {
providerName, err := canonicalProviderName(providerName)
if err != nil {
return nil, common.CodeNotFound, err
}
// Get tenant ID from user // Get tenant ID from user
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner") tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
+59 -59
View File
@@ -52,19 +52,19 @@ func TestQueryBuilder_IsChinese(t *testing.T) {
{"Single Chinese char", "中", true}, {"Single Chinese char", "中", true},
{"Two Chinese chars", "中文", true}, {"Two Chinese chars", "中文", true},
{"Three Chinese chars", "中文字", true}, {"Three Chinese chars", "中文字", true},
{"Four Chinese chars", "中文字符", true}, // ratio >=0.7 {"Four Chinese chars", "中文字符", true}, // ratio >=0.7
{"Mixed with English", "hello world", true}, // fields=2 <=3 {"Mixed with English", "hello world", true}, // fields=2 <=3
{"Mostly Chinese", "hello 世界 测试", true}, // fields=3 <=3 {"Mostly Chinese", "hello 世界 测试", true}, // fields=3 <=3
{"Mostly English", "hello world test", true}, // fields=3 <=3 {"Mostly English", "hello world test", true}, // fields=3 <=3
{"English with punctuation", "Hello, world!", true}, // fields=2 <=3 (after split) {"English with punctuation", "Hello, world!", true}, // fields=2 <=3 (after split)
{"Chinese with spaces", "这 是 一个 测试", true}, // fields=4, non-alpha=4, ratio=1 >=0.7 {"Chinese with spaces", "这 是 一个 测试", true}, // fields=4, non-alpha=4, ratio=1 >=0.7
{"Mixed with numbers", "123 abc", true}, // fields=2 <=3 {"Mixed with numbers", "123 abc", true}, // fields=2 <=3
// Additional cases where fields >3 and ratio determines result // Additional cases where fields >3 and ratio determines result
{"Many English words", "this is a long english sentence", false}, // fields=6, non-alpha=0, ratio=0 <0.7 {"Many English words", "this is a long english sentence", false}, // fields=6, non-alpha=0, ratio=0 <0.7
{"Mixed with mostly Chinese", "hello world 中文 测试 多个", false}, // fields=5, non-alpha=3, ratio=0.6 <0.7 => false {"Mixed with mostly Chinese", "hello world 中文 测试 多个", false}, // fields=5, non-alpha=3, ratio=0.6 <0.7 => false
{"Mostly Chinese with many words", "这 是 一个 中文 测试 多个 汉字", true}, // fields=7, non-alpha=7, ratio=1 >=0.7 {"Mostly Chinese with many words", "这 是 一个 中文 测试 多个 汉字", true}, // fields=7, non-alpha=7, ratio=1 >=0.7
{"English with Chinese suffix", "hello world 中文", true}, // fields=3 <=3 {"English with Chinese suffix", "hello world 中文", true}, // fields=3 <=3
{"Chinese with English suffix", "中文 test", true}, // fields=2 <=3 {"Chinese with English suffix", "中文 test", true}, // fields=2 <=3
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -119,12 +119,12 @@ func TestQueryBuilder_RmWWW(t *testing.T) {
{"No stop words", "普通文本", "普通文本"}, {"No stop words", "普通文本", "普通文本"},
{"Chinese question word", "请问如何操作", "操作"}, // "请问" and "如何" both matched {"Chinese question word", "请问如何操作", "操作"}, // "请问" and "如何" both matched
{"Chinese stop word 怎么办", "怎么办安装", "安装"}, {"Chinese stop word 怎么办", "怎么办安装", "安装"},
{"English what", "what is this", " this"}, // removes "what " and "is " {"English what", "what is this", " this"}, // removes "what " and "is "
{"English who", "who are you", " you"}, // removes "who " and "are " {"English who", "who are you", " you"}, // removes "who " and "are "
{"Mixed stop words", "请问what is the problem", " the problem"}, // Chinese removed, "what ", "is " removed {"Mixed stop words", "请问what is the problem", " the problem"}, // Chinese removed, "what ", "is " removed
{"All removed becomes empty", "请问", "请问"}, // should revert to original {"All removed becomes empty", "请问", "请问"}, // should revert to original
{"English articles", "the cat is on a mat", " cat on mat"}, // removes "the ", "is ", "a " {"English articles", "the cat is on a mat", " cat on mat"}, // removes "the ", "is ", "a "
{"Case insensitive", "WHAT IS THIS", " THIS"}, // removes "WHAT " and "IS " {"Case insensitive", "WHAT IS THIS", " THIS"}, // removes "WHAT " and "IS "
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -181,7 +181,7 @@ func TestQueryBuilder_StrFullWidth2HalfWidth(t *testing.T) {
{"Chinese characters unchanged", "你好世界", "你好世界"}, {"Chinese characters unchanged", "你好世界", "你好世界"},
{"Japanese characters unchanged", "こんにちは", "こんにちは"}, {"Japanese characters unchanged", "こんにちは", "こんにちは"},
{"Korean characters unchanged", "안녕하세요", "안녕하세요"}, {"Korean characters unchanged", "안녕하세요", "안녕하세요"},
{"Full-width symbols outside range", "", "@@@"}, // Actually full-width '@' is U+FF20 which maps to U+0040 {"Full-width symbols outside range", "", "@@@"}, // Actually full-width '@' is U+FF20 which maps to U+0040
{"Edge case: character just below range", "\u001F", "\u001F"}, // U+001F is < 0x0020, should remain {"Edge case: character just below range", "\u001F", "\u001F"}, // U+001F is < 0x0020, should remain
{"Edge case: character just above range", "\u007F", "\u007F"}, // U+007F is > 0x7E, should remain {"Edge case: character just above range", "\u007F", "\u007F"}, // U+007F is > 0x7E, should remain
} }
@@ -224,12 +224,12 @@ func TestQueryBuilder_Traditional2Simplified(t *testing.T) {
func TestQueryBuilder_Question(t *testing.T) { func TestQueryBuilder_Question(t *testing.T) {
qb := NewQueryBuilder() qb := NewQueryBuilder()
tests := []struct { tests := []struct {
name string name string
txt string txt string
tbl string tbl string
minMatch float64 minMatch float64
expectNil bool expectNil bool
checkExpr func(*types.MatchTextExpr) bool checkExpr func(*types.MatchTextExpr) bool
checkKeywords func([]string) bool checkKeywords func([]string) bool
}{ }{
{ {
@@ -275,10 +275,10 @@ func TestQueryBuilder_Question(t *testing.T) {
}, },
}, },
{ {
name: "Empty text", name: "Empty text",
txt: "", txt: "",
tbl: "test", tbl: "test",
minMatch: 0.5, minMatch: 0.5,
expectNil: true, expectNil: true,
checkExpr: func(expr *types.MatchTextExpr) bool { checkExpr: func(expr *types.MatchTextExpr) bool {
return expr == nil return expr == nil
@@ -310,59 +310,59 @@ func TestQueryBuilder_Question(t *testing.T) {
func TestQueryBuilder_Paragraph(t *testing.T) { func TestQueryBuilder_Paragraph(t *testing.T) {
qb := NewQueryBuilder() qb := NewQueryBuilder()
tests := []struct { tests := []struct {
name string name string
contentTks string contentTks string
keywords []string keywords []string
keywordsTopN int keywordsTopN int
expectedQuery string expectedQuery string
}{ }{
{ {
name: "No keywords", name: "No keywords",
contentTks: "some content terms", contentTks: "some content terms",
keywords: []string{}, keywords: []string{},
keywordsTopN: 0, keywordsTopN: 0,
expectedQuery: "", expectedQuery: "",
}, },
{ {
name: "Single keyword", name: "Single keyword",
contentTks: "content", contentTks: "content",
keywords: []string{"hello"}, keywords: []string{"hello"},
keywordsTopN: 0, keywordsTopN: 0,
expectedQuery: `"hello"`, expectedQuery: `"hello"`,
}, },
{ {
name: "Multiple keywords", name: "Multiple keywords",
contentTks: "content", contentTks: "content",
keywords: []string{"hello", "world", "test"}, keywords: []string{"hello", "world", "test"},
keywordsTopN: 0, keywordsTopN: 0,
expectedQuery: `"hello" "world" "test"`, expectedQuery: `"hello" "world" "test"`,
}, },
{ {
name: "Trim spaces", name: "Trim spaces",
contentTks: "", contentTks: "",
keywords: []string{" hello ", " world "}, keywords: []string{" hello ", " world "},
keywordsTopN: 0, keywordsTopN: 0,
expectedQuery: `"hello" "world"`, expectedQuery: `"hello" "world"`,
}, },
{ {
name: "TopN limit", name: "TopN limit",
contentTks: "", contentTks: "",
keywords: []string{"a", "b", "c", "d", "e"}, keywords: []string{"a", "b", "c", "d", "e"},
keywordsTopN: 3, keywordsTopN: 3,
expectedQuery: `"a" "b" "c"`, expectedQuery: `"a" "b" "c"`,
}, },
{ {
name: "TopN larger than slice", name: "TopN larger than slice",
contentTks: "", contentTks: "",
keywords: []string{"a", "b"}, keywords: []string{"a", "b"},
keywordsTopN: 10, keywordsTopN: 10,
expectedQuery: `"a" "b"`, expectedQuery: `"a" "b"`,
}, },
{ {
name: "Empty keyword filtered", name: "Empty keyword filtered",
contentTks: "", contentTks: "",
keywords: []string{"a", "", "b"}, keywords: []string{"a", "", "b"},
keywordsTopN: 0, keywordsTopN: 0,
expectedQuery: `"a" "b"`, expectedQuery: `"a" "b"`,
}, },
} }
@@ -442,4 +442,4 @@ func TestQueryBuilder_SetQueryFields(t *testing.T) {
if !reflect.DeepEqual(expr.Fields, newFields) { if !reflect.DeepEqual(expr.Fields, newFields) {
t.Errorf("Paragraph fields not updated after SetQueryFields, got %v, want %v", expr.Fields, newFields) t.Errorf("Paragraph fields not updated after SetQueryFields, got %v, want %v", expr.Fields, newFields)
} }
} }
+1 -1
View File
@@ -17,12 +17,12 @@ package nlp
import ( import (
"encoding/json" "encoding/json"
"os" "os"
"sync/atomic"
"path/filepath" "path/filepath"
"ragflow/internal/common" "ragflow/internal/common"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"sync/atomic"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
+5 -5
View File
@@ -102,10 +102,10 @@ func TestNewSynonymWithMockFile(t *testing.T) {
// Create mock synonym.json // Create mock synonym.json
synonymData := map[string]interface{}{ synonymData := map[string]interface{}{
"happy": []string{"joyful", "cheerful", "glad"}, "happy": []string{"joyful", "cheerful", "glad"},
"sad": []string{"unhappy", "sorrowful"}, "sad": []string{"unhappy", "sorrowful"},
"test": "single", // Test string value "test": "single", // Test string value
"UPPER": []string{"lower"}, // Test case conversion "UPPER": []string{"lower"}, // Test case conversion
} }
data, _ := json.Marshal(synonymData) data, _ := json.Marshal(synonymData)
if err := os.WriteFile(filepath.Join(tmpDir, "synonym.json"), data, 0644); err != nil { if err := os.WriteFile(filepath.Join(tmpDir, "synonym.json"), data, 0644); err != nil {
@@ -238,7 +238,7 @@ func TestSynonymLoad(t *testing.T) {
s := NewSynonym(redis, tmpDir, testSynonymWordNetDir) s := NewSynonym(redis, tmpDir, testSynonymWordNetDir)
// Simulate multiple lookups to trigger load // Simulate multiple lookups to trigger load
s.lookupNum.Store(200) // Set above threshold s.lookupNum.Store(200) // Set above threshold
s.loadTm = time.Now().Add(-4000 * time.Second) // Set load time > 1 hour ago s.loadTm = time.Now().Add(-4000 * time.Second) // Set load time > 1 hour ago
// Call load directly // Call load directly
+8 -8
View File
@@ -190,7 +190,7 @@ func TestTokenMerge(t *testing.T) {
result := d.TokenMerge(tt.tks) result := d.TokenMerge(tt.tks)
if !reflect.DeepEqual(result, tt.expected) { if !reflect.DeepEqual(result, tt.expected) {
// Debug: print detailed comparison // Debug: print detailed comparison
t.Errorf("TokenMerge(%v) = %v (len=%d), expected %v (len=%d)", t.Errorf("TokenMerge(%v) = %v (len=%d), expected %v (len=%d)",
tt.tks, result, len(result), tt.expected, len(tt.expected)) tt.tks, result, len(result), tt.expected, len(tt.expected))
for i, r := range result { for i, r := range result {
t.Errorf(" result[%d] = %q (len=%d)", i, r, len(r)) t.Errorf(" result[%d] = %q (len=%d)", i, r, len(r))
@@ -250,8 +250,8 @@ func TestSplit(t *testing.T) {
expected []string expected []string
}{ }{
{ {
name: "simple split", name: "simple split",
txt: "hello world test", txt: "hello world test",
// Consecutive English words ending with letters are merged // Consecutive English words ending with letters are merged
expected: []string{"hello world test"}, expected: []string{"hello world test"},
}, },
@@ -261,8 +261,8 @@ func TestSplit(t *testing.T) {
expected: []string{"machine learning algorithm"}, // Should merge expected: []string{"machine learning algorithm"}, // Should merge
}, },
{ {
name: "mixed Chinese and English", name: "mixed Chinese and English",
txt: "hello 世界 world", txt: "hello 世界 world",
// "hello" ends with letter, "世界" doesn't start with letter but doesn't end with letter either // "hello" ends with letter, "世界" doesn't start with letter but doesn't end with letter either
expected: []string{"hello", "世界", "world"}, expected: []string{"hello", "世界", "world"},
}, },
@@ -272,8 +272,8 @@ func TestSplit(t *testing.T) {
expected: []string{""}, expected: []string{""},
}, },
{ {
name: "multiple spaces", name: "multiple spaces",
txt: "hello world", txt: "hello world",
// Multiple spaces are normalized, then merged if both end with letters // Multiple spaces are normalized, then merged if both end with letters
expected: []string{"hello world"}, expected: []string{"hello world"},
}, },
@@ -283,7 +283,7 @@ func TestSplit(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := d.Split(tt.txt) result := d.Split(tt.txt)
if !reflect.DeepEqual(result, tt.expected) { if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("Split('%s') = %v (len=%d), expected %v (len=%d)", t.Errorf("Split('%s') = %v (len=%d), expected %v (len=%d)",
tt.txt, result, len(result), tt.expected, len(tt.expected)) tt.txt, result, len(result), tt.expected, len(tt.expected))
for i, r := range result { for i, r := range result {
t.Errorf(" result[%d] = %q", i, r) t.Errorf(" result[%d] = %q", i, r)
+1 -1
View File
@@ -17,8 +17,8 @@
package service package service
import ( import (
_ "unsafe"
"testing" "testing"
_ "unsafe"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
+1 -1
View File
@@ -246,4 +246,4 @@ func ExtractVisibleAnswer(raw string) string {
return "" return ""
} }
return result return result
} }
+1 -1
View File
@@ -253,4 +253,4 @@ func TestStreamThinkTagDelta_NoThinkTags(t *testing.T) {
if joined != "just plain text" { if joined != "just plain text" {
t.Errorf("got %q, want 'just plain text'", joined) t.Errorf("got %q, want 'just plain text'", joined)
} }
} }
+8 -8
View File
@@ -51,14 +51,14 @@ import (
// so a scale of 4 produces 20x28 glyphs, which are ~16x16 px after // so a scale of 4 produces 20x28 glyphs, which are ~16x16 px after
// padding — comfortably readable for humans at typical browser zoom. // padding — comfortably readable for humans at typical browser zoom.
const ( const (
captchaPNGScale = 4 captchaPNGScale = 4
captchaGlyphW = 5 captchaGlyphW = 5
captchaGlyphH = 7 captchaGlyphH = 7
captchaCharSpacing = 4 // px between glyphs (after scaling) captchaCharSpacing = 4 // px between glyphs (after scaling)
captchaSidePadding = 8 captchaSidePadding = 8
captchaTopPadding = 6 captchaTopPadding = 6
captchaNoiseDots = 60 captchaNoiseDots = 60
captchaNoiseLines = 4 captchaNoiseLines = 4
) )
// font5x7 maps a single character to its 7-row bitmap. Each row is a // font5x7 maps a single character to its 7-row bitmap. Each row is a
+1 -1
View File
@@ -350,4 +350,4 @@ func FloatToString(f float64) string {
s = s + ".0" s = s + ".0"
} }
return s return s
} }
+21 -21
View File
@@ -32,23 +32,23 @@ import (
// Forgot-password constants — match api/utils/web_utils.py. // Forgot-password constants — match api/utils/web_utils.py.
const ( const (
OTPLength = 4 OTPLength = 4
OTPTTL = 5 * time.Minute OTPTTL = 5 * time.Minute
OTPAttemptLimit = 5 OTPAttemptLimit = 5
OTPAttemptLockDuration = 30 * time.Minute OTPAttemptLockDuration = 30 * time.Minute
OTPResendCooldown = 60 * time.Second OTPResendCooldown = 60 * time.Second
) )
// otpUpperAlphabet is the OTP alphabet (uppercase letters, same as // otpUpperAlphabet is the OTP alphabet (uppercase letters, same as
// Python ``string.ascii_uppercase``). // Python string.ascii_uppercase).
const otpUpperAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" const otpUpperAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// captchaAlphabet is the captcha alphabet (uppercase letters + digits, // captchaAlphabet is the captcha alphabet (uppercase letters + digits,
// same as Python ``string.ascii_uppercase + string.digits``). // same as Python string.ascii_uppercase + string.digits).
const captchaAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const captchaAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// normalizeEmail lowercases and trims an email address for keying. Mirrors // normalizeEmail lowercases and trims an email address for keying. Mirrors
// the leading ``email = (email or "").strip().lower()`` in Python's // the leading email = (email or "").strip().lower() in Python's
// otp_keys helper. // otp_keys helper.
func normalizeEmail(email string) string { func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email)) return strings.ToLower(strings.TrimSpace(email))
@@ -58,7 +58,7 @@ func normalizeEmail(email string) string {
// for a server-issued captcha_id. The handler returns the id to the // for a server-issued captcha_id. The handler returns the id to the
// client and never the code itself, so an attacker cannot read the // client and never the code itself, so an attacker cannot read the
// expected answer from the response. Diverges from Python's // expected answer from the response. Diverges from Python's
// email-keyed ``captcha_key`` on purpose — captchas are 60s-lived // email-keyed captcha_key on purpose — captchas are 60s-lived
// and never cross between Go and Python in practice, so there is no // and never cross between Go and Python in practice, so there is no
// shared-state requirement. // shared-state requirement.
func CaptchaIDRedisKey(captchaID string) string { func CaptchaIDRedisKey(captchaID string) string {
@@ -66,7 +66,7 @@ func CaptchaIDRedisKey(captchaID string) string {
} }
// OTPRedisKeys returns the four Redis keys used by the forgot-password // OTPRedisKeys returns the four Redis keys used by the forgot-password
// flow, in the same order as Python's ``otp_keys`` helper: // flow, in the same order as Python's otp_keys helper:
// //
// code, attempts, last_sent, lock // code, attempts, last_sent, lock
func OTPRedisKeys(email string) (codeKey, attemptsKey, lastSentKey, lockKey string) { func OTPRedisKeys(email string) (codeKey, attemptsKey, lastSentKey, lockKey string) {
@@ -79,13 +79,13 @@ func OTPRedisKeys(email string) (codeKey, attemptsKey, lastSentKey, lockKey stri
// OTPVerifiedRedisKey returns the Redis key that records a successful OTP // OTPVerifiedRedisKey returns the Redis key that records a successful OTP
// verification, used as the gate for the password-reset step (matches // verification, used as the gate for the password-reset step (matches
// Python ``_verified_key``). // Python _verified_key).
func OTPVerifiedRedisKey(email string) string { func OTPVerifiedRedisKey(email string) string {
return "otp:verified:" + normalizeEmail(email) return "otp:verified:" + normalizeEmail(email)
} }
// HashOTPCode computes the HMAC-SHA256 of an OTP using the given salt and // HashOTPCode computes the HMAC-SHA256 of an OTP using the given salt and
// returns its hex digest, matching Python's ``hash_code`` helper. // returns its hex digest, matching Python's hash_code helper.
func HashOTPCode(code string, salt []byte) string { func HashOTPCode(code string, salt []byte) string {
mac := hmac.New(sha256.New, salt) mac := hmac.New(sha256.New, salt)
mac.Write([]byte(code)) mac.Write([]byte(code))
@@ -93,7 +93,7 @@ func HashOTPCode(code string, salt []byte) string {
} }
// GenerateOTPSalt returns a cryptographically random 16-byte salt for // GenerateOTPSalt returns a cryptographically random 16-byte salt for
// hashing an OTP — same width as Python ``os.urandom(16)``. // hashing an OTP — same width as Python os.urandom(16).
func GenerateOTPSalt() ([]byte, error) { func GenerateOTPSalt() ([]byte, error) {
salt := make([]byte, 16) salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil { if _, err := rand.Read(salt); err != nil {
@@ -102,22 +102,22 @@ func GenerateOTPSalt() ([]byte, error) {
return salt, nil return salt, nil
} }
// GenerateOTPCode generates an OTP of length ``OTPLength`` drawn uniformly // GenerateOTPCode generates an OTP of length OTPLength drawn uniformly
// from ``otpUpperAlphabet`` using crypto/rand (matches Python // from otpUpperAlphabet using crypto/rand (matches Python
// ``secrets.choice``). // secrets.choice).
func GenerateOTPCode() (string, error) { func GenerateOTPCode() (string, error) {
return randomStringFromAlphabet(otpUpperAlphabet, OTPLength) return randomStringFromAlphabet(otpUpperAlphabet, OTPLength)
} }
// GenerateCaptchaCode generates a captcha of length ``OTPLength`` drawn // GenerateCaptchaCode generates a captcha of length OTPLength drawn
// uniformly from ``captchaAlphabet`` using crypto/rand. The shared length // uniformly from captchaAlphabet using crypto/rand. The shared length
// is intentional — Python uses ``OTP_LENGTH`` for both. // is intentional — Python uses OTP_LENGTH for both.
func GenerateCaptchaCode() (string, error) { func GenerateCaptchaCode() (string, error) {
return randomStringFromAlphabet(captchaAlphabet, OTPLength) return randomStringFromAlphabet(captchaAlphabet, OTPLength)
} }
// EncodeOTPStorageValue serializes the (hash, salt) pair the way Python // EncodeOTPStorageValue serializes the (hash, salt) pair the way Python
// stores it in Redis: ``"<hex_hash>:<hex_salt>"``. Returning the salt's // stores it in Redis: "<hex_hash>:<hex_salt>". Returning the salt's
// hex form (not raw bytes) keeps the value safe to store as a Redis // hex form (not raw bytes) keeps the value safe to store as a Redis
// string and matches the Python encoding so either backend can verify a // string and matches the Python encoding so either backend can verify a
// code minted by the other. // code minted by the other.
@@ -125,7 +125,7 @@ func EncodeOTPStorageValue(codeHash string, salt []byte) string {
return codeHash + ":" + hex.EncodeToString(salt) return codeHash + ":" + hex.EncodeToString(salt)
} }
// DecodeOTPStorageValue reverses ``EncodeOTPStorageValue``. Returns the // DecodeOTPStorageValue reverses EncodeOTPStorageValue. Returns the
// stored hash, decoded salt bytes, and a non-nil error if the value is // stored hash, decoded salt bytes, and a non-nil error if the value is
// malformed. // malformed.
func DecodeOTPStorageValue(stored string) (codeHash string, salt []byte, err error) { func DecodeOTPStorageValue(stored string) (codeHash string, salt []byte, err error) {
+9 -3
View File
@@ -112,7 +112,7 @@ dependencies = [
"en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl", "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl",
"slack-sdk==3.37.0", "slack-sdk==3.37.0",
"socksio==1.0.0", "socksio==1.0.0",
"agentrun-sdk>=0.0.16,<1.0.0", "agentrun-sdk>=0.0.51,<1.0.0",
"nest-asyncio>=1.6.0,<2.0.0", # Needed for agent/component/message.py "nest-asyncio>=1.6.0,<2.0.0", # Needed for agent/component/message.py
"sqlglotrs==0.9.0", "sqlglotrs==0.9.0",
"tavily-python==0.5.1", "tavily-python==0.5.1",
@@ -238,13 +238,19 @@ packages = [
'agent', 'agent',
'api', 'api',
'deepdoc', 'deepdoc',
'graphrag', 'rag.graphrag',
'intergrations.chatgpt-on-wechat.plugins', 'tools.chatgpt_on_wechat.plugins',
'mcp.server', 'mcp.server',
'rag', 'rag',
'sdk.python.ragflow_sdk', 'sdk.python.ragflow_sdk',
] ]
# tools/ holds standalone Python projects whose names contain hyphens
# (chatgpt-on-wechat). Python package names cannot contain hyphens, so
# we map the underscored Python name to the hyphenated filesystem path.
[tool.setuptools.package-dir]
"tools.chatgpt_on_wechat.plugins" = "tools/chatgpt-on-wechat/plugins"
[tool.ruff] [tool.ruff]
line-length = 200 line-length = 200
exclude = [".venv", "rag/svr/discord_svr.py"] exclude = [".venv", "rag/svr/discord_svr.py"]
+10 -10
View File
@@ -673,13 +673,13 @@ def test_dataset_update_identifier_validation_contract(rest_client):
assert not_uuid_res.status_code == 200 assert not_uuid_res.status_code == 200
not_uuid_payload = not_uuid_res.json() not_uuid_payload = not_uuid_res.json()
assert not_uuid_payload["code"] == 101, not_uuid_payload assert not_uuid_payload["code"] == 101, not_uuid_payload
assert "Invalid UUID1 format" in not_uuid_payload["message"], not_uuid_payload assert "Invalid UUID format" in not_uuid_payload["message"], not_uuid_payload
not_uuid1_res = rest_client.put(f"/datasets/{uuid.uuid4().hex}", json=payload) not_uuid1_res = rest_client.put(f"/datasets/{uuid.uuid4().hex}", json=payload)
assert not_uuid1_res.status_code == 200 assert not_uuid1_res.status_code == 200
not_uuid1_payload = not_uuid1_res.json() not_uuid1_payload = not_uuid1_res.json()
assert not_uuid1_payload["code"] == 101, not_uuid1_payload assert not_uuid1_payload["code"] == 102, not_uuid1_payload
assert "Invalid UUID1 format" in not_uuid1_payload["message"], not_uuid1_payload assert "lacks permission for dataset" in not_uuid1_payload["message"], not_uuid1_payload
wrong_uuid_res = rest_client.put("/datasets/d94a8dc02c9711f0930f7fbc369eab6d", json=payload) wrong_uuid_res = rest_client.put("/datasets/d94a8dc02c9711f0930f7fbc369eab6d", json=payload)
assert wrong_uuid_res.status_code == 200 assert wrong_uuid_res.status_code == 200
@@ -1835,13 +1835,13 @@ def test_dataset_delete_contract_matrix(rest_client, clear_datasets):
assert id_not_uuid_res.status_code == 200 assert id_not_uuid_res.status_code == 200
id_not_uuid_payload = id_not_uuid_res.json() id_not_uuid_payload = id_not_uuid_res.json()
assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload
assert "Invalid UUID1 format" in id_not_uuid_payload["message"], id_not_uuid_payload assert "Invalid UUID format" in id_not_uuid_payload["message"], id_not_uuid_payload
id_not_uuid1_res = rest_client.delete("/datasets", json={"ids": [uuid.uuid4().hex]}) id_not_uuid1_res = rest_client.delete("/datasets", json={"ids": [uuid.uuid4().hex]})
assert id_not_uuid1_res.status_code == 200 assert id_not_uuid1_res.status_code == 200
id_not_uuid1_payload = id_not_uuid1_res.json() id_not_uuid1_payload = id_not_uuid1_res.json()
assert id_not_uuid1_payload["code"] == 101, id_not_uuid1_payload assert id_not_uuid1_payload["code"] == 102, id_not_uuid1_payload
assert "Invalid UUID1 format" in id_not_uuid1_payload["message"], id_not_uuid1_payload assert "lacks permission for dataset" in id_not_uuid1_payload["message"], id_not_uuid1_payload
id_wrong_uuid_res = rest_client.delete("/datasets", json={"ids": ["d94a8dc02c9711f0930f7fbc369eab6d"]}) id_wrong_uuid_res = rest_client.delete("/datasets", json={"ids": ["d94a8dc02c9711f0930f7fbc369eab6d"]})
assert id_wrong_uuid_res.status_code == 200 assert id_wrong_uuid_res.status_code == 200
@@ -2113,13 +2113,13 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
assert id_not_uuid_res.status_code == 200 assert id_not_uuid_res.status_code == 200
id_not_uuid_payload = id_not_uuid_res.json() id_not_uuid_payload = id_not_uuid_res.json()
assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload assert id_not_uuid_payload["code"] == 101, id_not_uuid_payload
assert "Invalid UUID1 format" in id_not_uuid_payload["message"], id_not_uuid_payload assert "Invalid UUID format" in id_not_uuid_payload["message"], id_not_uuid_payload
id_not_uuid1_res = rest_client.get("/datasets", params={"id": uuid.uuid4().hex}) id_not_uuid1_res = rest_client.get("/datasets", params={"id": uuid.uuid4().hex})
assert id_not_uuid1_res.status_code == 200 assert id_not_uuid1_res.status_code == 200
id_not_uuid1_payload = id_not_uuid1_res.json() id_not_uuid1_payload = id_not_uuid1_res.json()
assert id_not_uuid1_payload["code"] == 101, id_not_uuid1_payload assert id_not_uuid1_payload["code"] == 102, id_not_uuid1_payload
assert "Invalid UUID1 format" in id_not_uuid1_payload["message"], id_not_uuid1_payload assert "lacks permission for dataset" in id_not_uuid1_payload["message"], id_not_uuid1_payload
id_wrong_uuid_res = rest_client.get("/datasets", params={"id": "d94a8dc02c9711f0930f7fbc369eab6d"}) id_wrong_uuid_res = rest_client.get("/datasets", params={"id": "d94a8dc02c9711f0930f7fbc369eab6d"})
assert id_wrong_uuid_res.status_code == 200 assert id_wrong_uuid_res.status_code == 200
@@ -2131,7 +2131,7 @@ def test_dataset_list_query_contract_matrix(rest_client, clear_datasets):
assert id_empty_res.status_code == 200 assert id_empty_res.status_code == 200
id_empty_payload = id_empty_res.json() id_empty_payload = id_empty_res.json()
assert id_empty_payload["code"] == 101, id_empty_payload assert id_empty_payload["code"] == 101, id_empty_payload
assert "Invalid UUID1 format" in id_empty_payload["message"], id_empty_payload assert "Invalid UUID format" in id_empty_payload["message"], id_empty_payload
id_none_res = rest_client.get("/datasets", params={"id": None}) id_none_res = rest_client.get("/datasets", params={"id": None})
assert id_none_res.status_code == 200 assert id_none_res.status_code == 200
@@ -142,7 +142,7 @@ class TestDatasetsDelete:
payload = {"ids": ["not_uuid"]} payload = {"ids": ["not_uuid"]}
res = delete_datasets(HttpApiAuth, payload) res = delete_datasets(HttpApiAuth, payload)
assert res["code"] == 101, res assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res assert "Invalid UUID format" in res["message"], res
res = list_datasets(HttpApiAuth) res = list_datasets(HttpApiAuth)
assert len(res["data"]) == 1, res assert len(res["data"]) == 1, res
@@ -152,8 +152,8 @@ class TestDatasetsDelete:
def test_id_not_uuid1(self, HttpApiAuth): def test_id_not_uuid1(self, HttpApiAuth):
payload = {"ids": [uuid.uuid4().hex]} payload = {"ids": [uuid.uuid4().hex]}
res = delete_datasets(HttpApiAuth, payload) res = delete_datasets(HttpApiAuth, payload)
assert res["code"] == 101, res assert res["code"] == 102, res
assert "Invalid UUID1 format" in res["message"], res assert "lacks permission for dataset" in res["message"], res
@pytest.mark.p2 @pytest.mark.p2
@pytest.mark.usefixtures("add_dataset_func") @pytest.mark.usefixtures("add_dataset_func")
@@ -268,14 +268,14 @@ class TestDatasetsList:
params = {"id": "not_uuid"} params = {"id": "not_uuid"}
res = list_datasets(HttpApiAuth, params) res = list_datasets(HttpApiAuth, params)
assert res["code"] == 101, res assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res assert "Invalid UUID format" in res["message"], res
@pytest.mark.p2 @pytest.mark.p2
def test_id_not_uuid1(self, HttpApiAuth): def test_id_not_uuid1(self, HttpApiAuth):
params = {"id": uuid.uuid4().hex} params = {"id": uuid.uuid4().hex}
res = list_datasets(HttpApiAuth, params) res = list_datasets(HttpApiAuth, params)
assert res["code"] == 101, res assert res["code"] == 102, res
assert "Invalid UUID1 format" in res["message"], res assert "lacks permission for dataset" in res["message"], res
@pytest.mark.p2 @pytest.mark.p2
def test_id_wrong_uuid(self, HttpApiAuth): def test_id_wrong_uuid(self, HttpApiAuth):
@@ -289,7 +289,7 @@ class TestDatasetsList:
params = {"id": ""} params = {"id": ""}
res = list_datasets(HttpApiAuth, params) res = list_datasets(HttpApiAuth, params)
assert res["code"] == 101, res assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res assert "Invalid UUID format" in res["message"], res
@pytest.mark.p2 @pytest.mark.p2
def test_id_none(self, HttpApiAuth): def test_id_none(self, HttpApiAuth):
@@ -105,14 +105,14 @@ class TestDatasetUpdate:
payload = {"name": "not uuid"} payload = {"name": "not uuid"}
res = update_dataset(HttpApiAuth, "not_uuid", payload) res = update_dataset(HttpApiAuth, "not_uuid", payload)
assert res["code"] == 101, res assert res["code"] == 101, res
assert "Invalid UUID1 format" in res["message"], res assert "Invalid UUID format" in res["message"], res
@pytest.mark.p3 @pytest.mark.p3
def test_dataset_id_not_uuid1(self, HttpApiAuth): def test_dataset_id_not_uuid1(self, HttpApiAuth):
payload = {"name": "not uuid1"} payload = {"name": "not uuid1"}
res = update_dataset(HttpApiAuth, uuid.uuid4().hex, payload) res = update_dataset(HttpApiAuth, uuid.uuid4().hex, payload)
assert res["code"] == 101, res assert res["code"] == 102, res
assert "Invalid UUID1 format" in res["message"], res assert "lacks permission for dataset" in res["message"], res
@pytest.mark.p3 @pytest.mark.p3
def test_dataset_id_wrong_uuid(self, HttpApiAuth): def test_dataset_id_wrong_uuid(self, HttpApiAuth):
@@ -103,7 +103,7 @@ class TestDatasetsDelete:
payload = {"ids": ["not_uuid"]} payload = {"ids": ["not_uuid"]}
with pytest.raises(Exception) as exception_info: with pytest.raises(Exception) as exception_info:
client.delete_datasets(**payload) client.delete_datasets(**payload)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value) assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
datasets = client.list_datasets() datasets = client.list_datasets()
assert len(datasets) == 1, str(datasets) assert len(datasets) == 1, str(datasets)
@@ -114,7 +114,7 @@ class TestDatasetsDelete:
payload = {"ids": [uuid.uuid4().hex]} payload = {"ids": [uuid.uuid4().hex]}
with pytest.raises(Exception) as exception_info: with pytest.raises(Exception) as exception_info:
client.delete_datasets(**payload) client.delete_datasets(**payload)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value) assert "lacks permission for dataset" in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2 @pytest.mark.p2
@pytest.mark.usefixtures("add_dataset_func") @pytest.mark.usefixtures("add_dataset_func")
@@ -250,14 +250,14 @@ class TestDatasetsList:
params = {"id": "not_uuid"} params = {"id": "not_uuid"}
with pytest.raises(Exception) as exception_info: with pytest.raises(Exception) as exception_info:
client.list_datasets(**params) client.list_datasets(**params)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value) assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2 @pytest.mark.p2
def test_id_not_uuid1(self, client): def test_id_not_uuid1(self, client):
params = {"id": uuid.uuid4().hex} params = {"id": uuid.uuid4().hex}
with pytest.raises(Exception) as exception_info: with pytest.raises(Exception) as exception_info:
client.list_datasets(**params) client.list_datasets(**params)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value) assert "lacks permission for dataset" in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2 @pytest.mark.p2
def test_id_wrong_uuid(self, client): def test_id_wrong_uuid(self, client):
@@ -271,7 +271,7 @@ class TestDatasetsList:
params = {"id": ""} params = {"id": ""}
with pytest.raises(Exception) as exception_info: with pytest.raises(Exception) as exception_info:
client.list_datasets(**params) client.list_datasets(**params)
assert "Invalid UUID1 format" in str(exception_info.value), str(exception_info.value) assert "Invalid UUID format" in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2 @pytest.mark.p2
def test_id_none(self, client): def test_id_none(self, client):
@@ -0,0 +1,157 @@
#
# 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 importlib.util
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
def _load_fillup_module(monkeypatch):
repo_root = Path(__file__).resolve().parents[4]
agent_pkg = ModuleType("agent")
agent_pkg.__path__ = [str(repo_root / "agent")]
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
component_pkg = ModuleType("agent.component")
component_pkg.__path__ = [str(repo_root / "agent" / "component")]
monkeypatch.setitem(sys.modules, "agent.component", component_pkg)
base_mod = ModuleType("agent.component.base")
class _ComponentParamBase:
def __init__(self):
self.inputs = {}
self.outputs = {}
class _ComponentBase:
def get_input_elements(self):
return self._param.inputs
def check_if_canceled(self, *_args, **_kwargs):
return False
def get_input_elements_from_text(self, *_args, **_kwargs):
return {}
def set_output(self, key, value):
if key not in self._param.outputs:
self._param.outputs[key] = {"value": None}
self._param.outputs[key]["value"] = value
def set_input_value(self, key, value):
if key not in self._param.inputs:
self._param.inputs[key] = {"value": None}
self._param.inputs[key]["value"] = value
base_mod.ComponentBase = _ComponentBase
base_mod.ComponentParamBase = _ComponentParamBase
monkeypatch.setitem(sys.modules, "agent.component.base", base_mod)
api_pkg = ModuleType("api")
api_pkg.__path__ = [str(repo_root / "api")]
monkeypatch.setitem(sys.modules, "api", api_pkg)
services_pkg = ModuleType("api.db.services")
services_pkg.__path__ = [str(repo_root / "api" / "db" / "services")]
monkeypatch.setitem(sys.modules, "api.db.services", services_pkg)
file_service_mod = ModuleType("api.db.services.file_service")
class _FileService:
@staticmethod
def get_files(files, layout_recognize=None):
return {"files": files, "layout_recognize": layout_recognize}
file_service_mod.FileService = _FileService
monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod)
module_path = repo_root / "agent" / "component" / "fillup.py"
spec = importlib.util.spec_from_file_location(
"test_fillup_unit_module", module_path
)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "test_fillup_unit_module", module)
spec.loader.exec_module(module)
return module
def _make_fillup(module, *, query, inputs):
component = module.UserFillUp.__new__(module.UserFillUp)
component._canvas = SimpleNamespace(
globals={
"sys.query": query,
"sys.__initial_user_input_consumed__": False,
}
)
component._param = SimpleNamespace(
enable_tips=False,
tips="",
layout_recognize="",
inputs=inputs,
outputs={},
)
return component
@pytest.mark.p2
def test_user_fillup_auto_consumes_initial_query_for_single_field(monkeypatch):
module = _load_fillup_module(monkeypatch)
component = _make_fillup(
module,
query="code",
inputs={"demo": {"type": "options", "name": "Demo"}},
)
component._invoke(inputs={})
assert component._param.inputs["demo"]["value"] == "code"
assert component._param.outputs["demo"]["value"] == "code"
assert component._canvas.globals["sys.__initial_user_input_consumed__"] is True
@pytest.mark.p2
def test_user_fillup_only_auto_consumes_initial_query_once(monkeypatch):
module = _load_fillup_module(monkeypatch)
component = _make_fillup(
module,
query="code",
inputs={"demo": {"type": "options", "name": "Demo"}},
)
component._invoke(inputs={})
component._param.outputs = {}
component._invoke(inputs={})
assert component._param.outputs == {}
@pytest.mark.p2
def test_user_fillup_does_not_consume_unmatched_structured_query(monkeypatch):
module = _load_fillup_module(monkeypatch)
component = _make_fillup(
module,
query={"x": 8},
inputs={"demo": {"type": "options", "name": "Demo"}},
)
component._invoke(inputs={})
assert component._param.outputs == {}
assert component._canvas.globals["sys.__initial_user_input_consumed__"] is False
@@ -212,6 +212,54 @@ def _load_canvas_runtime(monkeypatch):
def thoughts(self): def thoughts(self):
return "sink" return "sink"
class UserFillUpParam(base_mod.ComponentParamBase):
def __init__(self):
super().__init__()
self.enable_tips = True
self.tips = "Please fill"
self.inputs = {"value": {"type": "line", "name": "Value"}}
def get_input_form(self):
return self.inputs
def check(self):
return True
class UserFillUp(base_mod.ComponentBase):
component_name = "UserFillUp"
def _invoke(self, **kwargs):
incoming = kwargs.get("inputs", {})
if "value" in incoming:
raw = incoming["value"]
value = raw.get("value") if isinstance(raw, dict) else raw
self.set_output("value", value)
if self._param.enable_tips:
self.set_output("tips", self._param.tips)
def get_input_elements(self):
return self._param.inputs
def thoughts(self):
return "fill"
class MessageParam(base_mod.ComponentParamBase):
def __init__(self):
super().__init__()
self.content = "{UserFillUp:1@value}"
def check(self):
return True
class Message(base_mod.ComponentBase):
component_name = "Message"
def _invoke(self, **kwargs):
self.set_output("content", self.string_format(self._param.content, {}))
def thoughts(self):
return "message"
class_map = { class_map = {
"Begin": Begin, "Begin": Begin,
"BeginParam": BeginParam, "BeginParam": BeginParam,
@@ -223,6 +271,10 @@ def _load_canvas_runtime(monkeypatch):
"ProbeParam": ProbeParam, "ProbeParam": ProbeParam,
"Sink": Sink, "Sink": Sink,
"SinkParam": SinkParam, "SinkParam": SinkParam,
"UserFillUp": UserFillUp,
"UserFillUpParam": UserFillUpParam,
"Message": Message,
"MessageParam": MessageParam,
} }
component_pkg.component_class = lambda name: class_map[name] component_pkg.component_class = lambda name: class_map[name]
@@ -237,9 +289,9 @@ def _load_canvas_runtime(monkeypatch):
return canvas_mod return canvas_mod
async def _collect_events(canvas): async def _collect_events(stream):
events = [] events = []
async for event in canvas.run(): async for event in stream:
events.append(event) events.append(event)
return events return events
@@ -308,7 +360,7 @@ def test_iteration_runtime_processes_all_array_items(monkeypatch):
} }
canvas = canvas_mod.Canvas(json.dumps(dsl)) canvas = canvas_mod.Canvas(json.dumps(dsl))
events = asyncio.run(_collect_events(canvas)) events = asyncio.run(_collect_events(canvas.run()))
assert canvas.globals["probe.calls"] == ["a", "b", "c"] assert canvas.globals["probe.calls"] == ["a", "b", "c"]
assert any(event["event"] == "workflow_finished" for event in events) assert any(event["event"] == "workflow_finished" for event in events)
@@ -386,6 +438,78 @@ def test_iteration_runtime_supports_bare_iteration_aliases(monkeypatch, query, e
} }
canvas = canvas_mod.Canvas(json.dumps(dsl)) canvas = canvas_mod.Canvas(json.dumps(dsl))
asyncio.run(_collect_events(canvas)) asyncio.run(_collect_events(canvas.run()))
assert canvas.globals["probe.calls"] == expected_calls assert canvas.globals["probe.calls"] == expected_calls
@pytest.mark.p2
def test_canvas_resume_does_not_emit_duplicate_workflow_started(monkeypatch):
canvas_mod = _load_canvas_runtime(monkeypatch)
dsl = {
"components": {
"begin": {
"obj": {"component_name": "Begin", "params": {}},
"downstream": ["UserFillUp:1"],
"upstream": [],
},
"UserFillUp:1": {
"obj": {
"component_name": "UserFillUp",
"params": {
"enable_tips": True,
"tips": "Enter value",
"inputs": {"value": {"type": "line", "name": "Value"}},
},
},
"downstream": ["Message:1"],
"upstream": ["begin"],
},
"Message:1": {
"obj": {
"component_name": "Message",
"params": {"content": "{UserFillUp:1@value}"},
},
"downstream": [],
"upstream": ["UserFillUp:1"],
},
},
"graph": {
"nodes": [
{"id": "begin", "data": {"name": "Begin"}},
{"id": "UserFillUp:1", "data": {"name": "UserFillUp"}},
{"id": "Message:1", "data": {"name": "Message"}},
]
},
"history": [],
"path": [],
"retrieval": [],
"globals": {
"sys.query": "",
"sys.user_id": "",
"sys.conversation_turns": 0,
"sys.files": [],
"sys.history": [],
"sys.date": "",
},
}
canvas = canvas_mod.Canvas(json.dumps(dsl))
first_events = asyncio.run(_collect_events(canvas.run()))
first_kinds = [event["event"] for event in first_events]
assert first_kinds == [
"workflow_started",
"node_started",
"node_finished",
"user_inputs",
]
resumed_events = asyncio.run(
_collect_events(canvas.run(query="hello", inputs={"value": {"value": "hello"}}))
)
resumed_kinds = [event["event"] for event in resumed_events]
assert resumed_kinds[0] == "node_started"
assert "workflow_started" not in resumed_kinds
assert "message" in resumed_kinds
assert resumed_kinds[-1] == "workflow_finished"
@@ -189,3 +189,13 @@ def test_set_outputs_tracks_first_and_last(monkeypatch):
assert component._param.outputs["result"]["value"] == ["c", "d", "e"] assert component._param.outputs["result"]["value"] == ["c", "d", "e"]
assert component._param.outputs["first"]["value"] == "c" assert component._param.outputs["first"]["value"] == "c"
assert component._param.outputs["last"]["value"] == "e" assert component._param.outputs["last"]["value"] == "e"
@pytest.mark.p2
def test_topn_operation_alias_normalizes_to_head(monkeypatch):
module = _load_list_operations_module(monkeypatch)
param = module.ListOperationsParam()
param.query = "items"
param.operations = "topN"
param.check()
assert param.operations == "head"
@@ -0,0 +1,48 @@
#
# 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.
#
from types import SimpleNamespace
from common.constants import ActiveStatusEnum
from api.db.joint_services import tenant_model_service as module
def test_resolve_instance_for_model_falls_back_from_default_to_single_active_instance(monkeypatch):
provider = SimpleNamespace(id="provider-1", provider_name="SILICONFLOW")
resolved = SimpleNamespace(
id="instance-1",
instance_name="yy2",
status=ActiveStatusEnum.ACTIVE.value,
)
monkeypatch.setattr(
module.TenantModelInstanceService,
"get_by_provider_id_and_instance_name",
lambda provider_id, instance_name: None,
)
monkeypatch.setattr(
module.TenantModelInstanceService,
"get_all_by_provider_id",
lambda provider_id: [resolved],
)
got = module._resolve_instance_for_model(
provider,
"default",
"Qwen/Qwen3-8B@default@SILICONFLOW",
)
assert got is resolved
Generated
+3358 -3293
View File
File diff suppressed because it is too large Load Diff
+35 -2
View File
@@ -15,6 +15,7 @@ export enum MessageEventType {
MessageEnd = 'message_end', MessageEnd = 'message_end',
WorkflowFinished = 'workflow_finished', WorkflowFinished = 'workflow_finished',
UserInputs = 'user_inputs', UserInputs = 'user_inputs',
WaitingForUser = 'waiting_for_user',
NodeLogs = 'node_logs', NodeLogs = 'node_logs',
} }
@@ -86,6 +87,28 @@ export type IChatEvent = INodeEvent | IMessageEvent | IMessageEndEvent;
export type IEventList = Array<IChatEvent>; export type IEventList = Array<IChatEvent>;
const parseAgentEventData = (data: any) => {
if (typeof data !== 'string') return data;
try {
return JSON.parse(data);
} catch {
return data;
}
};
const normalizeAgentEvent = (value: any) => {
if (value?.event === MessageEventType.WaitingForUser) {
return {
...value,
event: MessageEventType.UserInputs,
data: parseAgentEventData(value.data),
};
}
return value;
};
export const useSendMessageBySSE = (url: string) => { export const useSendMessageBySSE = (url: string) => {
const [answerList, setAnswerList] = useState<IEventList>([]); const [answerList, setAnswerList] = useState<IEventList>([]);
const [done, setDone] = useState(true); const [done, setDone] = useState(true);
@@ -123,7 +146,14 @@ export const useSendMessageBySSE = (url: string) => {
body: JSON.stringify(body), body: JSON.stringify(body),
signal: controller?.signal || sseRef.current?.signal, signal: controller?.signal || sseRef.current?.signal,
}); });
const responseDataPromise = response // SSE streams (text/event-stream) emit `data: {...}\n\n` frames, not
// a single JSON document. The .clone().json() call below is kept
// for non-streaming callers (lastEventData will be set from the
// per-frame parser below when the response IS SSE); for SSE
// bodies the JSON parse rejects — swallow it silently instead
// of console.warn'ing `SyntaxError: Unexpected token 'd', "data:
// {"ev"... is not valid JSON` on every chat completion.
const responseDataPromise: Promise<ResponseType | undefined> = response
.clone() .clone()
.json() .json()
.then((data: ResponseType) => data) .then((data: ResponseType) => data)
@@ -166,6 +196,7 @@ export const useSendMessageBySSE = (url: string) => {
} }
const { done, value } = x; const { done, value } = x;
if (done) { if (done) {
console.log('agent chat sse reader done');
break; break;
} }
@@ -185,9 +216,11 @@ export const useSendMessageBySSE = (url: string) => {
// `data: [DONE]` payload is caught and the stream // `data: [DONE]` payload is caught and the stream
// loop is terminated. // loop is terminated.
if (payload === '[DONE]') { if (payload === '[DONE]') {
console.log('agent chat sse done sentinel');
break; break;
} }
const val = JSON.parse(payload); const val = normalizeAgentEvent(JSON.parse(payload));
console.log('agent chat sse event', val);
if (typeof val?.code === 'number' && val.code !== 0) { if (typeof val?.code === 'number' && val.code !== 0) {
message.error(val.message); message.error(val.message);