vachaspathi commited on
Commit
bdd3046
·
verified ·
1 Parent(s): 028d1d6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +58 -406
app.py CHANGED
@@ -1,7 +1,8 @@
1
- # app.py — MCP POC updated to use Kimi (Moonshot) tool_calls flow (HTTP-based)
2
- # IMPORTANT:
3
- # - Put keys in config.py (do NOT paste keys in chat).
4
- # - requirements.txt should include: fastmcp, gradio, requests
 
5
 
6
  from mcp.server.fastmcp import FastMCP
7
  from typing import Optional, List, Tuple, Any, Dict
@@ -12,428 +13,79 @@ import json
12
  import time
13
  import traceback
14
  import inspect
15
- import uuid
16
 
17
- # ----------------------------
18
- # Load secrets/config - edit config.py accordingly
19
- # ----------------------------
20
  try:
21
- from config import (
22
- CLIENT_ID,
23
- CLIENT_SECRET,
24
- REFRESH_TOKEN,
25
- API_BASE,
26
- KIMI_API_KEY, # Moonshot Kimi API key (put it in config.py)
27
- KIMI_MODEL # optional; default "moonshot-v1-8k" used if missing
28
- )
29
  except Exception:
30
- raise SystemExit(
31
- "Make sure config.py exists with CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, "
32
- "API_BASE, KIMI_API_KEY. Optionally set KIMI_MODEL."
33
- )
34
 
35
- KIMI_BASE_URL = "https://api.moonshot.ai/v1"
36
- KIMI_MODEL = globals().get("KIMI_MODEL", "moonshot-v1-8k")
37
-
38
- # ----------------------------
39
- # Initialize FastMCP
40
- # ----------------------------
41
- mcp = FastMCP("ZohoCRMAgent")
42
-
43
- # ----------------------------
44
- # Analytics / KPI logging (simple local JSON file)
45
- # ----------------------------
46
- ANALYTICS_PATH = "mcp_analytics.json"
47
 
48
- def _init_analytics():
49
- if not os.path.exists(ANALYTICS_PATH):
50
- base = {
51
- "tool_calls": {},
52
- "llm_calls": 0,
53
- "last_llm_confidence": None,
54
- "created_at": time.time(),
55
- }
56
- with open(ANALYTICS_PATH, "w") as f:
57
- json.dump(base, f, indent=2)
58
-
59
- def _log_tool_call(tool_name: str, success: bool = True):
60
- try:
61
- with open(ANALYTICS_PATH, "r") as f:
62
- data = json.load(f)
63
- except Exception:
64
- data = {"tool_calls": {}, "llm_calls": 0, "last_llm_confidence": None}
65
- data["tool_calls"].setdefault(tool_name, {"count": 0, "success": 0, "fail": 0})
66
- data["tool_calls"][tool_name]["count"] += 1
67
- if success:
68
- data["tool_calls"][tool_name]["success"] += 1
69
- else:
70
- data["tool_calls"][tool_name]["fail"] += 1
71
- with open(ANALYTICS_PATH, "w") as f:
72
- json.dump(data, f, indent=2)
73
-
74
- def _log_llm_call(confidence: Optional[float] = None):
75
- try:
76
- with open(ANALYTICS_PATH, "r") as f:
77
- data = json.load(f)
78
- except Exception:
79
- data = {"tool_calls": {}, "llm_calls": 0, "last_llm_confidence": None}
80
- data["llm_calls"] = data.get("llm_calls", 0) + 1
81
- if confidence is not None:
82
- data["last_llm_confidence"] = confidence
83
- with open(ANALYTICS_PATH, "w") as f:
84
- json.dump(data, f, indent=2)
85
-
86
- _init_analytics()
87
-
88
- # ----------------------------
89
- # Kimi HTTP helpers (calls Moonshot Kimi API)
90
- # ----------------------------
91
- def _kimi_headers():
92
- return {"Authorization": f"Bearer {KIMI_API_KEY}", "Content-Type": "application/json"}
93
 
94
- def _kimi_chat_completion(messages: List[Dict], tools: Optional[List[Dict]] = None, model: str = KIMI_MODEL):
95
- """
96
- Send a single chat/completion request to Kimi. Returns the full parsed JSON response.
97
- """
98
- body = {
99
- "model": model,
100
- "messages": messages
101
- }
102
- # include tools if present (tools should be JSON Schema declarations)
103
- if tools:
104
- body["tools"] = tools
105
- url = f"{KIMI_BASE_URL}/chat/completions"
106
- resp = requests.post(url, headers=_kimi_headers(), json=body, timeout=60)
107
- if resp.status_code not in (200, 201):
108
- raise RuntimeError(f"Kimi API error: {resp.status_code} {resp.text}")
109
- return resp.json()
110
 
111
  # ----------------------------
112
- # Zoho token refresh & headers
113
- # ----------------------------
114
- def _get_valid_token_headers() -> dict:
115
- token_url = "https://accounts.zoho.in/oauth/v2/token"
116
- params = {
117
- "refresh_token": REFRESH_TOKEN,
118
- "client_id": CLIENT_ID,
119
- "client_secret": CLIENT_SECRET,
120
- "grant_type": "refresh_token"
121
- }
122
- r = requests.post(token_url, params=params, timeout=20)
123
- if r.status_code == 200:
124
- t = r.json().get("access_token")
125
- return {"Authorization": f"Zoho-oauthtoken {t}"}
126
- else:
127
- raise RuntimeError(f"Failed to refresh Zoho token: {r.status_code} {r.text}")
128
-
129
  # ----------------------------
130
- # MCP tools: Zoho CRM and Books (CRUD + documents)
131
- # (same as earlier; use these function names as tool names in the Kimi tools definitions)
132
- # ----------------------------
133
- @mcp.tool()
134
- def authenticate_zoho() -> str:
135
- try:
136
- _ = _get_valid_token_headers()
137
- _log_tool_call("authenticate_zoho", True)
138
- return "Zoho token refreshed (ok)."
139
- except Exception as e:
140
- _log_tool_call("authenticate_zoho", False)
141
- return f"Failed to authenticate: {e}"
142
-
143
- @mcp.tool()
144
- def create_record(module_name: str, record_data: dict) -> str:
145
- try:
146
- headers = _get_valid_token_headers()
147
- url = f"{API_BASE}/{module_name}"
148
- payload = {"data": [record_data]}
149
- r = requests.post(url, headers=headers, json=payload, timeout=20)
150
- if r.status_code in (200, 201):
151
- _log_tool_call("create_record", True)
152
- return json.dumps(r.json(), ensure_ascii=False)
153
- else:
154
- _log_tool_call("create_record", False)
155
- return f"Error creating record: {r.status_code} {r.text}"
156
- except Exception as e:
157
- _log_tool_call("create_record", False)
158
- return f"Exception: {e}"
159
-
160
- @mcp.tool()
161
- def get_records(module_name: str, page: int = 1, per_page: int = 200) -> list:
162
- try:
163
- headers = _get_valid_token_headers()
164
- url = f"{API_BASE}/{module_name}"
165
- r = requests.get(url, headers=headers, params={"page": page, "per_page": per_page}, timeout=20)
166
- if r.status_code == 200:
167
- _log_tool_call("get_records", True)
168
- return r.json().get("data", [])
169
- else:
170
- _log_tool_call("get_records", False)
171
- return [f"Error retrieving {module_name}: {r.status_code} {r.text}"]
172
- except Exception as e:
173
- _log_tool_call("get_records", False)
174
- return [f"Exception: {e}"]
175
-
176
- @mcp.tool()
177
- def update_record(module_name: str, record_id: str, data: dict) -> str:
178
- try:
179
- headers = _get_valid_token_headers()
180
- url = f"{API_BASE}/{module_name}/{record_id}"
181
- payload = {"data": [data]}
182
- r = requests.put(url, headers=headers, json=payload, timeout=20)
183
- if r.status_code == 200:
184
- _log_tool_call("update_record", True)
185
- return json.dumps(r.json(), ensure_ascii=False)
186
- else:
187
- _log_tool_call("update_record", False)
188
- return f"Error updating: {r.status_code} {r.text}"
189
- except Exception as e:
190
- _log_tool_call("update_record", False)
191
- return f"Exception: {e}"
192
-
193
- @mcp.tool()
194
- def delete_record(module_name: str, record_id: str) -> str:
195
- try:
196
- headers = _get_valid_token_headers()
197
- url = f"{API_BASE}/{module_name}/{record_id}"
198
- r = requests.delete(url, headers=headers, timeout=20)
199
- if r.status_code == 200:
200
- _log_tool_call("delete_record", True)
201
- return json.dumps(r.json(), ensure_ascii=False)
202
- else:
203
- _log_tool_call("delete_record", False)
204
- return f"Error deleting: {r.status_code} {r.text}"
205
- except Exception as e:
206
- _log_tool_call("delete_record", False)
207
- return f"Exception: {e}"
208
-
209
- @mcp.tool()
210
- def create_invoice(data: dict) -> str:
211
- try:
212
- headers = _get_valid_token_headers()
213
- url = f"{API_BASE}/invoices"
214
- r = requests.post(url, headers=headers, json={"data": [data]}, timeout=20)
215
- if r.status_code in (200, 201):
216
- _log_tool_call("create_invoice", True)
217
- return json.dumps(r.json(), ensure_ascii=False)
218
- else:
219
- _log_tool_call("create_invoice", False)
220
- return f"Error creating invoice: {r.status_code} {r.text}"
221
- except Exception as e:
222
- _log_tool_call("create_invoice", False)
223
- return f"Exception: {e}"
224
 
225
- @mcp.tool()
226
- def process_document(file_path: str, target_module: Optional[str] = "Contacts") -> dict:
227
- try:
228
- extracted = {}
229
- if os.path.exists(file_path):
230
- # For POC: simulated extraction; replace with real OCR and parsing
231
- extracted = {
232
- "Name": "ACME Corp (simulated)",
233
- "Email": "[email protected]",
234
- "Phone": "+91-99999-00000",
235
- "Total": "1234.00",
236
- "Confidence": 0.87,
237
- }
238
- else:
239
- extracted = {"note": "file not found locally; treat as URL in production", "path": file_path}
240
- _log_tool_call("process_document", True)
241
- return {
242
- "status": "success",
243
- "file": os.path.basename(file_path),
244
- "source_path": file_path,
245
- "target_module": target_module,
246
- "extracted_data": extracted,
247
- }
248
- except Exception as e:
249
- _log_tool_call("process_document", False)
250
- return {"status": "error", "error": str(e)}
251
 
252
  # ----------------------------
253
- # Tool map for local execution (used to run tool_calls returned by Kimi)
254
  # ----------------------------
255
- # Keys should match the "name" you place in the tools JSON you send to Kimi
256
- tool_map = {
257
- "authenticate_zoho": authenticate_zoho,
258
- "create_record": create_record,
259
- "get_records": get_records,
260
- "update_record": update_record,
261
- "delete_record": delete_record,
262
- "create_invoice": create_invoice,
263
- "process_document": process_document,
264
- }
265
 
266
- # ----------------------------
267
- # Build the "tools" JSON to send to Kimi (simple schema per doc)
268
- # For the POC, declare only a subset or declare all tools. Each tool is a JSON schema.
269
- # Below is an example declaration for create_record; expand as needed.
270
- # ----------------------------
271
- def build_tool_definitions():
272
- # Example: create simple JSON schema definitions that Kimi can use.
273
- # Keep definitions concise to avoid token blowup.
274
- tools = [
275
- {
276
- "type": "function",
277
- "function": {
278
- "name": "create_record",
279
- "description": "Create a record in a Zoho CRM module. Args: module_name (str), record_data (json).",
280
- "parameters": {
281
- "type": "object",
282
- "properties": {
283
- "module_name": {"type": "string"},
284
- "record_data": {"type": "object"}
285
- },
286
- "required": ["module_name", "record_data"]
287
- }
288
- }
289
- },
290
- {
291
- "type": "function",
292
- "function": {
293
- "name": "process_document",
294
- "description": "Process an uploaded document (local path or URL). Args: file_path, target_module.",
295
- "parameters": {
296
- "type": "object",
297
- "properties": {
298
- "file_path": {"type": "string"},
299
- "target_module": {"type": "string"}
300
- },
301
- "required": ["file_path"]
302
- }
303
- }
304
- },
305
- # Add more tool definitions (get_records, update_record, create_invoice, etc.) similarly if needed
306
- ]
307
- return tools
308
 
309
  # ----------------------------
310
- # Kimi tool_calls orchestration loop (follows Moonshot docs)
311
  # ----------------------------
312
- def kimi_chat_with_tools(user_message: str, history: Optional[List[Dict]] = None):
313
- """
314
- Orchestrates the chat + tool_calls flow with Kimi:
315
- - messages: list of dict {"role": "system"/"user"/"assistant"/"tool", "content": "..." }
316
- - tools: list of JSON schema tool definitions (from build_tool_definitions)
317
- The loop:
318
- 1. call Kimi with messages+tools
319
- 2. if Kimi returns finish_reason == "tool_calls", iterate each tool_call, execute local tool, append role=tool message with tool_call_id and continue
320
- 3. when finish_reason == "stop" or other, return assistant content
321
- """
322
- # Build initial messages list from history (history is list of (user, assistant) tuples)
323
- messages = []
324
- system_prompt = (
325
- "You are Zoho Assistant. Use available tools when needed. "
326
- "When you want to perform an action, return tool_calls. Otherwise, return normal assistant text."
327
- )
328
- messages.append({"role": "system", "content": system_prompt})
329
- history = history or []
330
- for pair in history:
331
- try:
332
- user_turn, assistant_turn = pair[0], pair[1]
333
- except Exception:
334
- if isinstance(pair, dict):
335
- user_turn = pair.get("user", "")
336
- assistant_turn = pair.get("assistant", "")
337
- else:
338
- user_turn, assistant_turn = "", ""
339
- if user_turn:
340
- messages.append({"role": "user", "content": user_turn})
341
- if assistant_turn:
342
- messages.append({"role": "assistant", "content": assistant_turn})
343
-
344
- # Append the new user message
345
- messages.append({"role": "user", "content": user_message})
346
-
347
- # Prepare tool definitions
348
- tools = build_tool_definitions()
349
-
350
- finish_reason = None
351
- assistant_reply_text = None
352
 
353
- # Start loop
354
- while True:
355
- # Call Kimi
356
- resp_json = _kimi_chat_completion(messages, tools=tools, model=KIMI_MODEL)
357
- # According to docs, response structure: choices[0] with finish_reason and message
358
- choice = resp_json.get("choices", [{}])[0]
359
- finish_reason = choice.get("finish_reason")
360
- message = choice.get("message", {})
361
- # If finish_reason == "tool_calls", Kimi has returned tool_calls to execute
362
- if finish_reason == "tool_calls":
363
- # The message may contain 'tool_calls' field which is a list
364
- tool_calls = message.get("tool_calls", []) or []
365
- # Append the assistant message as-is so the next call has proper context
366
- messages.append(message) # message already contains tool_calls per docs
367
- # Execute each tool_call (can be done in parallel, but we'll do sequential for POC)
368
- for tc in tool_calls:
369
- # tc.function.name and tc.function.arguments (arguments serialized JSON string)
370
- func_meta = tc.get("function", {})
371
- tool_name = func_meta.get("name")
372
- raw_args = func_meta.get("arguments", "{}")
373
- try:
374
- parsed_args = json.loads(raw_args)
375
- except Exception:
376
- parsed_args = {}
377
- # Execute the matching local tool function
378
- tool_fn = tool_map.get(tool_name)
379
- if callable(tool_fn):
380
- try:
381
- result = tool_fn(**parsed_args) if isinstance(parsed_args, dict) else tool_fn(parsed_args)
382
- except Exception as e:
383
- result = {"error": str(e)}
384
- else:
385
- result = {"error": f"tool '{tool_name}' not found locally."}
386
 
387
- # Per docs: append a role=tool message with tool_call_id and name so Kimi can match it
388
- tool_message = {
389
- "role": "tool",
390
- "tool_call_id": tc.get("id") or str(uuid.uuid4()),
391
- "name": tool_name,
392
- "content": json.dumps(result, ensure_ascii=False)
393
- }
394
- messages.append(tool_message)
395
- # Continue loop: call Kimi again with appended tool messages
396
- continue
397
- else:
398
- # finish_reason != tool_calls; assistant likely returned a final response
399
- # message.content may be the assistant reply
400
- assistant_reply_text = message.get("content", "")
401
- # Log LLM call (no explicit confidence field in this response shape; leave None)
402
- _log_llm_call(None)
403
- break
404
 
405
- return assistant_reply_text or "(no content)"
406
 
407
- # ----------------------------
408
- # Chat handler + Gradio UI
409
- # ----------------------------
410
- def chat_handler(message, history):
411
- history = history or []
412
- trimmed = (message or "").strip()
413
- DEV_TEST_PREFIX = "/mnt/data/"
414
- if trimmed.startswith(DEV_TEST_PREFIX):
415
- try:
416
- doc = process_document(trimmed)
417
- return f"Processed file {doc.get('file')}. Extracted: {json.dumps(doc.get('extracted_data'), ensure_ascii=False)}"
418
- except Exception as e:
419
- return f"Error processing document: {e}"
420
- # Otherwise call Kimi with tool_calls loop
421
- try:
422
- reply = kimi_chat_with_tools(trimmed, history)
423
- return reply
424
- except Exception as e:
425
- return f"(Kimi error) {e}"
426
 
427
- def chat_interface():
428
- return gr.ChatInterface(
429
- fn=chat_handler,
430
- textbox=gr.Textbox(placeholder="Ask me to create contacts, invoices, upload docs (or paste /mnt/data/... for dev).")
431
- )
432
 
433
- # ----------------------------
434
- # Launch
435
- # ----------------------------
436
- if __name__ == "__main__":
437
- print("[startup] Launching Gradio UI + FastMCP server (Kimi tool_calls integration).")
438
- demo = chat_interface()
439
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
 
 
 
 
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
 
13
  import time
14
  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
+ # --------------