Skip to content

Commit 43b16c0

Browse files
committed
✨ file to text tool
1 parent d707514 commit 43b16c0

File tree

8 files changed

+532
-23
lines changed

8 files changed

+532
-23
lines changed

sdk/nexent/core/tools/analyze_text_file_tool.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,16 @@ class AnalyzeTextFileTool(Tool):
3737
inputs = {
3838
"file_url_list": {
3939
"type": "array",
40-
"description": "List of file URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs. Can also accept a single file URL which will be treated as a list with one element."
40+
"description": "List of file URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs."
4141
},
4242
"query": {
4343
"type": "string",
4444
"description": "User's question to guide the analysis"
4545
}
4646
}
47-
output_type = "string"
48-
category = ToolCategory.FILE.value
49-
tool_sign = ToolSign.FILE_OPERATION.value
47+
output_type = "array"
48+
category = ToolCategory.MULTIMODAL.value
49+
tool_sign = ToolSign.MULTIMODAL_OPERATION.value
5050

5151
def __init__(
5252
self,
@@ -76,30 +76,29 @@ def __init__(
7676
self.data_process_service_url = data_process_service_url
7777
self.mm = LoadSaveObjectManager(storage_client=self.storage_client)
7878

79-
self.running_prompt_zh = "正在分析文本文件..."
80-
self.running_prompt_en = "Analyzing text file..."
79+
self.running_prompt_zh = "正在分析文件..."
80+
self.running_prompt_en = "Analyzing file..."
8181
# Dynamically apply the load_object decorator to forward method
8282
self.forward = self.mm.load_object(input_names=["file_url_list"])(self._forward_impl)
8383

8484
def _forward_impl(
8585
self,
86-
file_url_list: Union[bytes, List[bytes]],
86+
file_url_list: List[bytes],
8787
query: str,
88-
) -> Union[str, List[str]]:
88+
) -> List[str]:
8989
"""
9090
Analyze text file content using a large language model.
9191
9292
Note: This method is wrapped by load_object decorator which downloads
9393
the image from S3 URL, HTTP URL, or HTTPS URL and passes bytes to this method.
9494
9595
Args:
96-
file_url_list: File bytes or a sequence of file bytes (converted from URLs by the decorator).
97-
The load_object decorator converts URLs to bytes before calling this method.
96+
file_url_list: List of file bytes converted from URLs by the decorator.
97+
The load_object decorator converts URLs to bytes before calling this method.
9898
query: User's question to guide the analysis
9999
100100
Returns:
101-
Union[str, List[str]]: Single analysis string for one file or a list
102-
of analysis strings that align with the order of the provided files.
101+
List[str]: One analysis string per file that aligns with the order
103102
"""
104103
# Send tool run message
105104
if self.observer:
@@ -109,19 +108,15 @@ def _forward_impl(
109108
self.observer.add_message("", ProcessType.CARD, json.dumps(card_content, ensure_ascii=False))
110109

111110
if file_url_list is None:
112-
raise ValueError("file_url_list must contain at least one file")
111+
raise ValueError("file_url_list cannot be None")
113112

114-
if isinstance(file_url_list, (list, tuple)):
115-
file_inputs: List[bytes] = list(file_url_list)
116-
elif isinstance(file_url_list, bytes):
117-
file_inputs = [file_url_list]
118-
else:
119-
raise ValueError("file_url_list must be bytes or a list/tuple of bytes")
113+
if not isinstance(file_url_list, list):
114+
raise ValueError("file_url_list must be a list of bytes")
120115

121116
try:
122117
analysis_results: List[str] = []
123118

124-
for index, single_file in enumerate(file_inputs, start=1):
119+
for index, single_file in enumerate(file_url_list, start=1):
125120
logger.info(f"Extracting text content from file #{index}, query: {query}")
126121
filename = f"file_{index}.txt"
127122

@@ -143,8 +138,6 @@ def _forward_impl(
143138
logger.error(f"Failed to analyze file #{index}: {analysis_error}")
144139
analysis_results.append(str(analysis_error))
145140

146-
if len(analysis_results) == 1:
147-
return analysis_results[0]
148141
return analysis_results
149142

150143
except Exception as e:

sdk/nexent/core/utils/tools_common_message.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class ToolSign(Enum):
1111
TAVILY_SEARCH = "d" # Tavily search tool identifier
1212
FILE_OPERATION = "f" # File operation tool identifier
1313
TERMINAL_OPERATION = "t" # Terminal operation tool identifier
14+
MULTIMODAL_OPERATION = "m" # Multimodal operation tool identifier
1415

1516

1617
# Tool sign mapping for backward compatibility
@@ -21,6 +22,7 @@ class ToolSign(Enum):
2122
"exa_search": ToolSign.EXA_SEARCH.value,
2223
"file_operation": ToolSign.FILE_OPERATION.value,
2324
"terminal_operation": ToolSign.TERMINAL_OPERATION.value,
25+
"multimodal_operation": ToolSign.MULTIMODAL_OPERATION.value,
2426
}
2527

2628
# Reverse mapping for lookup
@@ -33,6 +35,7 @@ class ToolCategory(Enum):
3335
FILE = "file"
3436
EMAIL = "email"
3537
TERMINAL = "terminal"
38+
MULTIMODAL = "multimodal"
3639

3740

3841
@dataclass

test/common/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Common utilities shared across backend tests."""

test/common/env_test_utils.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Shared helpers for image-service related tests."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
import types
7+
from functools import lru_cache
8+
from pathlib import Path
9+
from typing import Dict, Any
10+
from unittest.mock import MagicMock
11+
12+
13+
def _ensure_path(path: Path) -> None:
14+
if str(path) not in sys.path:
15+
sys.path.insert(0, str(path))
16+
17+
18+
def _create_module(name: str, **attrs: Any) -> types.ModuleType:
19+
module = types.ModuleType(name)
20+
for attr_name, attr_value in attrs.items():
21+
setattr(module, attr_name, attr_value)
22+
sys.modules[name] = module
23+
return module
24+
25+
26+
@lru_cache(maxsize=1)
27+
def bootstrap_env() -> Dict[str, Any]:
28+
current_dir = Path(__file__).resolve().parent
29+
project_root = current_dir.parents[1]
30+
backend_dir = project_root / "backend"
31+
32+
_ensure_path(project_root)
33+
_ensure_path(backend_dir)
34+
35+
mock_const = MagicMock()
36+
consts_module = _create_module("consts", const=mock_const)
37+
sys.modules["consts.const"] = mock_const
38+
39+
boto3_mock = MagicMock()
40+
sys.modules.setdefault("boto3", boto3_mock)
41+
42+
client_module = _create_module(
43+
"backend.database.client",
44+
MinioClient=MagicMock(),
45+
PostgresClient=MagicMock(),
46+
db_client=MagicMock(),
47+
get_db_session=MagicMock(),
48+
as_dict=MagicMock(),
49+
minio_client=MagicMock(),
50+
postgres_client=MagicMock(),
51+
)
52+
sys.modules["database.client"] = client_module
53+
if "database" not in sys.modules:
54+
_create_module("database")
55+
56+
config_utils_module = _create_module(
57+
"utils.config_utils",
58+
tenant_config_manager=MagicMock(),
59+
get_model_name_from_config=MagicMock(return_value=""),
60+
)
61+
62+
nexent_module = _create_module("nexent", MessageObserver=MagicMock())
63+
_create_module("nexent.core")
64+
_create_module("nexent.core.models", OpenAIVLModel=MagicMock())
65+
66+
return {
67+
"mock_const": mock_const,
68+
"consts_module": consts_module,
69+
"client_module": client_module,
70+
"config_utils_module": config_utils_module,
71+
"nexent_module": nexent_module,
72+
"boto3_mock": boto3_mock,
73+
"project_root": project_root,
74+
"backend_dir": backend_dir,
75+
}

test/sdk/core/agents/test_nexent_agent.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,47 @@ def test_create_local_tool_success(nexent_agent_instance):
491491
assert result == mock_tool_instance
492492

493493

494+
def test_create_local_tool_analyze_text_file_tool(nexent_agent_instance):
495+
"""Test AnalyzeTextFileTool creation injects observer and metadata."""
496+
mock_analyze_tool_class = MagicMock()
497+
mock_analyze_tool_instance = MagicMock()
498+
mock_analyze_tool_class.return_value = mock_analyze_tool_instance
499+
500+
tool_config = ToolConfig(
501+
class_name="AnalyzeTextFileTool",
502+
name="analyze_text_file",
503+
description="desc",
504+
inputs="{}",
505+
output_type="array",
506+
params={"prompt": "describe this"},
507+
source="local",
508+
metadata={
509+
"llm_model": "llm_model_obj",
510+
"storage_client": "storage_client_obj",
511+
},
512+
)
513+
514+
original_value = nexent_agent.__dict__.get("AnalyzeTextFileTool")
515+
nexent_agent.__dict__["AnalyzeTextFileTool"] = mock_analyze_tool_class
516+
517+
try:
518+
result = nexent_agent_instance.create_local_tool(tool_config)
519+
finally:
520+
if original_value is not None:
521+
nexent_agent.__dict__["AnalyzeTextFileTool"] = original_value
522+
elif "AnalyzeTextFileTool" in nexent_agent.__dict__:
523+
del nexent_agent.__dict__["AnalyzeTextFileTool"]
524+
525+
mock_analyze_tool_class.assert_called_once_with(
526+
observer=nexent_agent_instance.observer,
527+
llm_model="llm_model_obj",
528+
storage_client="storage_client_obj",
529+
prompt="describe this",
530+
data_process_service_url="https://example.com",
531+
)
532+
assert result == mock_analyze_tool_instance
533+
534+
494535
def test_create_local_tool_class_not_found(nexent_agent_instance):
495536
"""Test create_local_tool raises ValueError when class is not found."""
496537
tool_config = ToolConfig(

0 commit comments

Comments
 (0)