Skip to content

Commit 1e3bbfe

Browse files
authored
✨ file to text tool
✨ file to text tool #1219
2 parents bef9329 + 35dcc9f commit 1e3bbfe

19 files changed

+1243
-54
lines changed

backend/agents/create_agent_info.py

Lines changed: 8 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,
@@ -25,7 +26,7 @@
2526
from utils.model_name_utils import add_repo_to_name
2627
from utils.prompt_template_utils import get_agent_prompt_template
2728
from utils.config_utils import tenant_config_manager, get_model_name_from_config
28-
from consts.const import LOCAL_MCP_SERVER, MODEL_CONFIG_MAPPING, LANGUAGE
29+
from consts.const import LOCAL_MCP_SERVER, MODEL_CONFIG_MAPPING, LANGUAGE, DATA_PROCESS_SERVICE
2930

3031
logger = logging.getLogger("create_agent_info")
3132
logger.setLevel(logging.DEBUG)
@@ -238,6 +239,12 @@ async def create_tool_config_list(agent_id, tenant_id, user_id):
238239
"vdb_core": get_vector_db_core(),
239240
"embedding_model": get_embedding_model(tenant_id=tenant_id),
240241
}
242+
elif tool_config.class_name == "AnalyzeTextFileTool":
243+
tool_config.metadata = {
244+
"llm_model": get_llm_model(tenant_id=tenant_id),
245+
"storage_client": minio_client,
246+
"data_process_service_url": DATA_PROCESS_SERVICE
247+
}
241248
elif tool_config.class_name == "AnalyzeImageTool":
242249
tool_config.metadata = {
243250
"vlm_model": get_vlm_model(tenant_id=tenant_id),

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: 13 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,7 @@
2323
search_last_tool_instance_by_tool_id,
2424
)
2525
from database.user_tenant_db import get_all_tenant_ids
26+
from services.file_management_service import get_llm_model
2627
from services.vectordatabase_service import get_embedding_model, get_vector_db_core
2728
from services.tenant_config_service import get_selected_knowledge_list
2829
from database.client import minio_client
@@ -625,6 +626,17 @@ def _validate_local_tool(
625626
'storage_client': minio_client
626627
}
627628
tool_instance = tool_class(**params)
629+
elif tool_name == "analyze_text_file":
630+
if not tenant_id or not user_id:
631+
raise ToolExecutionException(f"Tenant ID and User ID are required for {tool_name} validation")
632+
long_text_to_text_model = get_llm_model(tenant_id=tenant_id)
633+
params = {
634+
**instantiation_params,
635+
'llm_model': long_text_to_text_model,
636+
'storage_client': minio_client,
637+
"data_process_service_url": DATA_PROCESS_SERVICE
638+
}
639+
tool_instance = tool_class(**params)
628640
else:
629641
tool_instance = tool_class(**instantiation_params)
630642

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
elif class_name == "AnalyzeImageTool":
8793
tools_obj = tool_class(observer=self.observer,
8894
vlm_model=tool_config.metadata.get("vlm_model", []),
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: 2 additions & 0 deletions
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
from .analyze_image_tool import AnalyzeImageTool
1617

1718
__all__ = [
@@ -29,5 +30,6 @@
2930
"MoveItemTool",
3031
"ListDirectoryTool",
3132
"TerminalTool",
33+
"AnalyzeTextFileTool",
3234
"AnalyzeImageTool"
3335
]
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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."
41+
},
42+
"query": {
43+
"type": "string",
44+
"description": "User's question to guide the analysis"
45+
}
46+
}
47+
output_type = "array"
48+
category = ToolCategory.MULTIMODAL.value
49+
tool_sign = ToolSign.MULTIMODAL_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 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: List[bytes],
87+
query: str,
88+
) -> 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: List 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+
List[str]: One analysis string per file that aligns with the order
102+
"""
103+
# Send tool run message
104+
if self.observer:
105+
running_prompt = self.running_prompt_zh if self.observer.lang == "zh" else self.running_prompt_en
106+
self.observer.add_message("", ProcessType.TOOL, running_prompt)
107+
card_content = [{"icon": "file", "text": f"Analyzing file..."}]
108+
self.observer.add_message("", ProcessType.CARD, json.dumps(card_content, ensure_ascii=False))
109+
110+
if file_url_list is None:
111+
raise ValueError("file_url_list cannot be None")
112+
113+
if not isinstance(file_url_list, list):
114+
raise ValueError("file_url_list must be a list of bytes")
115+
116+
try:
117+
analysis_results: List[str] = []
118+
119+
for index, single_file in enumerate(file_url_list, start=1):
120+
logger.info(f"Extracting text content from file #{index}, query: {query}")
121+
filename = f"file_{index}.txt"
122+
123+
# Step 1: Get file content
124+
raw_text = self.process_text_file(filename, single_file)
125+
126+
if not raw_text:
127+
error_msg = f"No text content extracted from file #{index}"
128+
logger.error(error_msg)
129+
raise Exception(error_msg)
130+
131+
logger.info(f"Analyzing text content with LLM for file #{index}, query: {query}")
132+
133+
# Step 2: Analyze file content
134+
try:
135+
text, _ = self.analyze_file(query, raw_text)
136+
analysis_results.append(text)
137+
except Exception as analysis_error:
138+
logger.error(f"Failed to analyze file #{index}: {analysis_error}")
139+
analysis_results.append(str(analysis_error))
140+
141+
return analysis_results
142+
143+
except Exception as e:
144+
logger.error(f"Error analyzing text file: {str(e)}", exc_info=True)
145+
error_msg = f"Error analyzing text file: {str(e)}"
146+
raise Exception(error_msg)
147+
148+
149+
def process_text_file(self, filename: str, file_content: bytes,) -> str:
150+
"""
151+
Process text file, convert to text using external API
152+
"""
153+
# file_content is byte data, need to send to API through file upload
154+
api_url = f"{self.data_process_service_url}/tasks/process_text_file"
155+
logger.info(f"Processing text file {filename} with API: {api_url}")
156+
157+
raw_text = ""
158+
try:
159+
# Upload byte data as a file
160+
files = {
161+
'file': (filename, file_content, 'application/octet-stream')
162+
}
163+
data = {
164+
'chunking_strategy': 'basic',
165+
'timeout': 60
166+
}
167+
with httpx.Client(timeout=60) as client:
168+
response = client.post(api_url, files=files, data=data)
169+
170+
if response.status_code == 200:
171+
result = response.json()
172+
raw_text = result.get("text", "")
173+
logger.info(
174+
f"File processed successfully: {raw_text[:200]}...{raw_text[-200:]}..., length: {len(raw_text)}")
175+
else:
176+
error_detail = response.json().get('detail', 'unknown error') if response.headers.get(
177+
'content-type', '').startswith('application/json') else response.text
178+
logger.error(
179+
f"File processing failed (status code: {response.status_code}): {error_detail}")
180+
raise Exception(error_detail)
181+
182+
except Exception as e:
183+
logger.error(f"Failed to process text file {filename}: {str(e)}", exc_info=True)
184+
raise
185+
186+
return raw_text
187+
188+
def analyze_file(self, query: str, raw_text: str,):
189+
"""
190+
Process text file, convert to text using external API
191+
"""
192+
language = getattr(self.observer, "lang", "en") if self.observer else "en"
193+
prompts = get_prompt_template(template_type='analyze_file', language=language)
194+
system_prompt_template = Template(prompts['system_prompt'], undefined=StrictUndefined)
195+
user_prompt_template = Template(prompts['user_prompt'], undefined=StrictUndefined)
196+
197+
system_prompt = system_prompt_template.render({'query': query})
198+
user_prompt = user_prompt_template.render({})
199+
200+
result, truncation_percentage = self.llm_model.analyze_long_text(
201+
text_content=raw_text,
202+
system_prompt=system_prompt,
203+
user_prompt=user_prompt
204+
)
205+
return result.content, truncation_percentage

0 commit comments

Comments
 (0)