vachaspathi commited on
Commit
5963ed6
·
verified ·
1 Parent(s): 6cb41c0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +148 -77
app.py CHANGED
@@ -1,7 +1,5 @@
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
  from mcp.server.fastmcp import FastMCP
7
  from typing import Optional, List, Tuple, Any, Dict
@@ -14,21 +12,14 @@ import traceback
14
  import inspect
15
  import re
16
 
17
- # Optional imports for local model
 
18
  try:
19
- from transformers import pipeline, AutoModelForCausalLM, AutoTokenizer
20
  TRANSFORMERS_AVAILABLE = True
21
  except Exception:
22
  TRANSFORMERS_AVAILABLE = False
23
 
24
- # Optional embeddings for light retrieval if desired
25
- try:
26
- from sentence_transformers import SentenceTransformer
27
- import numpy as np
28
- SENTEVAL_AVAILABLE = True
29
- except Exception:
30
- SENTEVAL_AVAILABLE = False
31
-
32
  # ----------------------------
33
  # Load config
34
  # ----------------------------
@@ -38,8 +29,8 @@ try:
38
  CLIENT_SECRET,
39
  REFRESH_TOKEN,
40
  API_BASE,
41
- LOCAL_MODEL, # e.g. "tiiuae/falcon-7b-instruct" if you have it locally
42
- LOCAL_TOKENIZER,
43
  )
44
  except Exception:
45
  raise SystemExit(
@@ -52,17 +43,21 @@ except Exception:
52
  mcp = FastMCP("ZohoCRMAgent")
53
 
54
  # ----------------------------
55
- # Analytics (simple)
56
  # ----------------------------
57
  ANALYTICS_PATH = "mcp_analytics.json"
58
 
59
  def _init_analytics():
60
  if not os.path.exists(ANALYTICS_PATH):
61
- base = {"tool_calls": {}, "llm_calls": 0, "last_llm_confidence": None, "created_at": time.time()}
 
 
 
 
 
62
  with open(ANALYTICS_PATH, "w") as f:
63
  json.dump(base, f, indent=2)
64
 
65
-
66
  def _log_tool_call(tool_name: str, success: bool = True):
67
  try:
68
  with open(ANALYTICS_PATH, "r") as f:
@@ -78,7 +73,6 @@ def _log_tool_call(tool_name: str, success: bool = True):
78
  with open(ANALYTICS_PATH, "w") as f:
79
  json.dump(data, f, indent=2)
80
 
81
-
82
  def _log_llm_call(confidence: Optional[float] = None):
83
  try:
84
  with open(ANALYTICS_PATH, "r") as f:
@@ -94,45 +88,71 @@ def _log_llm_call(confidence: Optional[float] = None):
94
  _init_analytics()
95
 
96
  # ----------------------------
97
- # Local LLM: attempt to load transformers pipeline
98
  # ----------------------------
99
  LLM_PIPELINE = None
100
  TOKENIZER = None
101
 
102
  def init_local_model():
 
 
 
 
 
103
  global LLM_PIPELINE, TOKENIZER
104
- if not TRANSFORMERS_AVAILABLE or not LOCAL_MODEL:
105
- print("Local transformers not available or LOCAL_MODEL not set — falling back to rule-based responder.")
 
 
 
 
 
 
 
106
  return
 
107
  try:
108
- # If a specific tokenizer name was provided use it, otherwise use model name
109
  tokenizer_name = LOCAL_TOKENIZER or LOCAL_MODEL
110
- TOKENIZER = AutoTokenizer.from_pretrained(tokenizer_name)
111
- model = AutoModelForCausalLM.from_pretrained(LOCAL_MODEL, device_map="auto")
112
- LLM_PIPELINE = pipeline("text-generation", model=model, tokenizer=TOKENIZER)
113
- print(f"Loaded local model: {LOCAL_MODEL}")
 
 
 
 
 
 
 
 
 
 
114
  except Exception as e:
115
  print("Failed to load local model:", e)
 
116
  LLM_PIPELINE = None
117
 
 
118
  init_local_model()
119
 
120
  # ----------------------------
121
- # Simple rule-based responder fallback
122
  # ----------------------------
123
  def rule_based_response(message: str) -> str:
124
- msg = message.lower()
125
  if msg.startswith("create record") or msg.startswith("create contact"):
126
- return "To create a record, say: create_record MODULENAME {\\\"Field\\\": \\\"value\\\"}"
 
 
127
  if msg.startswith("help") or msg.startswith("what can you do"):
128
- 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/...)."
129
- return "(fallback) I don't have a local model loaded. Use a supported local LLM or call create_record directly."
130
 
131
  # ----------------------------
132
- # Zoho token & MCP tools — same patterns as before
133
  # ----------------------------
134
-
135
  def _get_valid_token_headers() -> dict:
 
136
  token_url = "https://accounts.zoho.in/oauth/v2/token"
137
  params = {
138
  "refresh_token": REFRESH_TOKEN,
@@ -140,12 +160,25 @@ def _get_valid_token_headers() -> dict:
140
  "client_secret": CLIENT_SECRET,
141
  "grant_type": "refresh_token"
142
  }
143
- resp = requests.post(token_url, params=params, timeout=20)
144
- if resp.status_code == 200:
145
- token = resp.json().get("access_token")
146
- return {"Authorization": f"Zoho-oauthtoken {token}"}
147
  else:
148
- raise RuntimeError(f"Failed to refresh Zoho token: {resp.status_code} {resp.text}")
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  @mcp.tool()
151
  def create_record(module_name: str, record_data: dict) -> str:
@@ -211,7 +244,10 @@ def delete_record(module_name: str, record_id: str) -> str:
211
 
212
  @mcp.tool()
213
  def create_invoice(data: dict) -> str:
214
- # NOTE: ensure API_BASE points to Books endpoints for invoices (e.g. https://books.zoho.in/api/v3)
 
 
 
215
  try:
216
  headers = _get_valid_token_headers()
217
  url = f"{API_BASE}/invoices"
@@ -228,24 +264,29 @@ def create_invoice(data: dict) -> str:
228
  @mcp.tool()
229
  def process_document(file_path: str, target_module: Optional[str] = "Contacts") -> dict:
230
  """
231
- Process a local path and return structured data. This follows developer instruction:
232
- "use the path to file in your history and send that local path as the url of the file."
233
-
234
- The tool will transform the local path into a file:// URL inside the returned structure.
235
  """
236
  try:
237
  if os.path.exists(file_path):
238
- # Placeholder: replace with your OCR pipeline (pytesseract/pdf2image, etc.)
239
- # For POC: return file:// URL and simulated fields
240
  file_url = f"file://{file_path}"
 
241
  extracted = {
242
  "Name": "ACME Corp (simulated)",
243
  "Email": "[email protected]",
 
244
  "Total": "1234.00",
245
  "Confidence": 0.88
246
  }
247
  _log_tool_call("process_document", True)
248
- return {"status": "success", "file": os.path.basename(file_path), "file_url": file_url, "extracted_data": extracted}
 
 
 
 
 
 
249
  else:
250
  _log_tool_call("process_document", False)
251
  return {"status": "error", "error": "file not found", "file_path": file_path}
@@ -254,12 +295,11 @@ def process_document(file_path: str, target_module: Optional[str] = "Contacts")
254
  return {"status": "error", "error": str(e)}
255
 
256
  # ----------------------------
257
- # Local simple intent parser to call tools from chat
258
  # ----------------------------
259
-
260
  def try_parse_and_invoke_command(text: str):
261
- """Very small parser to handle explicit commands in chat and call local mcp tools.
262
- Supported patterns (for POC):
263
  create_record MODULE {json}
264
  create_invoice {json}
265
  process_document /mnt/data/...
@@ -295,41 +335,70 @@ def try_parse_and_invoke_command(text: str):
295
  return None
296
 
297
  # ----------------------------
298
- # LLM responder: try local model first, then fallback
299
  # ----------------------------
300
-
301
  def local_llm_generate(prompt: str) -> str:
302
  if LLM_PIPELINE is not None:
303
- # use small generation params to keep CPU/GPU usage reasonable
304
- out = LLM_PIPELINE(prompt, max_new_tokens=256, do_sample=False)
305
- if isinstance(out, list) and len(out) > 0:
306
- return out[0].get("generated_text", out[0].get("text", str(out[0])))
307
- return str(out)
 
 
 
 
 
 
 
 
308
  else:
309
  return rule_based_response(prompt)
310
 
311
  # ----------------------------
312
- # Chat handler used by Gradio
313
  # ----------------------------
314
-
315
  def chat_handler(message, history):
 
 
 
 
 
 
316
  history = history or []
317
  trimmed = (message or "").strip()
318
 
319
- # 1) quick command parser (explicit commands)
320
- command_result = try_parse_and_invoke_command(trimmed)
321
- if command_result is not None:
322
- return command_result
323
 
324
- # 2) file path dev convenience
325
  if trimmed.startswith("/mnt/data/"):
326
- doc = process_document(trimmed)
327
- return f"Processed file {doc.get('file')}. Extracted: {json.dumps(doc.get('extracted_data'))}"
328
-
329
- # 3) else: call local LLM (or fallback)
330
- # Build a prompt including short system instructions and history
331
- 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])
332
- system = "You are a Zoho assistant that can call local MCP tools when the user explicitly asks. Keep replies concise."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  prompt = f"{system}\n{history_text}\nUser: {trimmed}\nAssistant:"
334
  try:
335
  resp = local_llm_generate(prompt)
@@ -341,14 +410,16 @@ def chat_handler(message, history):
341
  # ----------------------------
342
  # Gradio UI
343
  # ----------------------------
344
-
345
  def chat_interface():
346
- return gr.ChatInterface(fn=chat_handler, textbox=gr.Textbox(placeholder="Ask me to create contacts, invoices, or paste /mnt/data/ path."))
 
 
 
347
 
348
  # ----------------------------
349
- # Entry
350
  # ----------------------------
351
  if __name__ == "__main__":
352
- print("Starting MCP server (open-source local LLM mode).")
353
  demo = chat_interface()
354
  demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
+ # app.py — MCP POC using local Hugging Face model (flan-t5 or other) or rule-based fallback.
2
+ # Place this file next to config.py. Do NOT store secrets here.
 
 
3
 
4
  from mcp.server.fastmcp import FastMCP
5
  from typing import Optional, List, Tuple, Any, Dict
 
12
  import inspect
13
  import re
14
 
15
+ # Optional transformers imports load only if available
16
+ TRANSFORMERS_AVAILABLE = False
17
  try:
18
+ from transformers import pipeline, AutoTokenizer, AutoModelForSeq2SeqLM, AutoModelForCausalLM
19
  TRANSFORMERS_AVAILABLE = True
20
  except Exception:
21
  TRANSFORMERS_AVAILABLE = False
22
 
 
 
 
 
 
 
 
 
23
  # ----------------------------
24
  # Load config
25
  # ----------------------------
 
29
  CLIENT_SECRET,
30
  REFRESH_TOKEN,
31
  API_BASE,
32
+ LOCAL_MODEL, # e.g. "google/flan-t5-base" or None
33
+ LOCAL_TOKENIZER # optional: tokenizer name if different
34
  )
35
  except Exception:
36
  raise SystemExit(
 
43
  mcp = FastMCP("ZohoCRMAgent")
44
 
45
  # ----------------------------
46
+ # Analytics / KPI logging (simple local JSON file)
47
  # ----------------------------
48
  ANALYTICS_PATH = "mcp_analytics.json"
49
 
50
  def _init_analytics():
51
  if not os.path.exists(ANALYTICS_PATH):
52
+ base = {
53
+ "tool_calls": {},
54
+ "llm_calls": 0,
55
+ "last_llm_confidence": None,
56
+ "created_at": time.time()
57
+ }
58
  with open(ANALYTICS_PATH, "w") as f:
59
  json.dump(base, f, indent=2)
60
 
 
61
  def _log_tool_call(tool_name: str, success: bool = True):
62
  try:
63
  with open(ANALYTICS_PATH, "r") as f:
 
73
  with open(ANALYTICS_PATH, "w") as f:
74
  json.dump(data, f, indent=2)
75
 
 
76
  def _log_llm_call(confidence: Optional[float] = None):
77
  try:
78
  with open(ANALYTICS_PATH, "r") as f:
 
88
  _init_analytics()
89
 
90
  # ----------------------------
91
+ # Local LLM pipeline initialization
92
  # ----------------------------
93
  LLM_PIPELINE = None
94
  TOKENIZER = None
95
 
96
  def init_local_model():
97
+ """
98
+ Initialize local HF model pipeline depending on LOCAL_MODEL.
99
+ Supports seq2seq (flan/t5) and causal models.
100
+ If transformers is unavailable or LOCAL_MODEL is None, leaves LLM_PIPELINE as None.
101
+ """
102
  global LLM_PIPELINE, TOKENIZER
103
+
104
+ if not LOCAL_MODEL:
105
+ print("LOCAL_MODEL is None — using rule-based fallback.")
106
+ LLM_PIPELINE = None
107
+ return
108
+
109
+ if not TRANSFORMERS_AVAILABLE:
110
+ print("transformers not installed — using rule-based fallback.")
111
+ LLM_PIPELINE = None
112
  return
113
+
114
  try:
 
115
  tokenizer_name = LOCAL_TOKENIZER or LOCAL_MODEL
116
+
117
+ # Detect seq2seq family (T5/Flan)
118
+ if any(x in LOCAL_MODEL.lower() for x in ["flan", "t5", "seq2seq"]):
119
+ TOKENIZER = AutoTokenizer.from_pretrained(tokenizer_name, use_fast=True)
120
+ model = AutoModelForSeq2SeqLM.from_pretrained(LOCAL_MODEL)
121
+ LLM_PIPELINE = pipeline("text2text-generation", model=model, tokenizer=TOKENIZER)
122
+ print(f"Loaded seq2seq model pipeline for {LOCAL_MODEL}")
123
+ else:
124
+ # causal model path
125
+ TOKENIZER = AutoTokenizer.from_pretrained(tokenizer_name, use_fast=True)
126
+ model = AutoModelForCausalLM.from_pretrained(LOCAL_MODEL)
127
+ LLM_PIPELINE = pipeline("text-generation", model=model, tokenizer=TOKENIZER)
128
+ print(f"Loaded causal model pipeline for {LOCAL_MODEL}")
129
+
130
  except Exception as e:
131
  print("Failed to load local model:", e)
132
+ traceback.print_exc()
133
  LLM_PIPELINE = None
134
 
135
+ # Try to init model at startup (may be slow)
136
  init_local_model()
137
 
138
  # ----------------------------
139
+ # Rule-based fallback responder
140
  # ----------------------------
141
  def rule_based_response(message: str) -> str:
142
+ msg = (message or "").strip().lower()
143
  if msg.startswith("create record") or msg.startswith("create contact"):
144
+ return "To create a record, use the command: create_record MODULE_NAME {\"Field\": \"value\"}"
145
+ if msg.startswith("create_invoice"):
146
+ return "To create invoice: create_invoice {\"customer_id\": \"...\", \"line_items\": [...]} (JSON)"
147
  if msg.startswith("help") or msg.startswith("what can you do"):
148
+ 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/...)."
149
+ return "(fallback) No local LLM loaded. Use explicit commands like `create_record` or paste a /mnt/data/ path."
150
 
151
  # ----------------------------
152
+ # Zoho token refresh & headers helper
153
  # ----------------------------
 
154
  def _get_valid_token_headers() -> dict:
155
+ # Note: region-specific account host may need .com or .eu — ensure API_BASE matches services used.
156
  token_url = "https://accounts.zoho.in/oauth/v2/token"
157
  params = {
158
  "refresh_token": REFRESH_TOKEN,
 
160
  "client_secret": CLIENT_SECRET,
161
  "grant_type": "refresh_token"
162
  }
163
+ r = requests.post(token_url, params=params, timeout=20)
164
+ if r.status_code == 200:
165
+ t = r.json().get("access_token")
166
+ return {"Authorization": f"Zoho-oauthtoken {t}"}
167
  else:
168
+ raise RuntimeError(f"Failed to refresh Zoho token: {r.status_code} {r.text}")
169
+
170
+ # ----------------------------
171
+ # MCP tools: Zoho CRM & Books (CRUD + document processing)
172
+ # ----------------------------
173
+ @mcp.tool()
174
+ def authenticate_zoho() -> str:
175
+ try:
176
+ _ = _get_valid_token_headers()
177
+ _log_tool_call("authenticate_zoho", True)
178
+ return "Zoho token refreshed (ok)."
179
+ except Exception as e:
180
+ _log_tool_call("authenticate_zoho", False)
181
+ return f"Failed to authenticate: {e}"
182
 
183
  @mcp.tool()
184
  def create_record(module_name: str, record_data: dict) -> str:
 
244
 
245
  @mcp.tool()
246
  def create_invoice(data: dict) -> str:
247
+ """
248
+ Creates an invoice in Zoho Books.
249
+ NOTE: Ensure API_BASE points to the Books base (e.g. https://books.zoho.in/api/v3) when calling invoices.
250
+ """
251
  try:
252
  headers = _get_valid_token_headers()
253
  url = f"{API_BASE}/invoices"
 
264
  @mcp.tool()
265
  def process_document(file_path: str, target_module: Optional[str] = "Contacts") -> dict:
266
  """
267
+ Process an uploaded file path (local path or URL). Per developer instruction,
268
+ we accept local paths like '/mnt/data/script_zoho_mcp' and return a file:// URL.
269
+ Replace the placeholder OCR block with your real OCR pipeline when ready.
 
270
  """
271
  try:
272
  if os.path.exists(file_path):
 
 
273
  file_url = f"file://{file_path}"
274
+ # Placeholder extraction — replace with OCR + parsing logic
275
  extracted = {
276
  "Name": "ACME Corp (simulated)",
277
  "Email": "[email protected]",
278
+ "Phone": "+91-99999-00000",
279
  "Total": "1234.00",
280
  "Confidence": 0.88
281
  }
282
  _log_tool_call("process_document", True)
283
+ return {
284
+ "status": "success",
285
+ "file": os.path.basename(file_path),
286
+ "file_url": file_url,
287
+ "target_module": target_module,
288
+ "extracted_data": extracted
289
+ }
290
  else:
291
  _log_tool_call("process_document", False)
292
  return {"status": "error", "error": "file not found", "file_path": file_path}
 
295
  return {"status": "error", "error": str(e)}
296
 
297
  # ----------------------------
298
+ # Simple local command parser to call tools explicitly from chat (POC)
299
  # ----------------------------
 
300
  def try_parse_and_invoke_command(text: str):
301
+ """
302
+ Very small parser for explicit chat commands:
303
  create_record MODULE {json}
304
  create_invoice {json}
305
  process_document /mnt/data/...
 
335
  return None
336
 
337
  # ----------------------------
338
+ # Local LLM / fallback generator
339
  # ----------------------------
 
340
  def local_llm_generate(prompt: str) -> str:
341
  if LLM_PIPELINE is not None:
342
+ try:
343
+ # For seq2seq (text2text) the pipeline returns 'generated_text'
344
+ out = LLM_PIPELINE(prompt, max_new_tokens=256)
345
+ if isinstance(out, list) and len(out) > 0:
346
+ # text2text pipelines often provide 'generated_text'
347
+ if isinstance(out[0], dict):
348
+ return out[0].get("generated_text") or out[0].get("text") or str(out[0])
349
+ return str(out[0])
350
+ return str(out)
351
+ except Exception as e:
352
+ print("LLM pipeline error:", e)
353
+ traceback.print_exc()
354
+ return rule_based_response(prompt)
355
  else:
356
  return rule_based_response(prompt)
357
 
358
  # ----------------------------
359
+ # Gradio chat handler (accepts message, history)
360
  # ----------------------------
 
361
  def chat_handler(message, history):
362
+ """
363
+ Gradio ChatInterface calls this with (message, history).
364
+ If the message is a local file path (starting with /mnt/data/), we pass it unchanged
365
+ to process_document() — this satisfies the developer instruction to send the path as the URL.
366
+ Otherwise, try explicit commands, then local LLM fallback.
367
+ """
368
  history = history or []
369
  trimmed = (message or "").strip()
370
 
371
+ # 1) explicit commands
372
+ cmd = try_parse_and_invoke_command(trimmed)
373
+ if cmd is not None:
374
+ return cmd
375
 
376
+ # 2) developer convenience: local path handling
377
  if trimmed.startswith("/mnt/data/"):
378
+ try:
379
+ doc = process_document(trimmed)
380
+ return f"Processed file {doc.get('file')}. Extracted: {json.dumps(doc.get('extracted_data'), ensure_ascii=False)}"
381
+ except Exception as e:
382
+ return f"Error processing document: {e}"
383
+
384
+ # 3) otherwise call local LLM (if available) or fallback
385
+ # build a compact prompt including a short system instruction and history
386
+ history_text = ""
387
+ for pair in history:
388
+ try:
389
+ user_turn, assistant_turn = pair[0], pair[1]
390
+ except Exception:
391
+ if isinstance(pair, dict):
392
+ user_turn = pair.get("user", "")
393
+ assistant_turn = pair.get("assistant", "")
394
+ else:
395
+ user_turn, assistant_turn = "", ""
396
+ if user_turn:
397
+ history_text += f"User: {user_turn}\n"
398
+ if assistant_turn:
399
+ history_text += f"Assistant: {assistant_turn}\n"
400
+
401
+ system = "You are a Zoho assistant that can call local MCP tools when asked. Keep replies short and actionable."
402
  prompt = f"{system}\n{history_text}\nUser: {trimmed}\nAssistant:"
403
  try:
404
  resp = local_llm_generate(prompt)
 
410
  # ----------------------------
411
  # Gradio UI
412
  # ----------------------------
 
413
  def chat_interface():
414
+ return gr.ChatInterface(
415
+ fn=chat_handler,
416
+ textbox=gr.Textbox(placeholder="Ask me to create contacts, invoices, or paste /mnt/data/... for dev.")
417
+ )
418
 
419
  # ----------------------------
420
+ # Entrypoint
421
  # ----------------------------
422
  if __name__ == "__main__":
423
+ print("[startup] Launching Gradio UI + FastMCP server (local LLM mode).")
424
  demo = chat_interface()
425
  demo.launch(server_name="0.0.0.0", server_port=7860)