Skip to content

Commit d707514

Browse files
committed
✨ file to text tool
1 parent 9f5585f commit d707514

File tree

10 files changed

+346
-7
lines changed

10 files changed

+346
-7
lines changed

backend/agents/create_agent_info.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from nexent.core.agents.agent_model import AgentRunInfo, ModelConfig, AgentConfig, ToolConfig
1010
from nexent.memory.memory_service import search_memory_in_levels
1111

12+
from services.file_management_service import get_llm_model
1213
from services.vectordatabase_service import (
1314
ElasticSearchService,
1415
get_vector_db_core,
@@ -20,10 +21,11 @@
2021
from database.agent_db import search_agent_info_by_agent_id, query_sub_agents_id_list
2122
from database.tool_db import search_tools_for_sub_agent
2223
from database.model_management_db import get_model_records, get_model_by_model_id
24+
from database.client import minio_client
2325
from utils.model_name_utils import add_repo_to_name
2426
from utils.prompt_template_utils import get_agent_prompt_template
2527
from utils.config_utils import tenant_config_manager, get_model_name_from_config
26-
from consts.const import LOCAL_MCP_SERVER, MODEL_CONFIG_MAPPING, LANGUAGE
28+
from consts.const import LOCAL_MCP_SERVER, MODEL_CONFIG_MAPPING, LANGUAGE, DATA_PROCESS_SERVICE
2729

2830
logger = logging.getLogger("create_agent_info")
2931
logger.setLevel(logging.DEBUG)
@@ -236,6 +238,12 @@ async def create_tool_config_list(agent_id, tenant_id, user_id):
236238
"vdb_core": get_vector_db_core(),
237239
"embedding_model": get_embedding_model(tenant_id=tenant_id),
238240
}
241+
elif tool_config.class_name == "AnalyzeTextFileTool":
242+
tool_config.metadata = {
243+
"llm_model": get_llm_model(tenant_id=tenant_id),
244+
"storage_client": minio_client,
245+
"data_process_service_url": DATA_PROCESS_SERVICE
246+
}
239247
tool_config_list.append(tool_config)
240248

241249
return tool_config_list

backend/services/file_management_service.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from fastapi import UploadFile
1111

1212
from agents.preprocess_manager import preprocess_manager
13-
from consts.const import UPLOAD_FOLDER, MAX_CONCURRENT_UPLOADS, DATA_PROCESS_SERVICE, LANGUAGE
13+
from consts.const import UPLOAD_FOLDER, MAX_CONCURRENT_UPLOADS, DATA_PROCESS_SERVICE, LANGUAGE, MODEL_CONFIG_MAPPING
1414
from database.attachment_db import (
1515
upload_fileobj,
1616
get_file_url,
@@ -19,11 +19,15 @@
1919
delete_file,
2020
list_files
2121
)
22-
from utils.attachment_utils import convert_image_to_text, convert_long_text_to_text
2322
from services.vectordatabase_service import ElasticSearchService, get_vector_db_core
23+
from utils.attachment_utils import convert_image_to_text, convert_long_text_to_text
24+
from utils.config_utils import tenant_config_manager, get_model_name_from_config
2425
from utils.prompt_template_utils import get_file_processing_messages_template
2526
from utils.file_management_utils import save_upload_file
2627

28+
from nexent import MessageObserver
29+
from nexent.core.models import OpenAILongContextModel
30+
2731
# Create upload directory
2832
upload_dir = Path(UPLOAD_FOLDER)
2933
upload_dir.mkdir(exist_ok=True)
@@ -405,3 +409,16 @@ def get_file_description(files: List[UploadFile]) -> str:
405409
else:
406410
description += f"- File {file.filename or ''}\n"
407411
return description
412+
413+
def get_llm_model(tenant_id: str):
414+
# Get the tenant config
415+
main_model_config = tenant_config_manager.get_model_config(
416+
key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id)
417+
long_text_to_text_model = OpenAILongContextModel(
418+
observer=MessageObserver(),
419+
model_id=get_model_name_from_config(main_model_config),
420+
api_base=main_model_config.get("base_url"),
421+
api_key=main_model_config.get("api_key"),
422+
max_context_tokens=main_model_config.get("max_tokens")
423+
)
424+
return long_text_to_text_model

backend/services/tool_configuration_service.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import jsonref
1212
from mcpadapt.smolagents_adapter import _sanitize_function_name
1313

14-
from consts.const import DEFAULT_USER_ID, LOCAL_MCP_SERVER
14+
from consts.const import DEFAULT_USER_ID, LOCAL_MCP_SERVER, DATA_PROCESS_SERVICE
1515
from consts.exceptions import MCPConnectionError, ToolExecutionException, NotFoundException
1616
from consts.model import ToolInstanceInfoRequest, ToolInfo, ToolSourceEnum, ToolValidateRequest
1717
from database.remote_mcp_db import get_mcp_records_by_tenant, get_mcp_server_by_name_and_tenant
@@ -23,6 +23,8 @@
2323
search_last_tool_instance_by_tool_id,
2424
)
2525
from database.user_tenant_db import get_all_tenant_ids
26+
from database.client import minio_client
27+
from services.file_management_service import get_llm_model
2628
from services.vectordatabase_service import get_embedding_model, get_vector_db_core
2729
from services.tenant_config_service import get_selected_knowledge_list
2830

@@ -613,6 +615,17 @@ def _validate_local_tool(
613615
'embedding_model': embedding_model,
614616
}
615617
tool_instance = tool_class(**params)
618+
elif tool_name == "analyze_text_file":
619+
if not tenant_id or not user_id:
620+
raise ToolExecutionException(f"Tenant ID and User ID are required for {tool_name} validation")
621+
long_text_to_text_model = get_llm_model(tenant_id=tenant_id)
622+
params = {
623+
**instantiation_params,
624+
'llm_model': long_text_to_text_model,
625+
'storage_client': minio_client,
626+
"data_process_service_url": DATA_PROCESS_SERVICE
627+
}
628+
tool_instance = tool_class(**params)
616629
else:
617630
tool_instance = tool_class(**instantiation_params)
618631

sdk/nexent/core/agents/nexent_agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ def create_local_tool(self, tool_config: ToolConfig):
8383
"vdb_core", None) if tool_config.metadata else None
8484
tools_obj.embedding_model = tool_config.metadata.get(
8585
"embedding_model", None) if tool_config.metadata else None
86+
elif class_name == "AnalyzeTextFileTool":
87+
tools_obj = tool_class(observer=self.observer,
88+
llm_model=tool_config.metadata.get("llm_model", []),
89+
storage_client=tool_config.metadata.get("storage_client", []),
90+
data_process_service_url=tool_config.metadata.get("data_process_service_url", []),
91+
**params)
8692
else:
8793
tools_obj = tool_class(**params)
8894
if hasattr(tools_obj, 'observer'):
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# File analysis prompt template
2+
# For long text content analysis
3+
system_prompt: |-
4+
用户提出了一个问题:{{ query }},请从回答这个问题的角度精简、仔细描述一下这段文本,200字以内。
5+
6+
**文本分析要求:**
7+
1. 重点提取与用户问题相关的文本内容
8+
2. 归纳总结要准确简洁,突出核心信息
9+
3. 保持原文的关键观点和数据
10+
4. 避免冗余信息,专注于问题相关内容
11+
12+
user_prompt: |
13+
请仔细阅读并分析这段文本:
14+
15+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# File analysis prompt template
2+
# For long text content analysis
3+
system_prompt: |-
4+
The user has asked a question: {{ query }}. Please provide a concise and careful description of this text from the perspective of answering this question, within 200 words.
5+
6+
**Text Analysis Requirements:**
7+
1. Focus on extracting text content relevant to the user's question
8+
2. Summary should be accurate and concise, highlighting core information
9+
3. Maintain key viewpoints and data from the original text
10+
4. Avoid redundant information, focus on question-related content
11+
12+
user_prompt: |
13+
Please carefully read and analyze this text:
14+

sdk/nexent/core/tools/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .move_item_tool import MoveItemTool
1313
from .list_directory_tool import ListDirectoryTool
1414
from .terminal_tool import TerminalTool
15+
from .analyze_text_file_tool import AnalyzeTextFileTool
1516

1617
__all__ = [
1718
"ExaSearchTool",
@@ -27,5 +28,6 @@
2728
"DeleteDirectoryTool",
2829
"MoveItemTool",
2930
"ListDirectoryTool",
30-
"TerminalTool"
31+
"TerminalTool",
32+
"AnalyzeTextFileTool"
3133
]
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""
2+
Analyze Text File Tool
3+
4+
Extracts content from text files (excluding images) and analyzes it using a large language model.
5+
Supports files from S3, HTTP, and HTTPS URLs.
6+
"""
7+
import json
8+
import logging
9+
from typing import List, Optional, Union
10+
11+
import httpx
12+
from jinja2 import Template, StrictUndefined
13+
from pydantic import Field
14+
from smolagents.tools import Tool
15+
16+
from nexent.core import MessageObserver
17+
from nexent.core.utils.observer import ProcessType
18+
from nexent.core.utils.prompt_template_utils import get_prompt_template
19+
from nexent.core.utils.tools_common_message import ToolCategory, ToolSign
20+
from nexent.storage import MinIOStorageClient
21+
from nexent.multi_modal.load_save_object import LoadSaveObjectManager
22+
23+
24+
logger = logging.getLogger("analyze_text_file_tool")
25+
26+
27+
class AnalyzeTextFileTool(Tool):
28+
"""Tool for analyzing text file content using a large language model"""
29+
30+
name = "analyze_text_file"
31+
description = (
32+
"Extract content from text files and analyze them using a large language model based on your query. "
33+
"Supports multiple files from S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs. "
34+
"The tool will extract the text content from each file and return an analysis based on your question."
35+
)
36+
37+
inputs = {
38+
"file_url_list": {
39+
"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."
41+
},
42+
"query": {
43+
"type": "string",
44+
"description": "User's question to guide the analysis"
45+
}
46+
}
47+
output_type = "string"
48+
category = ToolCategory.FILE.value
49+
tool_sign = ToolSign.FILE_OPERATION.value
50+
51+
def __init__(
52+
self,
53+
storage_client: Optional[MinIOStorageClient] = Field(
54+
description="Storage client for downloading files from S3 URLs、HTTP URLs、HTTPS URLs.",
55+
default=None,
56+
exclude=True
57+
),
58+
observer: MessageObserver = Field(
59+
description="Message observer",
60+
default=None,
61+
exclude=True
62+
),
63+
data_process_service_url: str = Field(
64+
description="URL of data process service",
65+
default=None,
66+
exclude=True),
67+
llm_model: str = Field(
68+
description="The LLM model to use",
69+
default=None,
70+
exclude=True)
71+
):
72+
super().__init__()
73+
self.storage_client = storage_client
74+
self.observer = observer
75+
self.llm_model = llm_model
76+
self.data_process_service_url = data_process_service_url
77+
self.mm = LoadSaveObjectManager(storage_client=self.storage_client)
78+
79+
self.running_prompt_zh = "正在分析文本文件..."
80+
self.running_prompt_en = "Analyzing text file..."
81+
# Dynamically apply the load_object decorator to forward method
82+
self.forward = self.mm.load_object(input_names=["file_url_list"])(self._forward_impl)
83+
84+
def _forward_impl(
85+
self,
86+
file_url_list: Union[bytes, List[bytes]],
87+
query: str,
88+
) -> Union[str, List[str]]:
89+
"""
90+
Analyze text file content using a large language model.
91+
92+
Note: This method is wrapped by load_object decorator which downloads
93+
the image from S3 URL, HTTP URL, or HTTPS URL and passes bytes to this method.
94+
95+
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.
98+
query: User's question to guide the analysis
99+
100+
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.
103+
"""
104+
# Send tool run message
105+
if self.observer:
106+
running_prompt = self.running_prompt_zh if self.observer.lang == "zh" else self.running_prompt_en
107+
self.observer.add_message("", ProcessType.TOOL, running_prompt)
108+
card_content = [{"icon": "file", "text": f"Analyzing file..."}]
109+
self.observer.add_message("", ProcessType.CARD, json.dumps(card_content, ensure_ascii=False))
110+
111+
if file_url_list is None:
112+
raise ValueError("file_url_list must contain at least one file")
113+
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")
120+
121+
try:
122+
analysis_results: List[str] = []
123+
124+
for index, single_file in enumerate(file_inputs, start=1):
125+
logger.info(f"Extracting text content from file #{index}, query: {query}")
126+
filename = f"file_{index}.txt"
127+
128+
# Step 1: Get file content
129+
raw_text = self.process_text_file(filename, single_file)
130+
131+
if not raw_text:
132+
error_msg = f"No text content extracted from file #{index}"
133+
logger.error(error_msg)
134+
raise Exception(error_msg)
135+
136+
logger.info(f"Analyzing text content with LLM for file #{index}, query: {query}")
137+
138+
# Step 2: Analyze file content
139+
try:
140+
text, _ = self.analyze_file(query, raw_text)
141+
analysis_results.append(text)
142+
except Exception as analysis_error:
143+
logger.error(f"Failed to analyze file #{index}: {analysis_error}")
144+
analysis_results.append(str(analysis_error))
145+
146+
if len(analysis_results) == 1:
147+
return analysis_results[0]
148+
return analysis_results
149+
150+
except Exception as e:
151+
logger.error(f"Error analyzing text file: {str(e)}", exc_info=True)
152+
error_msg = f"Error analyzing text file: {str(e)}"
153+
raise Exception(error_msg)
154+
155+
156+
def process_text_file(self, filename: str, file_content: bytes,) -> str:
157+
"""
158+
Process text file, convert to text using external API
159+
"""
160+
# file_content is byte data, need to send to API through file upload
161+
api_url = f"{self.data_process_service_url}/tasks/process_text_file"
162+
logger.info(f"Processing text file {filename} with API: {api_url}")
163+
164+
raw_text = ""
165+
try:
166+
# Upload byte data as a file
167+
files = {
168+
'file': (filename, file_content, 'application/octet-stream')
169+
}
170+
data = {
171+
'chunking_strategy': 'basic',
172+
'timeout': 60
173+
}
174+
with httpx.Client(timeout=60) as client:
175+
response = client.post(api_url, files=files, data=data)
176+
177+
if response.status_code == 200:
178+
result = response.json()
179+
raw_text = result.get("text", "")
180+
logger.info(
181+
f"File processed successfully: {raw_text[:200]}...{raw_text[-200:]}..., length: {len(raw_text)}")
182+
else:
183+
error_detail = response.json().get('detail', 'unknown error') if response.headers.get(
184+
'content-type', '').startswith('application/json') else response.text
185+
logger.error(
186+
f"File processing failed (status code: {response.status_code}): {error_detail}")
187+
raise Exception(error_detail)
188+
189+
except Exception as e:
190+
logger.error(f"Failed to process text file {filename}: {str(e)}", exc_info=True)
191+
raise
192+
193+
return raw_text
194+
195+
def analyze_file(self, query: str, raw_text: str,):
196+
"""
197+
Process text file, convert to text using external API
198+
"""
199+
language = getattr(self.observer, "lang", "en") if self.observer else "en"
200+
prompts = get_prompt_template(template_type='analyze_file', language=language)
201+
system_prompt_template = Template(prompts['system_prompt'], undefined=StrictUndefined)
202+
user_prompt_template = Template(prompts['user_prompt'], undefined=StrictUndefined)
203+
204+
system_prompt = system_prompt_template.render({'query': query})
205+
user_prompt = user_prompt_template.render({})
206+
207+
result, truncation_percentage = self.llm_model.analyze_long_text(
208+
text_content=raw_text,
209+
system_prompt=system_prompt,
210+
user_prompt=user_prompt
211+
)
212+
return result.content, truncation_percentage

0 commit comments

Comments
 (0)