vachaspathi commited on
Commit
e142418
·
verified ·
1 Parent(s): fe5faa4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +311 -46
app.py CHANGED
@@ -1,9 +1,9 @@
 
1
  # app.py — MCP server using an open-source local LLM (transformers) or a rule-based fallback
2
  # - Uses FastMCP for tools
3
  # - Gradio ChatInterface for UI
4
  # - process_document accepts local path and transforms it to a file:// URL in the tool call
5
 
6
-
7
  from mcp.server.fastmcp import FastMCP
8
  from typing import Optional, List, Tuple, Any, Dict
9
  import requests
@@ -15,77 +15,342 @@ import traceback
15
  import inspect
16
  import re
17
 
18
-
19
  # Optional imports for local model
20
  try:
21
- from transformers import pipeline, AutoModelForCausalLM, AutoTokenizer
22
- TRANSFORMERS_AVAILABLE = True
23
  except Exception:
24
- TRANSFORMERS_AVAILABLE = False
25
-
26
 
27
  # Optional embeddings for light retrieval if desired
28
  try:
29
- from sentence_transformers import SentenceTransformer
30
- import numpy as np
31
- SENTEVAL_AVAILABLE = True
32
  except Exception:
33
- SENTEVAL_AVAILABLE = False
34
-
35
 
36
  # ----------------------------
37
  # Load config
38
  # ----------------------------
39
  try:
40
- from config import (
41
- CLIENT_ID,
42
- CLIENT_SECRET,
43
- REFRESH_TOKEN,
44
- API_BASE,
45
- LOCAL_MODEL, # e.g. "tiiuae/falcon-7b-instruct" if you have it locally
46
- LOCAL_TOKENIZER,
47
- )
48
  except Exception:
49
- raise SystemExit(
50
- "Make sure config.py exists with CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, API_BASE, LOCAL_MODEL (or leave LOCAL_MODEL=None)."
51
- )
52
-
53
 
54
  # ----------------------------
55
  # Initialize FastMCP
56
  # ----------------------------
57
  mcp = FastMCP("ZohoCRMAgent")
58
 
59
-
60
  # ----------------------------
61
  # Analytics (simple)
62
  # ----------------------------
63
  ANALYTICS_PATH = "mcp_analytics.json"
64
 
65
-
66
  def _init_analytics():
67
- if not os.path.exists(ANALYTICS_PATH):
68
- base = {"tool_calls": {}, "llm_calls": 0, "last_llm_confidence": None, "created_at": time.time()}
69
- with open(ANALYTICS_PATH, "w") as f:
70
- json.dump(base, f, indent=2)
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
 
75
- def _log_tool_call(tool_name: str, success: bool = True):
76
- try:
77
- with open(ANALYTICS_PATH, "r") as f:
78
- data = json.load(f)
79
- except Exception:
80
- data = {"tool_calls": {}, "llm_calls": 0, "last_llm_confidence": None}
81
- data["tool_calls"].setdefault(tool_name, {"count": 0, "success": 0, "fail": 0})
82
- data["tool_calls"][tool_name]["count"] += 1
83
- if success:
84
- data["tool_calls"][tool_name]["success"] += 1
85
- else:
86
- data["tool_calls"][tool_name]["fail"] += 1
87
- with open(ANALYTICS_PATH, "w") as f:
88
- json.dump(data, f, indent=2)
89
-
90
-
91
- # --------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ```python
2
  # app.py — MCP server using an open-source local LLM (transformers) or a rule-based fallback
3
  # - Uses FastMCP for tools
4
  # - Gradio ChatInterface for UI
5
  # - process_document accepts local path and transforms it to a file:// URL in the tool call
6
 
 
7
  from mcp.server.fastmcp import FastMCP
8
  from typing import Optional, List, Tuple, Any, Dict
9
  import requests
 
15
  import inspect
16
  import re
17
 
 
18
  # Optional imports for local model
19
  try:
20
+ from transformers import pipeline, AutoModelForCausalLM, AutoTokenizer
21
+ TRANSFORMERS_AVAILABLE = True
22
  except Exception:
23
+ TRANSFORMERS_AVAILABLE = False
 
24
 
25
  # Optional embeddings for light retrieval if desired
26
  try:
27
+ from sentence_transformers import SentenceTransformer
28
+ import numpy as np
29
+ SENTEVAL_AVAILABLE = True
30
  except Exception:
31
+ SENTEVAL_AVAILABLE = False
 
32
 
33
  # ----------------------------
34
  # Load config
35
  # ----------------------------
36
  try:
37
+ from config import (
38
+ CLIENT_ID,
39
+ CLIENT_SECRET,
40
+ REFRESH_TOKEN,
41
+ API_BASE,
42
+ LOCAL_MODEL, # e.g. "tiiuae/falcon-7b-instruct" if you have it locally
43
+ LOCAL_TOKENIZER,
44
+ )
45
  except Exception:
46
+ raise SystemExit(
47
+ "Make sure config.py exists with CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, API_BASE, LOCAL_MODEL (or leave LOCAL_MODEL=None)."
48
+ )
 
49
 
50
  # ----------------------------
51
  # Initialize FastMCP
52
  # ----------------------------
53
  mcp = FastMCP("ZohoCRMAgent")
54
 
 
55
  # ----------------------------
56
  # Analytics (simple)
57
  # ----------------------------
58
  ANALYTICS_PATH = "mcp_analytics.json"
59
 
 
60
  def _init_analytics():
61
+ if not os.path.exists(ANALYTICS_PATH):
62
+ base = {"tool_calls": {}, "llm_calls": 0, "last_llm_confidence": None, "created_at": time.time()}
63
+ with open(ANALYTICS_PATH, "w") as f:
64
+ json.dump(base, f, indent=2)
65
 
66
 
67
+ def _log_tool_call(tool_name: str, success: bool = True):
68
+ try:
69
+ with open(ANALYTICS_PATH, "r") as f:
70
+ data = json.load(f)
71
+ except Exception:
72
+ data = {"tool_calls": {}, "llm_calls": 0, "last_llm_confidence": None}
73
+ data["tool_calls"].setdefault(tool_name, {"count": 0, "success": 0, "fail": 0})
74
+ data["tool_calls"][tool_name]["count"] += 1
75
+ if success:
76
+ data["tool_calls"][tool_name]["success"] += 1
77
+ else:
78
+ data["tool_calls"][tool_name]["fail"] += 1
79
+ with open(ANALYTICS_PATH, "w") as f:
80
+ json.dump(data, f, indent=2)
81
 
82
 
83
+ def _log_llm_call(confidence: Optional[float] = None):
84
+ try:
85
+ with open(ANALYTICS_PATH, "r") as f:
86
+ data = json.load(f)
87
+ except Exception:
88
+ data = {"tool_calls": {}, "llm_calls": 0, "last_llm_confidence": None}
89
+ data["llm_calls"] = data.get("llm_calls", 0) + 1
90
+ if confidence is not None:
91
+ data["last_llm_confidence"] = confidence
92
+ with open(ANALYTICS_PATH, "w") as f:
93
+ json.dump(data, f, indent=2)
94
+
95
+ _init_analytics()
96
+
97
+ # ----------------------------
98
+ # Local LLM: attempt to load transformers pipeline
99
+ # ----------------------------
100
+ LLM_PIPELINE = None
101
+ TOKENIZER = None
102
+
103
+ def init_local_model():
104
+ global LLM_PIPELINE, TOKENIZER
105
+ if not TRANSFORMERS_AVAILABLE or not LOCAL_MODEL:
106
+ print("Local transformers not available or LOCAL_MODEL not set — falling back to rule-based responder.")
107
+ return
108
+ try:
109
+ # If a specific tokenizer name was provided use it, otherwise use model name
110
+ tokenizer_name = LOCAL_TOKENIZER or LOCAL_MODEL
111
+ TOKENIZER = AutoTokenizer.from_pretrained(tokenizer_name)
112
+ model = AutoModelForCausalLM.from_pretrained(LOCAL_MODEL, device_map="auto")
113
+ LLM_PIPELINE = pipeline("text-generation", model=model, tokenizer=TOKENIZER)
114
+ print(f"Loaded local model: {LOCAL_MODEL}")
115
+ except Exception as e:
116
+ print("Failed to load local model:", e)
117
+ LLM_PIPELINE = None
118
+
119
+ init_local_model()
120
+
121
+ # ----------------------------
122
+ # Simple rule-based responder fallback
123
+ # ----------------------------
124
+ def rule_based_response(message: str) -> str:
125
+ msg = message.lower()
126
+ if msg.startswith("create record") or msg.startswith("create contact"):
127
+ return "To create a record, say: create_record MODULENAME {\\\"Field\\\": \\\"value\\\"}"
128
+ if msg.startswith("help") or msg.startswith("what can you do"):
129
+ return "I can create/update/delete records in Zoho (create_record, update_record, delete_record), or process local files by pasting their path (/mnt/data/...)."
130
+ return "(fallback) I don't have a local model loaded. Use a supported local LLM or call create_record directly."
131
+
132
+ # ----------------------------
133
+ # Zoho token & MCP tools — same patterns as before
134
+ # ----------------------------
135
+
136
+ def _get_valid_token_headers() -> dict:
137
+ token_url = "https://accounts.zoho.in/oauth/v2/token"
138
+ params = {
139
+ "refresh_token": REFRESH_TOKEN,
140
+ "client_id": CLIENT_ID,
141
+ "client_secret": CLIENT_SECRET,
142
+ "grant_type": "refresh_token"
143
+ }
144
+ resp = requests.post(token_url, params=params, timeout=20)
145
+ if resp.status_code == 200:
146
+ token = resp.json().get("access_token")
147
+ return {"Authorization": f"Zoho-oauthtoken {token}"}
148
+ else:
149
+ raise RuntimeError(f"Failed to refresh Zoho token: {resp.status_code} {resp.text}")
150
+
151
+ @mcp.tool()
152
+ def create_record(module_name: str, record_data: dict) -> str:
153
+ try:
154
+ headers = _get_valid_token_headers()
155
+ url = f"{API_BASE}/{module_name}"
156
+ payload = {"data": [record_data]}
157
+ r = requests.post(url, headers=headers, json=payload, timeout=20)
158
+ if r.status_code in (200, 201):
159
+ _log_tool_call("create_record", True)
160
+ return json.dumps(r.json(), ensure_ascii=False)
161
+ _log_tool_call("create_record", False)
162
+ return f"Error creating record: {r.status_code} {r.text}"
163
+ except Exception as e:
164
+ _log_tool_call("create_record", False)
165
+ return f"Exception: {e}"
166
+
167
+ @mcp.tool()
168
+ def get_records(module_name: str, page: int = 1, per_page: int = 200) -> list:
169
+ try:
170
+ headers = _get_valid_token_headers()
171
+ url = f"{API_BASE}/{module_name}"
172
+ r = requests.get(url, headers=headers, params={"page": page, "per_page": per_page}, timeout=20)
173
+ if r.status_code == 200:
174
+ _log_tool_call("get_records", True)
175
+ return r.json().get("data", [])
176
+ _log_tool_call("get_records", False)
177
+ return [f"Error retrieving {module_name}: {r.status_code} {r.text}"]
178
+ except Exception as e:
179
+ _log_tool_call("get_records", False)
180
+ return [f"Exception: {e}"]
181
+
182
+ @mcp.tool()
183
+ def update_record(module_name: str, record_id: str, data: dict) -> str:
184
+ try:
185
+ headers = _get_valid_token_headers()
186
+ url = f"{API_BASE}/{module_name}/{record_id}"
187
+ payload = {"data": [data]}
188
+ r = requests.put(url, headers=headers, json=payload, timeout=20)
189
+ if r.status_code == 200:
190
+ _log_tool_call("update_record", True)
191
+ return json.dumps(r.json(), ensure_ascii=False)
192
+ _log_tool_call("update_record", False)
193
+ return f"Error updating: {r.status_code} {r.text}"
194
+ except Exception as e:
195
+ _log_tool_call("update_record", False)
196
+ return f"Exception: {e}"
197
+
198
+ @mcp.tool()
199
+ def delete_record(module_name: str, record_id: str) -> str:
200
+ try:
201
+ headers = _get_valid_token_headers()
202
+ url = f"{API_BASE}/{module_name}/{record_id}"
203
+ r = requests.delete(url, headers=headers, timeout=20)
204
+ if r.status_code == 200:
205
+ _log_tool_call("delete_record", True)
206
+ return json.dumps(r.json(), ensure_ascii=False)
207
+ _log_tool_call("delete_record", False)
208
+ return f"Error deleting: {r.status_code} {r.text}"
209
+ except Exception as e:
210
+ _log_tool_call("delete_record", False)
211
+ return f"Exception: {e}"
212
+
213
+ @mcp.tool()
214
+ def create_invoice(data: dict) -> str:
215
+ # NOTE: ensure API_BASE points to Books endpoints for invoices (e.g. https://books.zoho.in/api/v3)
216
+ try:
217
+ headers = _get_valid_token_headers()
218
+ url = f"{API_BASE}/invoices"
219
+ r = requests.post(url, headers=headers, json={"data": [data]}, timeout=20)
220
+ if r.status_code in (200, 201):
221
+ _log_tool_call("create_invoice", True)
222
+ return json.dumps(r.json(), ensure_ascii=False)
223
+ _log_tool_call("create_invoice", False)
224
+ return f"Error creating invoice: {r.status_code} {r.text}"
225
+ except Exception as e:
226
+ _log_tool_call("create_invoice", False)
227
+ return f"Exception: {e}"
228
+
229
+ @mcp.tool()
230
+ def process_document(file_path: str, target_module: Optional[str] = "Contacts") -> dict:
231
+ """
232
+ Process a local path and return structured data. This follows developer instruction:
233
+ "use the path to file in your history and send that local path as the url of the file."
234
+
235
+ The tool will transform the local path into a file:// URL inside the returned structure.
236
+ """
237
+ try:
238
+ if os.path.exists(file_path):
239
+ # Placeholder: replace with your OCR pipeline (pytesseract/pdf2image, etc.)
240
+ # For POC: return file:// URL and simulated fields
241
+ file_url = f"file://{file_path}"
242
+ extracted = {
243
+ "Name": "ACME Corp (simulated)",
244
+ "Email": "[email protected]",
245
+ "Total": "1234.00",
246
+ "Confidence": 0.88
247
+ }
248
+ _log_tool_call("process_document", True)
249
+ return {"status": "success", "file": os.path.basename(file_path), "file_url": file_url, "extracted_data": extracted}
250
+ else:
251
+ _log_tool_call("process_document", False)
252
+ return {"status": "error", "error": "file not found", "file_path": file_path}
253
+ except Exception as e:
254
+ _log_tool_call("process_document", False)
255
+ return {"status": "error", "error": str(e)}
256
+
257
+ # ----------------------------
258
+ # Local simple intent parser to call tools from chat
259
+ # ----------------------------
260
+
261
+ def try_parse_and_invoke_command(text: str):
262
+ """Very small parser to handle explicit commands in chat and call local mcp tools.
263
+ Supported patterns (for POC):
264
+ create_record MODULE {json}
265
+ create_invoice {json}
266
+ process_document /mnt/data/...
267
+ """
268
+ text = text.strip()
269
+ # create_record
270
+ m = re.match(r"^create_record\s+(\w+)\s+(.+)$", text, re.I)
271
+ if m:
272
+ module = m.group(1)
273
+ body = m.group(2)
274
+ try:
275
+ record_data = json.loads(body)
276
+ except Exception:
277
+ return "Invalid JSON for record_data"
278
+ return create_record(module, record_data)
279
+
280
+ # create_invoice
281
+ m = re.match(r"^create_invoice\s+(.+)$", text, re.I)
282
+ if m:
283
+ body = m.group(1)
284
+ try:
285
+ invoice_data = json.loads(body)
286
+ except Exception:
287
+ return "Invalid JSON for invoice_data"
288
+ return create_invoice(invoice_data)
289
+
290
+ # process_document via local path
291
+ m = re.match(r"^(\/mnt\/data\/\S+)$", text)
292
+ if m:
293
+ path = m.group(1)
294
+ return process_document(path)
295
+
296
+ return None
297
+
298
+ # ----------------------------
299
+ # LLM responder: try local model first, then fallback
300
+ # ----------------------------
301
+
302
+ def local_llm_generate(prompt: str) -> str:
303
+ if LLM_PIPELINE is not None:
304
+ # use small generation params to keep CPU/GPU usage reasonable
305
+ out = LLM_PIPELINE(prompt, max_new_tokens=256, do_sample=False)
306
+ if isinstance(out, list) and len(out) > 0:
307
+ return out[0].get("generated_text", out[0].get("text", str(out[0])))
308
+ return str(out)
309
+ else:
310
+ return rule_based_response(prompt)
311
+
312
+ # ----------------------------
313
+ # Chat handler used by Gradio
314
+ # ----------------------------
315
+
316
+ def chat_handler(message, history):
317
+ history = history or []
318
+ trimmed = (message or "").strip()
319
+
320
+ # 1) quick command parser (explicit commands)
321
+ command_result = try_parse_and_invoke_command(trimmed)
322
+ if command_result is not None:
323
+ return command_result
324
+
325
+ # 2) file path dev convenience
326
+ if trimmed.startswith("/mnt/data/"):
327
+ doc = process_document(trimmed)
328
+ return f"Processed file {doc.get('file')}. Extracted: {json.dumps(doc.get('extracted_data'))}"
329
+
330
+ # 3) else: call local LLM (or fallback)
331
+ # Build a prompt including short system instructions and history
332
+ history_text = "\n".join([f"User: {h[0]}\nAssistant: {h[1]}" for h in (history or []) if isinstance(h, (list, tuple)) and len(h) >= 2])
333
+ system = "You are a Zoho assistant that can call local MCP tools when the user explicitly asks. Keep replies concise."
334
+ prompt = f"{system}\n{history_text}\nUser: {trimmed}\nAssistant:"
335
+ try:
336
+ resp = local_llm_generate(prompt)
337
+ _log_llm_call(None)
338
+ return resp
339
+ except Exception as e:
340
+ return f"LLM error: {e}"
341
+
342
+ # ----------------------------
343
+ # Gradio UI
344
+ # ----------------------------
345
+
346
+ def chat_interface():
347
+ return gr.ChatInterface(fn=chat_handler, textbox=gr.Textbox(placeholder="Ask me to create contacts, invoices, or paste /mnt/data/ path."))
348
+
349
+ # ----------------------------
350
+ # Entry
351
+ # ----------------------------
352
+ if __name__ == "__main__":
353
+ print("Starting MCP server (open-source local LLM mode).")
354
+ demo = chat_interface()
355
+ demo.launch(server_name="0.0.0.0", server_port=7860)
356
+ ```