Spaces:
Runtime error
Runtime error
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# app.py — MCP POC using
|
| 2 |
# Place this file next to config.py. Do NOT store secrets here.
|
| 3 |
|
| 4 |
from mcp.server.fastmcp import FastMCP
|
|
@@ -12,14 +12,6 @@ import traceback
|
|
| 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,13 +21,18 @@ try:
|
|
| 29 |
CLIENT_SECRET,
|
| 30 |
REFRESH_TOKEN,
|
| 31 |
API_BASE,
|
| 32 |
-
|
|
|
|
| 33 |
)
|
| 34 |
except Exception:
|
| 35 |
raise SystemExit(
|
| 36 |
-
"Make sure config.py exists with CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, API_BASE,
|
|
|
|
| 37 |
)
|
| 38 |
|
|
|
|
|
|
|
|
|
|
| 39 |
# ----------------------------
|
| 40 |
# Initialize FastMCP
|
| 41 |
# ----------------------------
|
|
@@ -87,71 +84,79 @@ def _log_llm_call(confidence: Optional[float] = None):
|
|
| 87 |
_init_analytics()
|
| 88 |
|
| 89 |
# ----------------------------
|
| 90 |
-
#
|
| 91 |
# ----------------------------
|
| 92 |
-
|
| 93 |
-
|
| 94 |
|
| 95 |
-
def
|
| 96 |
"""
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
If transformers is unavailable or LOCAL_MODEL is None, leaves LLM_PIPELINE as None.
|
| 100 |
"""
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
| 107 |
|
| 108 |
-
if not
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
return
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
try:
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
if
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
else:
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
except Exception as e:
|
| 130 |
-
print("Failed to load local model:", e)
|
| 131 |
-
traceback.print_exc()
|
| 132 |
-
LLM_PIPELINE = None
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
|
| 137 |
# ----------------------------
|
| 138 |
-
#
|
| 139 |
-
# ----------------------------
|
| 140 |
-
def rule_based_response(message: str) -> str:
|
| 141 |
-
msg = (message or "").strip().lower()
|
| 142 |
-
if msg.startswith("create record") or msg.startswith("create contact"):
|
| 143 |
-
return "To create a record, use the command: create_record MODULE_NAME {\"Field\": \"value\"}"
|
| 144 |
-
if msg.startswith("create_invoice"):
|
| 145 |
-
return "To create invoice: create_invoice {\"customer_id\": \"...\", \"line_items\": [...]} (JSON)"
|
| 146 |
-
if msg.startswith("help") or msg.startswith("what can you do"):
|
| 147 |
-
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/...)."
|
| 148 |
-
return "(fallback) No local LLM loaded. Use explicit commands like `create_record` or paste a /mnt/data/ path."
|
| 149 |
-
|
| 150 |
-
# ----------------------------
|
| 151 |
-
# Zoho token refresh & headers helper
|
| 152 |
# ----------------------------
|
| 153 |
def _get_valid_token_headers() -> dict:
|
| 154 |
-
# Note: region-specific account host may need .com or .eu — ensure API_BASE matches services used.
|
| 155 |
token_url = "https://accounts.zoho.in/oauth/v2/token"
|
| 156 |
params = {
|
| 157 |
"refresh_token": REFRESH_TOKEN,
|
|
@@ -167,7 +172,7 @@ def _get_valid_token_headers() -> dict:
|
|
| 167 |
raise RuntimeError(f"Failed to refresh Zoho token: {r.status_code} {r.text}")
|
| 168 |
|
| 169 |
# ----------------------------
|
| 170 |
-
# MCP tools: Zoho
|
| 171 |
# ----------------------------
|
| 172 |
@mcp.tool()
|
| 173 |
def authenticate_zoho() -> str:
|
|
@@ -243,10 +248,6 @@ def delete_record(module_name: str, record_id: str) -> str:
|
|
| 243 |
|
| 244 |
@mcp.tool()
|
| 245 |
def create_invoice(data: dict) -> str:
|
| 246 |
-
"""
|
| 247 |
-
Creates an invoice in Zoho Books.
|
| 248 |
-
NOTE: Ensure API_BASE points to the Books base (e.g. https://books.zoho.in/api/v3) when calling invoices.
|
| 249 |
-
"""
|
| 250 |
try:
|
| 251 |
headers = _get_valid_token_headers()
|
| 252 |
url = f"{API_BASE}/invoices"
|
|
@@ -270,7 +271,6 @@ def process_document(file_path: str, target_module: Optional[str] = "Contacts")
|
|
| 270 |
try:
|
| 271 |
if os.path.exists(file_path):
|
| 272 |
file_url = f"file://{file_path}"
|
| 273 |
-
# Placeholder extraction — replace with OCR + parsing logic
|
| 274 |
extracted = {
|
| 275 |
"Name": "ACME Corp (simulated)",
|
| 276 |
"Email": "[email protected]",
|
|
@@ -294,15 +294,9 @@ def process_document(file_path: str, target_module: Optional[str] = "Contacts")
|
|
| 294 |
return {"status": "error", "error": str(e)}
|
| 295 |
|
| 296 |
# ----------------------------
|
| 297 |
-
# Simple local command parser
|
| 298 |
# ----------------------------
|
| 299 |
def try_parse_and_invoke_command(text: str):
|
| 300 |
-
"""
|
| 301 |
-
Very small parser for explicit chat commands:
|
| 302 |
-
create_record MODULE {json}
|
| 303 |
-
create_invoice {json}
|
| 304 |
-
process_document /mnt/data/...
|
| 305 |
-
"""
|
| 306 |
text = text.strip()
|
| 307 |
# create_record
|
| 308 |
m = re.match(r"^create_record\s+(\w+)\s+(.+)$", text, re.I)
|
|
@@ -334,45 +328,56 @@ def try_parse_and_invoke_command(text: str):
|
|
| 334 |
return None
|
| 335 |
|
| 336 |
# ----------------------------
|
| 337 |
-
#
|
| 338 |
# ----------------------------
|
| 339 |
-
def
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
# ----------------------------
|
| 358 |
-
# Gradio chat handler
|
| 359 |
# ----------------------------
|
| 360 |
def chat_handler(message, history):
|
| 361 |
-
"""
|
| 362 |
-
Gradio ChatInterface calls this with (message, history).
|
| 363 |
-
If the message is a local file path (starting with /mnt/data/), we pass it unchanged
|
| 364 |
-
to process_document() — this satisfies the developer instruction to send the path as the URL.
|
| 365 |
-
Otherwise, try explicit commands, then local LLM fallback.
|
| 366 |
-
"""
|
| 367 |
history = history or []
|
| 368 |
trimmed = (message or "").strip()
|
| 369 |
|
| 370 |
-
#
|
| 371 |
cmd = try_parse_and_invoke_command(trimmed)
|
| 372 |
if cmd is not None:
|
| 373 |
return cmd
|
| 374 |
|
| 375 |
-
#
|
| 376 |
if trimmed.startswith("/mnt/data/"):
|
| 377 |
try:
|
| 378 |
doc = process_document(trimmed)
|
|
@@ -380,31 +385,8 @@ def chat_handler(message, history):
|
|
| 380 |
except Exception as e:
|
| 381 |
return f"Error processing document: {e}"
|
| 382 |
|
| 383 |
-
#
|
| 384 |
-
|
| 385 |
-
history_text = ""
|
| 386 |
-
for pair in history:
|
| 387 |
-
try:
|
| 388 |
-
user_turn, assistant_turn = pair[0], pair[1]
|
| 389 |
-
except Exception:
|
| 390 |
-
if isinstance(pair, dict):
|
| 391 |
-
user_turn = pair.get("user", "")
|
| 392 |
-
assistant_turn = pair.get("assistant", "")
|
| 393 |
-
else:
|
| 394 |
-
user_turn, assistant_turn = "", ""
|
| 395 |
-
if user_turn:
|
| 396 |
-
history_text += f"User: {user_turn}\n"
|
| 397 |
-
if assistant_turn:
|
| 398 |
-
history_text += f"Assistant: {assistant_turn}\n"
|
| 399 |
-
|
| 400 |
-
system = "You are a Zoho assistant that can call local MCP tools when asked. Keep replies short and actionable."
|
| 401 |
-
prompt = f"{system}\n{history_text}\nUser: {trimmed}\nAssistant:"
|
| 402 |
-
try:
|
| 403 |
-
resp = local_llm_generate(prompt)
|
| 404 |
-
_log_llm_call(None)
|
| 405 |
-
return resp
|
| 406 |
-
except Exception as e:
|
| 407 |
-
return f"LLM error: {e}"
|
| 408 |
|
| 409 |
# ----------------------------
|
| 410 |
# Gradio UI
|
|
@@ -412,13 +394,13 @@ def chat_handler(message, history):
|
|
| 412 |
def chat_interface():
|
| 413 |
return gr.ChatInterface(
|
| 414 |
fn=chat_handler,
|
| 415 |
-
textbox=gr.Textbox(placeholder="Ask me to create contacts, invoices, or paste /mnt/data/... for dev.")
|
| 416 |
)
|
| 417 |
|
| 418 |
# ----------------------------
|
| 419 |
# Entrypoint
|
| 420 |
# ----------------------------
|
| 421 |
if __name__ == "__main__":
|
| 422 |
-
print("[startup] Launching Gradio UI + FastMCP server (
|
| 423 |
demo = chat_interface()
|
| 424 |
demo.launch(server_name="0.0.0.0", server_port=7860)
|
|
|
|
| 1 |
+
# app.py — MCP POC using OpenRouter for LLM (replaces local HF model)
|
| 2 |
# Place this file next to config.py. Do NOT store secrets here.
|
| 3 |
|
| 4 |
from mcp.server.fastmcp import FastMCP
|
|
|
|
| 12 |
import inspect
|
| 13 |
import re
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# ----------------------------
|
| 16 |
# Load config
|
| 17 |
# ----------------------------
|
|
|
|
| 21 |
CLIENT_SECRET,
|
| 22 |
REFRESH_TOKEN,
|
| 23 |
API_BASE,
|
| 24 |
+
OPENROUTER_API_KEY, # your OpenRouter API key (put this in config.py)
|
| 25 |
+
OPENROUTER_MODEL # e.g. "gpt-4o-mini" or any model name routed by OpenRouter
|
| 26 |
)
|
| 27 |
except Exception:
|
| 28 |
raise SystemExit(
|
| 29 |
+
"Make sure config.py exists with CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, API_BASE, "
|
| 30 |
+
"OPENROUTER_API_KEY and OPENROUTER_MODEL (or set OPENROUTER_MODEL to your preferred model)."
|
| 31 |
)
|
| 32 |
|
| 33 |
+
# OpenRouter endpoint (public OpenRouter cloud endpoint)
|
| 34 |
+
OPENROUTER_BASE_URL = "https://api.openrouter.ai/v1"
|
| 35 |
+
|
| 36 |
# ----------------------------
|
| 37 |
# Initialize FastMCP
|
| 38 |
# ----------------------------
|
|
|
|
| 84 |
_init_analytics()
|
| 85 |
|
| 86 |
# ----------------------------
|
| 87 |
+
# OpenRouter wrapper
|
| 88 |
# ----------------------------
|
| 89 |
+
def _openrouter_headers():
|
| 90 |
+
return {"Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json"}
|
| 91 |
|
| 92 |
+
def openrouter_generate(system_prompt: str, user_prompt: str, history: Optional[List[Tuple[str,str]]] = None, max_tokens: int = 512) -> Dict[str, Any]:
|
| 93 |
"""
|
| 94 |
+
Call OpenRouter chat completions endpoint with messages built from system + history + user prompt.
|
| 95 |
+
Returns dict: {'text': <str>, 'raw': <resp_json>, 'confidence': Optional[float]}
|
|
|
|
| 96 |
"""
|
| 97 |
+
messages = []
|
| 98 |
+
# system
|
| 99 |
+
if system_prompt:
|
| 100 |
+
messages.append({"role": "system", "content": system_prompt})
|
| 101 |
+
# history (list of (user,assistant))
|
| 102 |
+
history = history or []
|
| 103 |
+
for pair in history:
|
| 104 |
+
try:
|
| 105 |
+
u, a = pair[0], pair[1]
|
| 106 |
+
if u:
|
| 107 |
+
messages.append({"role": "user", "content": u})
|
| 108 |
+
if a:
|
| 109 |
+
messages.append({"role": "assistant", "content": a})
|
| 110 |
+
except Exception:
|
| 111 |
+
continue
|
| 112 |
+
# current user
|
| 113 |
+
messages.append({"role": "user", "content": user_prompt})
|
| 114 |
+
|
| 115 |
+
body = {
|
| 116 |
+
"model": OPENROUTER_MODEL,
|
| 117 |
+
"messages": messages,
|
| 118 |
+
"max_tokens": max_tokens,
|
| 119 |
+
# "temperature": 0.0, # set if you want deterministic responses
|
| 120 |
+
}
|
| 121 |
|
| 122 |
+
url = f"{OPENROUTER_BASE_URL}/chat/completions"
|
| 123 |
+
try:
|
| 124 |
+
r = requests.post(url, headers=_openrouter_headers(), json=body, timeout=60)
|
| 125 |
+
except Exception as e:
|
| 126 |
+
raise RuntimeError(f"OpenRouter request failed: {e}")
|
| 127 |
|
| 128 |
+
if r.status_code not in (200, 201):
|
| 129 |
+
# surface the error for debugging
|
| 130 |
+
raise RuntimeError(f"OpenRouter API error {r.status_code}: {r.text}")
|
|
|
|
| 131 |
|
| 132 |
+
resp_json = r.json()
|
| 133 |
+
# Parse response for text; different routers may vary slightly
|
| 134 |
+
text = ""
|
| 135 |
+
confidence = None
|
| 136 |
try:
|
| 137 |
+
# typical shape: choices[0].message.content or choices[0].message
|
| 138 |
+
choice = resp_json.get("choices", [{}])[0]
|
| 139 |
+
message = choice.get("message", {}) or {}
|
| 140 |
+
if isinstance(message, dict):
|
| 141 |
+
text = message.get("content") or message.get("content", "")
|
| 142 |
+
# sometimes content is a dict mapping types -> text
|
| 143 |
+
if isinstance(text, dict):
|
| 144 |
+
# join possible parts
|
| 145 |
+
text = text.get("text") or next(iter(text.values()), "")
|
| 146 |
else:
|
| 147 |
+
text = str(message)
|
| 148 |
+
# some providers include scores
|
| 149 |
+
confidence = choice.get("finish_reason_score") or choice.get("score") or None
|
| 150 |
+
except Exception:
|
| 151 |
+
text = json.dumps(resp_json)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
+
_log_llm_call(confidence)
|
| 154 |
+
return {"text": text, "raw": resp_json, "confidence": confidence}
|
| 155 |
|
| 156 |
# ----------------------------
|
| 157 |
+
# Zoho token refresh & headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
# ----------------------------
|
| 159 |
def _get_valid_token_headers() -> dict:
|
|
|
|
| 160 |
token_url = "https://accounts.zoho.in/oauth/v2/token"
|
| 161 |
params = {
|
| 162 |
"refresh_token": REFRESH_TOKEN,
|
|
|
|
| 172 |
raise RuntimeError(f"Failed to refresh Zoho token: {r.status_code} {r.text}")
|
| 173 |
|
| 174 |
# ----------------------------
|
| 175 |
+
# MCP tools: Zoho CRUD & process_document (unchanged)
|
| 176 |
# ----------------------------
|
| 177 |
@mcp.tool()
|
| 178 |
def authenticate_zoho() -> str:
|
|
|
|
| 248 |
|
| 249 |
@mcp.tool()
|
| 250 |
def create_invoice(data: dict) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
try:
|
| 252 |
headers = _get_valid_token_headers()
|
| 253 |
url = f"{API_BASE}/invoices"
|
|
|
|
| 271 |
try:
|
| 272 |
if os.path.exists(file_path):
|
| 273 |
file_url = f"file://{file_path}"
|
|
|
|
| 274 |
extracted = {
|
| 275 |
"Name": "ACME Corp (simulated)",
|
| 276 |
"Email": "[email protected]",
|
|
|
|
| 294 |
return {"status": "error", "error": str(e)}
|
| 295 |
|
| 296 |
# ----------------------------
|
| 297 |
+
# Simple local command parser
|
| 298 |
# ----------------------------
|
| 299 |
def try_parse_and_invoke_command(text: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
text = text.strip()
|
| 301 |
# create_record
|
| 302 |
m = re.match(r"^create_record\s+(\w+)\s+(.+)$", text, re.I)
|
|
|
|
| 328 |
return None
|
| 329 |
|
| 330 |
# ----------------------------
|
| 331 |
+
# OpenRouter-based chat handler
|
| 332 |
# ----------------------------
|
| 333 |
+
def openrouter_response(message: str, history: Optional[List[Tuple[str,str]]] = None) -> str:
|
| 334 |
+
history = history or []
|
| 335 |
+
system_prompt = (
|
| 336 |
+
"You are Zoho Assistant. Keep responses concise. When appropriate, output a JSON object with keys 'tool' and 'args' "
|
| 337 |
+
"so the server can automatically call the corresponding MCP tool. Example:\n"
|
| 338 |
+
'{"tool":"create_record","args":{"module_name":"Contacts","record_data":{"Last_Name":"Doe","Email":"[email protected]"}}}\n'
|
| 339 |
+
"If not invoking tools, answer conversationally."
|
| 340 |
+
)
|
| 341 |
+
try:
|
| 342 |
+
resp = openrouter_generate(system_prompt, message, history)
|
| 343 |
+
text = resp.get("text", "")
|
| 344 |
+
# If LLM returned JSON indicating a tool invocation, attempt to parse & run
|
| 345 |
+
parsed = None
|
| 346 |
+
payload = text.strip()
|
| 347 |
+
if payload.startswith("{") or payload.startswith("["):
|
| 348 |
+
try:
|
| 349 |
+
parsed = json.loads(payload)
|
| 350 |
+
except Exception:
|
| 351 |
+
parsed = None
|
| 352 |
+
if isinstance(parsed, dict) and "tool" in parsed:
|
| 353 |
+
tool_name = parsed.get("tool")
|
| 354 |
+
args = parsed.get("args", {})
|
| 355 |
+
# Try call local tool by name if exists
|
| 356 |
+
if tool_name in globals() and callable(globals()[tool_name]):
|
| 357 |
+
try:
|
| 358 |
+
result = globals()[tool_name](**args) if isinstance(args, dict) else globals()[tool_name](args)
|
| 359 |
+
return f"Invoked tool '{tool_name}'. Result:\n{result}"
|
| 360 |
+
except Exception as e:
|
| 361 |
+
return f"Tool invocation error: {e}"
|
| 362 |
+
else:
|
| 363 |
+
return f"Requested tool '{tool_name}' not found locally."
|
| 364 |
+
return text
|
| 365 |
+
except Exception as e:
|
| 366 |
+
return f"(OpenRouter error) {e}"
|
| 367 |
|
| 368 |
# ----------------------------
|
| 369 |
+
# Gradio chat handler
|
| 370 |
# ----------------------------
|
| 371 |
def chat_handler(message, history):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
history = history or []
|
| 373 |
trimmed = (message or "").strip()
|
| 374 |
|
| 375 |
+
# Explicit POC commands
|
| 376 |
cmd = try_parse_and_invoke_command(trimmed)
|
| 377 |
if cmd is not None:
|
| 378 |
return cmd
|
| 379 |
|
| 380 |
+
# Developer convenience: local path handling (send unchanged)
|
| 381 |
if trimmed.startswith("/mnt/data/"):
|
| 382 |
try:
|
| 383 |
doc = process_document(trimmed)
|
|
|
|
| 385 |
except Exception as e:
|
| 386 |
return f"Error processing document: {e}"
|
| 387 |
|
| 388 |
+
# Otherwise, call OpenRouter
|
| 389 |
+
return openrouter_response(trimmed, history)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
# ----------------------------
|
| 392 |
# Gradio UI
|
|
|
|
| 394 |
def chat_interface():
|
| 395 |
return gr.ChatInterface(
|
| 396 |
fn=chat_handler,
|
| 397 |
+
textbox=gr.Textbox(placeholder="Ask me to create contacts, invoices, upload docs (or paste /mnt/data/... for dev).")
|
| 398 |
)
|
| 399 |
|
| 400 |
# ----------------------------
|
| 401 |
# Entrypoint
|
| 402 |
# ----------------------------
|
| 403 |
if __name__ == "__main__":
|
| 404 |
+
print("[startup] Launching Gradio UI + FastMCP server (OpenRouter mode).")
|
| 405 |
demo = chat_interface()
|
| 406 |
demo.launch(server_name="0.0.0.0", server_port=7860)
|