doodle-med commited on
Commit
a90bd60
·
verified ·
1 Parent(s): 99e0b55

Create App.py

Browse files
Files changed (1) hide show
  1. App.py +250 -0
App.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ # A production-quality, local, and uncensored text-editing agent
3
+ # that can read, reason over, and rewrite large documents.
4
+
5
+ import asyncio
6
+ import gradio as gr
7
+ import pathlib
8
+ import re
9
+ import textwrap
10
+ from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
11
+ from transformers.agents import Agent, Tool
12
+ from fastmcp import FastMCP
13
+
14
+ # --- Configuration ---
15
+ # Use a more descriptive model name for clarity
16
+ MODEL_ID = "NousResearch/Meta-Llama-3-8B-Instruct-GPTQ"
17
+ # Sandbox all file operations to this directory for security
18
+ ROOT = pathlib.Path("workspace")
19
+ ROOT.mkdir(exist_ok=True) # Ensure the workspace directory exists
20
+
21
+ # --- 1. MCP Text-Editing Server (The "Tools" Backend) ---
22
+ # This server runs locally and provides the agent with tools to interact with files.
23
+ server = FastMCP("DocTools")
24
+
25
+ @server.tool()
26
+ def list_files(relative_path: str = ".") -> list[str]:
27
+ """
28
+ Lists all files and directories within a given subdirectory of the workspace.
29
+ Args:
30
+ relative_path (str): The subdirectory path relative to the workspace root.
31
+ Defaults to the current directory ('.').
32
+ """
33
+ try:
34
+ # Security: Prevent directory traversal attacks
35
+ safe_path = (ROOT / relative_path).resolve()
36
+ if not safe_path.is_relative_to(ROOT.resolve()):
37
+ return ["Error: Access denied. Path is outside the workspace."]
38
+
39
+ if not safe_path.exists():
40
+ return [f"Error: Directory '{relative_path}' not found."]
41
+
42
+ return [p.name for p in safe_path.iterdir()]
43
+ except Exception as e:
44
+ return [f"An error occurred: {str(e)}"]
45
+
46
+ @server.tool()
47
+ def search_in_file(file_path: str, pattern: str, max_hits: int = 40) -> list[str]:
48
+ """
49
+ Searches for a regex pattern within a specified file in the workspace.
50
+ Args:
51
+ file_path (str): The path to the file relative to the workspace root.
52
+ pattern (str): The regular expression pattern to search for (case-insensitive).
53
+ max_hits (int): The maximum number of matching lines to return.
54
+ """
55
+ try:
56
+ # Security: Resolve and check the file path
57
+ safe_path = (ROOT / file_path).resolve()
58
+ if not safe_path.is_relative_to(ROOT.resolve()):
59
+ return ["Error: Access denied. Path is outside the workspace."]
60
+
61
+ if not safe_path.is_file():
62
+ return [f"Error: File '{file_path}' not found."]
63
+
64
+ output = []
65
+ regex = re.compile(pattern, re.IGNORECASE)
66
+ with open(safe_path, 'r', encoding='utf-8') as f:
67
+ for i, line in enumerate(f):
68
+ if regex.search(line):
69
+ output.append(f"{i+1}: {line.rstrip()}")
70
+ if len(output) >= max_hits:
71
+ break
72
+ return output if output else ["No matches found."]
73
+ except Exception as e:
74
+ return [f"An error occurred while reading the file: {str(e)}"]
75
+
76
+ @server.tool()
77
+ def read_lines(file_path: str, start_line: int, end_line: int) -> str:
78
+ """
79
+ Reads and returns a specific range of lines from a file.
80
+ Args:
81
+ file_path (str): The path to the file relative to the workspace root.
82
+ start_line (int): The starting line number (1-indexed).
83
+ end_line (int): The ending line number (inclusive).
84
+ """
85
+ try:
86
+ # Security: Resolve and check the file path
87
+ safe_path = (ROOT / file_path).resolve()
88
+ if not safe_path.is_relative_to(ROOT.resolve()):
89
+ return "Error: Access denied. Path is outside the workspace."
90
+
91
+ if not safe_path.is_file():
92
+ return f"Error: File '{file_path}' not found."
93
+
94
+ with open(safe_path, 'r', encoding='utf-8') as f:
95
+ lines = f.readlines()
96
+
97
+ # Adjust for 0-based indexing and ensure bounds are valid
98
+ start_index = max(0, start_line - 1)
99
+ end_index = min(len(lines), end_line)
100
+
101
+ return "".join(lines[start_index:end_index])
102
+ except Exception as e:
103
+ return f"An error occurred: {str(e)}"
104
+
105
+ @server.tool()
106
+ def patch_file(file_path: str, start_line: int, end_line: int, new_content: str) -> str:
107
+ """
108
+ Replaces a range of lines in a file with new content.
109
+ Args:
110
+ file_path (str): The path to the file relative to the workspace root.
111
+ start_line (int): The starting line number for replacement (1-indexed).
112
+ end_line (int): The ending line number for replacement (inclusive).
113
+ new_content (str): The new text to insert.
114
+ """
115
+ try:
116
+ # Security: Resolve and check the file path
117
+ safe_path = (ROOT / file_path).resolve()
118
+ if not safe_path.is_relative_to(ROOT.resolve()):
119
+ return "Error: Access denied. Path is outside the workspace."
120
+
121
+ if not safe_path.is_file():
122
+ return f"Error: File '{file_path}' not found."
123
+
124
+ with open(safe_path, 'r', encoding='utf-8') as f:
125
+ lines = f.readlines()
126
+
127
+ start_index = max(0, start_line - 1)
128
+
129
+ # Create the new file content in memory
130
+ new_lines = (
131
+ lines[:start_index] +
132
+ [line + '\n' for line in new_content.splitlines()] +
133
+ lines[end_line:]
134
+ )
135
+
136
+ with open(safe_path, 'w', encoding='utf-8') as f:
137
+ f.writelines(new_lines)
138
+
139
+ return f"Success: Patched lines {start_line}-{end_line} in '{file_path}'."
140
+ except Exception as e:
141
+ return f"An error occurred during patching: {str(e)}"
142
+
143
+
144
+ # --- 2. Local Function-Calling LLM ---
145
+ # Initialize the model and tokenizer for the agent.
146
+ # Using a GPTQ quantized model for efficient inference on GPUs.
147
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
148
+ model = AutoModelForCausalLM.from_pretrained(
149
+ MODEL_ID,
150
+ device_map="auto",
151
+ # 8-bit quantization for a balance of speed and performance.
152
+ quantization_config={"bits": 8, "load_in_8bit": True}
153
+ )
154
+
155
+ # Create the pipeline for text generation with streaming capabilities.
156
+ llm_pipeline = pipeline(
157
+ "text-generation",
158
+ model=model,
159
+ tokenizer=tokenizer,
160
+ return_full_text=False, # Essential for streaming and agent control
161
+ max_new_tokens=1024,
162
+ )
163
+
164
+ # --- 3. Transformers Agent Orchestrator ---
165
+ # This agent coordinates the LLM and the tools to accomplish user goals.
166
+
167
+ def build_hf_tool(mcp_tool_name: str) -> Tool:
168
+ """Dynamically creates a Hugging Face Tool from a FastMCP tool's schema."""
169
+ schema = server.get_schema(mcp_tool_name)
170
+
171
+ # The actual function that the agent will call
172
+ def tool_function(**kwargs):
173
+ # The FastMCP server handles the invocation internally
174
+ return server.invoke(mcp_tool_name, **kwargs)
175
+
176
+ return Tool(
177
+ name=mcp_tool_name,
178
+ description=schema["description"],
179
+ inputs=schema["parameters"],
180
+ function=tool_function
181
+ )
182
+
183
+ # Automatically build HF tools from all registered MCP server tools
184
+ tools = [build_hf_tool(tool_name) for tool_name in server.list_tools()]
185
+
186
+ # System prompt to define the agent's role and constraints
187
+ SYSTEM_PROMPT = textwrap.dedent("""
188
+ You are an expert technical editor and programmer.
189
+ Your task is to assist the user by performing file operations.
190
+ You have access to a set of tools for listing, searching, reading, and modifying files.
191
+ - All file paths are relative to the '/workspace' directory.
192
+ - Always verify file contents with `read_lines` or `search_in_file` before attempting to modify a file with `patch_file`.
193
+ - When you are done, provide a summary of the actions you have taken.
194
+ """)
195
+
196
+ # Initialize the agent with the LLM, tools, and a system prompt.
197
+ # memory=True enables conversational history.
198
+ agent = Agent(
199
+ llm_pipeline=llm_pipeline,
200
+ tools=tools,
201
+ system_prompt=SYSTEM_PROMPT,
202
+ max_steps=10, # Increased max steps for more complex tasks
203
+ memory=True
204
+ )
205
+
206
+ # --- 4. Interactive Gradio Chat Application ---
207
+
208
+ async def chat_fn(history: list, user_message: str):
209
+ """
210
+ Handles the chat interaction, streaming the agent's response back to the UI.
211
+ """
212
+ history.append((user_message, None))
213
+
214
+ # Use astream for real-time streaming of thoughts and actions
215
+ async for step_output in agent.astream(user_message):
216
+ # The final output is a string, intermediate steps are tool calls/thoughts
217
+ if isinstance(step_output, str):
218
+ history[-1] = (user_message, step_output)
219
+ yield history
220
+
221
+ return history
222
+
223
+ with gr.Blocks(theme=gr.themes.Soft(), css="footer {display: none !important}") as demo:
224
+ gr.Markdown("# Local Text-Editing Agent 📝")
225
+ gr.Markdown(
226
+ """
227
+ Chat with this AI agent to perform complex edits on text documents in the workspace.
228
+ **Example:** "List the files. Then, open `sample.txt`, summarize the second paragraph, and correct any passive-voice sentences you find."
229
+ """
230
+ )
231
+
232
+ chatbot = gr.Chatbot(height=600)
233
+ msg_textbox = gr.Textbox(label="Your Prompt", placeholder="Type your request here...")
234
+
235
+ msg_textbox.submit(chat_fn, [chatbot, msg_textbox], chatbot)
236
+ gr.ClearButton([msg_textbox, chatbot])
237
+
238
+ # Add a sample file to the workspace for easy testing
239
+ with open(ROOT / "sample.txt", "w") as f:
240
+ f.write(textwrap.dedent("""
241
+ This is the first paragraph. It contains some basic information.
242
+
243
+ The second paragraph is where the interesting details are located. A decision was made by the team to proceed. This text will be reviewed by the agent for clarity and conciseness.
244
+
245
+ The final paragraph concludes the document.
246
+ """))
247
+
248
+ # .queue() is essential for handling multiple users and streaming
249
+ # share=True creates a public link for easy sharing from Colab or locally.
250
+ demo.queue().launch(share=True)