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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +433 -7
app.py CHANGED
@@ -1,13 +1,439 @@
 
 
 
 
 
 
 
1
  import requests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- def refresh_access_token(refresh_token, client_id, client_secret, region="in"):
4
- token_url = f"https://accounts.zoho.{region}/oauth/v2/token"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  params = {
6
- "refresh_token": refresh_token,
7
- "client_id": client_id,
8
- "client_secret": client_secret,
9
  "grant_type": "refresh_token"
10
  }
11
  r = requests.post(token_url, params=params, timeout=20)
12
- r.raise_for_status()
13
- return r.json()["access_token"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
8
  import requests
9
+ import os
10
+ import gradio as gr
11
+ 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)