Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
-
# app.py — MCP server (
|
| 2 |
-
#
|
| 3 |
-
# -
|
| 4 |
-
# -
|
| 5 |
-
# -
|
|
|
|
| 6 |
|
| 7 |
from mcp.server.fastmcp import FastMCP
|
| 8 |
-
from typing import Optional, Any, Dict
|
| 9 |
import requests
|
| 10 |
import os
|
| 11 |
import gradio as gr
|
|
@@ -13,324 +14,277 @@ import json
|
|
| 13 |
import re
|
| 14 |
import logging
|
| 15 |
import gc
|
| 16 |
-
import
|
|
|
|
| 17 |
|
| 18 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
try:
|
| 20 |
-
from
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def extract_text_and_conf(path): return ("", 0.0)
|
| 24 |
-
def get_ocr_extraction_prompt(txt, page_count=1): return txt
|
| 25 |
-
def get_agent_prompt(h, u): return u
|
| 26 |
|
| 27 |
logging.basicConfig(level=logging.INFO)
|
| 28 |
logger = logging.getLogger("mcp_server")
|
| 29 |
|
| 30 |
-
# --- Load Config ---
|
| 31 |
-
try:
|
| 32 |
-
from config import (
|
| 33 |
-
CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, API_BASE,
|
| 34 |
-
INVOICE_API_BASE, ORGANIZATION_ID, LOCAL_MODEL
|
| 35 |
-
)
|
| 36 |
-
except Exception:
|
| 37 |
-
raise SystemExit("Config missing. Please create config.py with required keys.")
|
| 38 |
-
|
| 39 |
mcp = FastMCP("ZohoCRMAgent")
|
| 40 |
|
| 41 |
-
# --- Globals ---
|
| 42 |
LLM_PIPELINE = None
|
| 43 |
TOKENIZER = None
|
| 44 |
|
| 45 |
-
# ---
|
| 46 |
-
def calculate_extraction_confidence(data: dict, ocr_score: float) -> dict:
|
| 47 |
-
semantic_score = 0
|
| 48 |
-
issues = []
|
| 49 |
-
|
| 50 |
-
# Structure baseline
|
| 51 |
-
semantic_score += 10
|
| 52 |
-
|
| 53 |
-
# Total Amount Check (30)
|
| 54 |
-
grand = data.get("totals", {}).get("grand_total") if isinstance(data.get("totals"), dict) else None
|
| 55 |
-
if grand is not None:
|
| 56 |
-
try:
|
| 57 |
-
float(str(grand))
|
| 58 |
-
semantic_score += 30
|
| 59 |
-
except:
|
| 60 |
-
issues.append("Missing/Invalid Total Amount")
|
| 61 |
-
else:
|
| 62 |
-
issues.append("Missing/Invalid Total Amount")
|
| 63 |
-
|
| 64 |
-
# Date Check (20)
|
| 65 |
-
date_str = data.get("invoice_date")
|
| 66 |
-
if date_str and isinstance(date_str, str) and len(date_str) >= 8:
|
| 67 |
-
semantic_score += 20
|
| 68 |
-
else:
|
| 69 |
-
issues.append("Missing Invoice Date")
|
| 70 |
-
|
| 71 |
-
# Line Items Check (30)
|
| 72 |
-
items = data.get("line_items", [])
|
| 73 |
-
if isinstance(items, list) and len(items) > 0:
|
| 74 |
-
if any((isinstance(i, dict) and (i.get("name") or i.get("description"))) for i in items):
|
| 75 |
-
semantic_score += 30
|
| 76 |
-
else:
|
| 77 |
-
semantic_score += 10
|
| 78 |
-
issues.append("Line Items missing descriptions")
|
| 79 |
-
else:
|
| 80 |
-
issues.append("No Line Items detected")
|
| 81 |
-
|
| 82 |
-
# Contact Name (10)
|
| 83 |
-
buyer = data.get("buyer", {}) or {}
|
| 84 |
-
if buyer.get("contact_name") or buyer.get("company_name"):
|
| 85 |
-
semantic_score += 10
|
| 86 |
-
else:
|
| 87 |
-
issues.append("Missing Buyer / Contact Name")
|
| 88 |
-
|
| 89 |
-
final_score = (semantic_score * 0.8) + (ocr_score * 0.2)
|
| 90 |
-
rating = "High" if final_score > 80 else ("Medium" if final_score > 50 else "Low")
|
| 91 |
-
if ocr_score < 60:
|
| 92 |
-
issues.append(f"Low OCR Confidence ({ocr_score}%) - Check image quality")
|
| 93 |
-
|
| 94 |
-
return {
|
| 95 |
-
"score": int(round(final_score)),
|
| 96 |
-
"ocr_score": int(round(ocr_score)),
|
| 97 |
-
"semantic_score": semantic_score,
|
| 98 |
-
"rating": rating,
|
| 99 |
-
"issues": issues
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
# --- Robust JSON extraction & repair helpers ---
|
| 103 |
def _try_json_loads(text: str) -> Optional[Any]:
|
| 104 |
try:
|
| 105 |
return json.loads(text)
|
| 106 |
except Exception:
|
| 107 |
return None
|
| 108 |
|
| 109 |
-
def
|
| 110 |
s = re.sub(r"```(?:json)?\s*", "", s, flags=re.IGNORECASE)
|
| 111 |
s = re.sub(r"\s*```$", "", s, flags=re.IGNORECASE)
|
| 112 |
return s.strip()
|
| 113 |
|
| 114 |
def _attempt_simple_repairs(s: str) -> str:
|
| 115 |
-
#
|
| 116 |
s = "".join(ch for ch in s if (ch == "\n" or ch == "\t" or (32 <= ord(ch) <= 0x10FFFF)))
|
| 117 |
-
#
|
| 118 |
s = re.sub(r",\s*(\}|])", r"\1", s)
|
| 119 |
-
#
|
| 120 |
if '"' not in s and "'" in s:
|
| 121 |
s = s.replace("'", '"')
|
| 122 |
-
# 4) Remove assistant labels
|
| 123 |
-
s = re.sub(r"^(assistant:|response:)\s*", "", s, flags=re.IGNORECASE)
|
| 124 |
return s
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
def extract_json_safely(text: str) -> Optional[Any]:
|
| 127 |
"""
|
| 128 |
-
Robustly extract JSON from
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
"""
|
| 131 |
if not text:
|
| 132 |
return None
|
| 133 |
|
| 134 |
-
#
|
| 135 |
parsed = _try_json_loads(text)
|
| 136 |
if parsed is not None:
|
| 137 |
return parsed
|
| 138 |
|
| 139 |
-
#
|
| 140 |
-
|
| 141 |
-
m =
|
| 142 |
if m:
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
if
|
| 146 |
-
return
|
| 147 |
-
|
| 148 |
try:
|
| 149 |
-
return json.loads(
|
| 150 |
except Exception as e:
|
| 151 |
-
logger.warning("Marker JSON
|
| 152 |
|
| 153 |
-
#
|
| 154 |
-
brace_spans = []
|
| 155 |
stack = []
|
|
|
|
| 156 |
for i, ch in enumerate(text):
|
| 157 |
if ch == "{":
|
| 158 |
stack.append(i)
|
| 159 |
elif ch == "}" and stack:
|
| 160 |
start = stack.pop()
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
if (start, end) in seen:
|
| 167 |
continue
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
if
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
parsed = _try_json_loads(candidate)
|
| 174 |
-
if parsed is not None:
|
| 175 |
-
return parsed
|
| 176 |
-
repaired = _attempt_simple_repairs(candidate)
|
| 177 |
try:
|
| 178 |
-
return json.loads(
|
| 179 |
except Exception:
|
| 180 |
continue
|
| 181 |
|
| 182 |
-
#
|
| 183 |
-
|
| 184 |
-
if
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
if
|
| 188 |
-
return
|
| 189 |
-
|
| 190 |
try:
|
| 191 |
-
return json.loads(
|
| 192 |
-
except:
|
| 193 |
pass
|
| 194 |
|
| 195 |
-
#
|
| 196 |
-
|
| 197 |
-
logger.error("
|
| 198 |
return None
|
| 199 |
|
| 200 |
-
|
| 201 |
-
if not isinstance(args, dict):
|
| 202 |
-
return args
|
| 203 |
-
fp = args.get("file_path") or args.get("path")
|
| 204 |
-
if isinstance(fp, str) and fp.startswith("/mnt/data/") and os.path.exists(fp):
|
| 205 |
-
args["file_url"] = f"file://{fp}"
|
| 206 |
-
return args
|
| 207 |
-
|
| 208 |
-
# --- Model Loading (safer) ---
|
| 209 |
def init_local_model():
|
| 210 |
global LLM_PIPELINE, TOKENIZER
|
| 211 |
if LLM_PIPELINE is not None:
|
| 212 |
return
|
| 213 |
-
|
| 214 |
try:
|
| 215 |
from transformers import pipeline, AutoTokenizer, AutoModelForCausalLM
|
| 216 |
import torch
|
| 217 |
-
|
| 218 |
-
logger.info("Loading local model: %s", LOCAL_MODEL)
|
| 219 |
TOKENIZER = AutoTokenizer.from_pretrained(LOCAL_MODEL)
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
| 222 |
model = AutoModelForCausalLM.from_pretrained(LOCAL_MODEL, device_map="auto", torch_dtype=dtype)
|
| 223 |
LLM_PIPELINE = pipeline("text-generation", model=model, tokenizer=TOKENIZER)
|
| 224 |
-
logger.info("Local model
|
| 225 |
except Exception as e:
|
| 226 |
-
logger.exception("
|
| 227 |
LLM_PIPELINE = None
|
| 228 |
|
| 229 |
def local_llm_generate(prompt: str, max_tokens: int = 512) -> Dict[str, Any]:
|
| 230 |
-
"""
|
| 231 |
-
Generate text using local pipeline. Returns dict: { "text": <str>, "raw": <pipeline output> }
|
| 232 |
-
"""
|
| 233 |
if LLM_PIPELINE is None:
|
| 234 |
init_local_model()
|
| 235 |
if LLM_PIPELINE is None:
|
| 236 |
return {"text": "Model not loaded.", "raw": None}
|
| 237 |
-
|
| 238 |
try:
|
| 239 |
out = LLM_PIPELINE(prompt, max_new_tokens=max_tokens, return_full_text=False, do_sample=False)
|
| 240 |
-
#
|
| 241 |
-
|
|
|
|
| 242 |
first = out[0]
|
| 243 |
if isinstance(first, dict) and "generated_text" in first:
|
| 244 |
text = first["generated_text"]
|
| 245 |
elif isinstance(first, str):
|
| 246 |
text = first
|
| 247 |
else:
|
| 248 |
-
# fallback: join values
|
| 249 |
text = str(first)
|
| 250 |
elif isinstance(out, str):
|
| 251 |
text = out
|
| 252 |
-
else:
|
| 253 |
-
text = ""
|
| 254 |
return {"text": text, "raw": out}
|
| 255 |
except Exception as e:
|
| 256 |
-
logger.exception("LLM generation
|
| 257 |
-
return {"text": f"
|
| 258 |
|
| 259 |
-
# ---
|
| 260 |
def _get_valid_token_headers() -> dict:
|
| 261 |
try:
|
| 262 |
r = requests.post("https://accounts.zoho.in/oauth/v2/token", params={
|
| 263 |
"refresh_token": REFRESH_TOKEN, "client_id": CLIENT_ID,
|
| 264 |
"client_secret": CLIENT_SECRET, "grant_type": "refresh_token"
|
| 265 |
-
}, timeout=
|
| 266 |
if r.status_code == 200:
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
| 269 |
except Exception as e:
|
| 270 |
-
logger.exception("Token
|
| 271 |
-
|
| 272 |
|
|
|
|
| 273 |
@mcp.tool()
|
| 274 |
def create_record(module_name: str, record_data: dict) -> str:
|
| 275 |
-
|
| 276 |
-
if not
|
| 277 |
-
return json.dumps({"status": "error", "message": "Auth
|
| 278 |
try:
|
| 279 |
-
r = requests.post(f"{API_BASE}/{module_name}", headers=
|
| 280 |
-
return json.dumps(r.json()) if r.status_code in (200,
|
| 281 |
except Exception as e:
|
| 282 |
logger.exception("create_record failed: %s", e)
|
| 283 |
-
return json.dumps({"status":
|
| 284 |
|
| 285 |
@mcp.tool()
|
| 286 |
def create_invoice(data: dict) -> str:
|
| 287 |
-
|
| 288 |
-
if not
|
| 289 |
-
return json.dumps({"status": "error", "message": "Auth
|
| 290 |
try:
|
| 291 |
-
r = requests.post(f"{INVOICE_API_BASE}/invoices", headers=
|
| 292 |
-
|
| 293 |
-
return json.dumps(r.json()) if r.status_code in (200, 201) else json.dumps({"status": "error", "http_status": r.status_code, "text": r.text})
|
| 294 |
except Exception as e:
|
| 295 |
logger.exception("create_invoice failed: %s", e)
|
| 296 |
-
return json.dumps({"status":
|
| 297 |
|
|
|
|
| 298 |
@mcp.tool()
|
| 299 |
def process_document(file_path: str, target_module: Optional[str] = "Contacts") -> dict:
|
|
|
|
| 300 |
if not os.path.exists(file_path):
|
| 301 |
-
return {"status": "error", "error": f"File not found
|
| 302 |
|
| 303 |
-
# 1) OCR (returns text + confidence)
|
| 304 |
raw_text, ocr_score = extract_text_and_conf(file_path)
|
| 305 |
if not raw_text:
|
| 306 |
-
return {"status": "error", "error": "OCR empty
|
| 307 |
|
| 308 |
-
# 2) LLM extraction
|
| 309 |
prompt = get_ocr_extraction_prompt(raw_text, page_count=1)
|
| 310 |
-
|
| 311 |
-
llm_text =
|
| 312 |
-
data = extract_json_safely(llm_text)
|
| 313 |
|
| 314 |
-
|
| 315 |
-
kpis = {"score": 0, "rating": "Fail", "issues": ["Extraction
|
| 316 |
-
if
|
| 317 |
-
kpis
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
return {
|
| 320 |
-
"status": "success",
|
| 321 |
"file": os.path.basename(file_path),
|
| 322 |
-
"extracted_data":
|
| 323 |
"raw_llm_output": llm_text,
|
|
|
|
| 324 |
"kpis": kpis
|
| 325 |
}
|
| 326 |
|
| 327 |
-
# ---
|
| 328 |
def parse_and_execute(model_text: str, history: list) -> str:
|
| 329 |
payload = extract_json_safely(model_text)
|
| 330 |
if not payload:
|
| 331 |
-
return "No valid tool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
|
| 333 |
-
cmds = [payload] if isinstance(payload, dict) else payload
|
| 334 |
results = []
|
| 335 |
last_contact_id = None
|
| 336 |
|
|
@@ -338,21 +292,18 @@ def parse_and_execute(model_text: str, history: list) -> str:
|
|
| 338 |
if not isinstance(cmd, dict):
|
| 339 |
continue
|
| 340 |
tool = cmd.get("tool")
|
| 341 |
-
args =
|
| 342 |
|
| 343 |
if tool == "create_record":
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
res = create_record(
|
| 347 |
results.append(f"create_record -> {res}")
|
|
|
|
| 348 |
try:
|
| 349 |
rj = json.loads(res)
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
if "data" in rj and isinstance(rj["data"], list) and rj["data"] and "details" in rj["data"][0]:
|
| 353 |
-
last_contact_id = rj["data"][0]["details"].get("id")
|
| 354 |
-
elif "id" in rj:
|
| 355 |
-
last_contact_id = rj.get("id")
|
| 356 |
except Exception:
|
| 357 |
pass
|
| 358 |
|
|
@@ -366,47 +317,51 @@ def parse_and_execute(model_text: str, history: list) -> str:
|
|
| 366 |
else:
|
| 367 |
results.append(f"Unknown tool: {tool}")
|
| 368 |
|
| 369 |
-
return "\n".join(results) if results else "No actionable tool
|
| 370 |
|
| 371 |
-
# --- Chat Core ---
|
| 372 |
def chat_logic(message: str, file_path: Optional[str], history: list) -> str:
|
| 373 |
-
# PHASE: File Upload -> Extraction -> KPI Report
|
| 374 |
if file_path:
|
| 375 |
-
logger.info("
|
| 376 |
doc = process_document(file_path)
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
f"### 📄 Extraction
|
| 386 |
-
f"
|
| 387 |
-
f"
|
| 388 |
-
f"
|
| 389 |
-
f"```json\n{
|
| 390 |
-
"If you want to persist this to Zoho, type **Create Invoice** or ask me to create the contact/item first."
|
| 391 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
else:
|
| 393 |
-
return f"
|
| 394 |
|
| 395 |
-
#
|
| 396 |
hist_txt = "\n".join([f"U: {h[0]}\nA: {h[1]}" for h in history]) if history else ""
|
| 397 |
prompt = get_agent_prompt(hist_txt, message)
|
| 398 |
gen = local_llm_generate(prompt, max_tokens=256)
|
| 399 |
gen_text = gen.get("text", "")
|
| 400 |
|
| 401 |
-
# If LLM returned a tool JSON, execute it
|
| 402 |
tool_payload = extract_json_safely(gen_text)
|
| 403 |
if tool_payload:
|
| 404 |
return parse_and_execute(gen_text, history)
|
| 405 |
|
| 406 |
-
#
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
| 408 |
|
| 409 |
-
# ---
|
| 410 |
def chat_handler(msg, hist):
|
| 411 |
txt = msg.get("text", "")
|
| 412 |
files = msg.get("files", [])
|
|
|
|
| 1 |
+
# app.py — MCP server (refined)
|
| 2 |
+
# Key improvements:
|
| 3 |
+
# - Robust JSON extraction & repair
|
| 4 |
+
# - Detailed debug logging, write raw LLM output to /tmp when parse fails
|
| 5 |
+
# - Defensive LLM handling
|
| 6 |
+
# - Uses your ocr_engine.extract_text_and_conf
|
| 7 |
|
| 8 |
from mcp.server.fastmcp import FastMCP
|
| 9 |
+
from typing import Optional, Any, Dict
|
| 10 |
import requests
|
| 11 |
import os
|
| 12 |
import gradio as gr
|
|
|
|
| 14 |
import re
|
| 15 |
import logging
|
| 16 |
import gc
|
| 17 |
+
import time
|
| 18 |
+
import traceback
|
| 19 |
|
| 20 |
+
# imports from local modules (these must exist)
|
| 21 |
+
from ocr_engine import extract_text_and_conf
|
| 22 |
+
from prompts import get_ocr_extraction_prompt, get_agent_prompt
|
| 23 |
+
|
| 24 |
+
# config (must exist)
|
| 25 |
try:
|
| 26 |
+
from config import CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, API_BASE, INVOICE_API_BASE, ORGANIZATION_ID, LOCAL_MODEL
|
| 27 |
+
except Exception as e:
|
| 28 |
+
raise SystemExit("Missing config.py or required keys. Error: " + str(e))
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
logging.basicConfig(level=logging.INFO)
|
| 31 |
logger = logging.getLogger("mcp_server")
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
mcp = FastMCP("ZohoCRMAgent")
|
| 34 |
|
|
|
|
| 35 |
LLM_PIPELINE = None
|
| 36 |
TOKENIZER = None
|
| 37 |
|
| 38 |
+
# ---------------- JSON extraction helpers ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
def _try_json_loads(text: str) -> Optional[Any]:
|
| 40 |
try:
|
| 41 |
return json.loads(text)
|
| 42 |
except Exception:
|
| 43 |
return None
|
| 44 |
|
| 45 |
+
def _remove_code_fences(s: str) -> str:
|
| 46 |
s = re.sub(r"```(?:json)?\s*", "", s, flags=re.IGNORECASE)
|
| 47 |
s = re.sub(r"\s*```$", "", s, flags=re.IGNORECASE)
|
| 48 |
return s.strip()
|
| 49 |
|
| 50 |
def _attempt_simple_repairs(s: str) -> str:
|
| 51 |
+
# keep printable chars
|
| 52 |
s = "".join(ch for ch in s if (ch == "\n" or ch == "\t" or (32 <= ord(ch) <= 0x10FFFF)))
|
| 53 |
+
# remove trailing commas
|
| 54 |
s = re.sub(r",\s*(\}|])", r"\1", s)
|
| 55 |
+
# convert single quotes if double quotes not present
|
| 56 |
if '"' not in s and "'" in s:
|
| 57 |
s = s.replace("'", '"')
|
|
|
|
|
|
|
| 58 |
return s
|
| 59 |
|
| 60 |
+
def _dump_raw_llm_output(text: str) -> str:
|
| 61 |
+
"""Dump raw LLM output to a timestamped file for debugging and return path."""
|
| 62 |
+
try:
|
| 63 |
+
ts = int(time.time())
|
| 64 |
+
path = f"/tmp/llm_output_{ts}.txt"
|
| 65 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 66 |
+
f.write(text)
|
| 67 |
+
logger.info("Wrote raw LLM output to %s for debugging", path)
|
| 68 |
+
return path
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.exception("Failed to write raw llm output: %s", e)
|
| 71 |
+
return ""
|
| 72 |
+
|
| 73 |
def extract_json_safely(text: str) -> Optional[Any]:
|
| 74 |
"""
|
| 75 |
+
Robustly extract JSON from LLM output.
|
| 76 |
+
1) Try direct loads
|
| 77 |
+
2) Try marker extraction <<<JSON>>> ... <<<END_JSON>>>
|
| 78 |
+
3) Try largest balanced { ... } block
|
| 79 |
+
4) Try array [...]
|
| 80 |
+
On failure, write raw text to /tmp and return None.
|
| 81 |
"""
|
| 82 |
if not text:
|
| 83 |
return None
|
| 84 |
|
| 85 |
+
# direct
|
| 86 |
parsed = _try_json_loads(text)
|
| 87 |
if parsed is not None:
|
| 88 |
return parsed
|
| 89 |
|
| 90 |
+
# marker-based extraction
|
| 91 |
+
marker_re = re.compile(r"<<<JSON>>>\s*([\s\S]*?)\s*<<<END_JSON>>>", re.IGNORECASE)
|
| 92 |
+
m = marker_re.search(text)
|
| 93 |
if m:
|
| 94 |
+
cand = _remove_code_fences(m.group(1))
|
| 95 |
+
p = _try_json_loads(cand)
|
| 96 |
+
if p is not None:
|
| 97 |
+
return p
|
| 98 |
+
cand2 = _attempt_simple_repairs(cand)
|
| 99 |
try:
|
| 100 |
+
return json.loads(cand2)
|
| 101 |
except Exception as e:
|
| 102 |
+
logger.warning("Marker JSON repair failed: %s", e)
|
| 103 |
|
| 104 |
+
# fallback: largest balanced {...}
|
|
|
|
| 105 |
stack = []
|
| 106 |
+
spans = []
|
| 107 |
for i, ch in enumerate(text):
|
| 108 |
if ch == "{":
|
| 109 |
stack.append(i)
|
| 110 |
elif ch == "}" and stack:
|
| 111 |
start = stack.pop()
|
| 112 |
+
spans.append((start, i))
|
| 113 |
+
spans = sorted(spans, key=lambda t: t[1]-t[0], reverse=True)
|
| 114 |
+
for start, end in spans:
|
| 115 |
+
cand = text[start:end+1].strip()
|
| 116 |
+
if len(cand) < 20:
|
|
|
|
| 117 |
continue
|
| 118 |
+
cand = _remove_code_fences(cand)
|
| 119 |
+
p = _try_json_loads(cand)
|
| 120 |
+
if p is not None:
|
| 121 |
+
return p
|
| 122 |
+
cand2 = _attempt_simple_repairs(cand)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
try:
|
| 124 |
+
return json.loads(cand2)
|
| 125 |
except Exception:
|
| 126 |
continue
|
| 127 |
|
| 128 |
+
# try array
|
| 129 |
+
arr = re.search(r"(\[[\s\S]*\])", text)
|
| 130 |
+
if arr:
|
| 131 |
+
cand = _remove_code_fences(arr.group(1))
|
| 132 |
+
p = _try_json_loads(cand)
|
| 133 |
+
if p is not None:
|
| 134 |
+
return p
|
| 135 |
+
cand2 = _attempt_simple_repairs(cand)
|
| 136 |
try:
|
| 137 |
+
return json.loads(cand2)
|
| 138 |
+
except Exception:
|
| 139 |
pass
|
| 140 |
|
| 141 |
+
# failed: dump raw text and log traceback
|
| 142 |
+
dump_path = _dump_raw_llm_output(text)
|
| 143 |
+
logger.error("extract_json_safely: failed to parse JSON. Raw output saved to: %s", dump_path)
|
| 144 |
return None
|
| 145 |
|
| 146 |
+
# ---------------- Model helpers (defensive) ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
def init_local_model():
|
| 148 |
global LLM_PIPELINE, TOKENIZER
|
| 149 |
if LLM_PIPELINE is not None:
|
| 150 |
return
|
|
|
|
| 151 |
try:
|
| 152 |
from transformers import pipeline, AutoTokenizer, AutoModelForCausalLM
|
| 153 |
import torch
|
|
|
|
|
|
|
| 154 |
TOKENIZER = AutoTokenizer.from_pretrained(LOCAL_MODEL)
|
| 155 |
+
dtype = None
|
| 156 |
+
# choose dtype depending on CUDA availability
|
| 157 |
+
if torch.cuda.is_available():
|
| 158 |
+
dtype = torch.float16
|
| 159 |
model = AutoModelForCausalLM.from_pretrained(LOCAL_MODEL, device_map="auto", torch_dtype=dtype)
|
| 160 |
LLM_PIPELINE = pipeline("text-generation", model=model, tokenizer=TOKENIZER)
|
| 161 |
+
logger.info("Local model initialized.")
|
| 162 |
except Exception as e:
|
| 163 |
+
logger.exception("Failed to load local model: %s", e)
|
| 164 |
LLM_PIPELINE = None
|
| 165 |
|
| 166 |
def local_llm_generate(prompt: str, max_tokens: int = 512) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
| 167 |
if LLM_PIPELINE is None:
|
| 168 |
init_local_model()
|
| 169 |
if LLM_PIPELINE is None:
|
| 170 |
return {"text": "Model not loaded.", "raw": None}
|
|
|
|
| 171 |
try:
|
| 172 |
out = LLM_PIPELINE(prompt, max_new_tokens=max_tokens, return_full_text=False, do_sample=False)
|
| 173 |
+
# defensively extract text
|
| 174 |
+
text = ""
|
| 175 |
+
if isinstance(out, list) and out:
|
| 176 |
first = out[0]
|
| 177 |
if isinstance(first, dict) and "generated_text" in first:
|
| 178 |
text = first["generated_text"]
|
| 179 |
elif isinstance(first, str):
|
| 180 |
text = first
|
| 181 |
else:
|
|
|
|
| 182 |
text = str(first)
|
| 183 |
elif isinstance(out, str):
|
| 184 |
text = out
|
|
|
|
|
|
|
| 185 |
return {"text": text, "raw": out}
|
| 186 |
except Exception as e:
|
| 187 |
+
logger.exception("LLM generation error: %s", e)
|
| 188 |
+
return {"text": f"LLM error: {e}", "raw": None}
|
| 189 |
|
| 190 |
+
# ---------------- Zoho token utility ----------------
|
| 191 |
def _get_valid_token_headers() -> dict:
|
| 192 |
try:
|
| 193 |
r = requests.post("https://accounts.zoho.in/oauth/v2/token", params={
|
| 194 |
"refresh_token": REFRESH_TOKEN, "client_id": CLIENT_ID,
|
| 195 |
"client_secret": CLIENT_SECRET, "grant_type": "refresh_token"
|
| 196 |
+
}, timeout=15)
|
| 197 |
if r.status_code == 200:
|
| 198 |
+
tok = r.json().get("access_token")
|
| 199 |
+
return {"Authorization": f"Zoho-oauthtoken {tok}"}
|
| 200 |
+
else:
|
| 201 |
+
logger.error("Token refresh failed: %s", r.text)
|
| 202 |
+
return {}
|
| 203 |
except Exception as e:
|
| 204 |
+
logger.exception("Token refresh exception: %s", e)
|
| 205 |
+
return {}
|
| 206 |
|
| 207 |
+
# ---------------- MCP tool implementations ----------------
|
| 208 |
@mcp.tool()
|
| 209 |
def create_record(module_name: str, record_data: dict) -> str:
|
| 210 |
+
headers = _get_valid_token_headers()
|
| 211 |
+
if not headers:
|
| 212 |
+
return json.dumps({"status": "error", "message": "Auth failed"})
|
| 213 |
try:
|
| 214 |
+
r = requests.post(f"{API_BASE}/{module_name}", headers=headers, json={"data": [record_data]}, timeout=15)
|
| 215 |
+
return json.dumps(r.json()) if r.status_code in (200,201) else json.dumps({"status":"error","http_status":r.status_code,"text":r.text})
|
| 216 |
except Exception as e:
|
| 217 |
logger.exception("create_record failed: %s", e)
|
| 218 |
+
return json.dumps({"status":"error","message": str(e)})
|
| 219 |
|
| 220 |
@mcp.tool()
|
| 221 |
def create_invoice(data: dict) -> str:
|
| 222 |
+
headers = _get_valid_token_headers()
|
| 223 |
+
if not headers:
|
| 224 |
+
return json.dumps({"status": "error", "message": "Auth failed"})
|
| 225 |
try:
|
| 226 |
+
r = requests.post(f"{INVOICE_API_BASE}/invoices", headers=headers, params={"organization_id": ORGANIZATION_ID}, json=data, timeout=15)
|
| 227 |
+
return json.dumps(r.json()) if r.status_code in (200,201) else json.dumps({"status":"error","http_status": r.status_code, "text": r.text})
|
|
|
|
| 228 |
except Exception as e:
|
| 229 |
logger.exception("create_invoice failed: %s", e)
|
| 230 |
+
return json.dumps({"status":"error","message": str(e)})
|
| 231 |
|
| 232 |
+
# ---------------- Document processing ----------------
|
| 233 |
@mcp.tool()
|
| 234 |
def process_document(file_path: str, target_module: Optional[str] = "Contacts") -> dict:
|
| 235 |
+
"""Full flow: OCR -> LLM extraction -> KPI -> result with raw llm text for debugging"""
|
| 236 |
if not os.path.exists(file_path):
|
| 237 |
+
return {"status": "error", "error": f"File not found: {file_path}"}
|
| 238 |
|
|
|
|
| 239 |
raw_text, ocr_score = extract_text_and_conf(file_path)
|
| 240 |
if not raw_text:
|
| 241 |
+
return {"status": "error", "error": "OCR returned empty text."}
|
| 242 |
|
|
|
|
| 243 |
prompt = get_ocr_extraction_prompt(raw_text, page_count=1)
|
| 244 |
+
llm_res = local_llm_generate(prompt, max_tokens=512)
|
| 245 |
+
llm_text = llm_res.get("text", "")
|
|
|
|
| 246 |
|
| 247 |
+
parsed = extract_json_safely(llm_text)
|
| 248 |
+
kpis = {"score": 0, "rating": "Fail", "issues": ["Extraction failed"]}
|
| 249 |
+
if parsed:
|
| 250 |
+
# compute kpis basic heuristics (simple)
|
| 251 |
+
try:
|
| 252 |
+
total = parsed.get("totals", {}).get("grand_total")
|
| 253 |
+
semantic_ok = 1 if total else 0
|
| 254 |
+
kpis = {
|
| 255 |
+
"score": 80 if semantic_ok else 40,
|
| 256 |
+
"rating": "High" if semantic_ok else "Low",
|
| 257 |
+
"ocr_score": ocr_score,
|
| 258 |
+
"issues": [] if semantic_ok else ["grand_total missing"]
|
| 259 |
+
}
|
| 260 |
+
except Exception:
|
| 261 |
+
kpis["issues"].append("Error computing KPIs")
|
| 262 |
+
|
| 263 |
+
# If parse failed, persist raw LLM output path for debugging
|
| 264 |
+
raw_dump = None
|
| 265 |
+
if not parsed:
|
| 266 |
+
raw_dump = _dump_raw_llm_output(llm_text)
|
| 267 |
|
| 268 |
return {
|
| 269 |
+
"status": "success" if parsed else "partial",
|
| 270 |
"file": os.path.basename(file_path),
|
| 271 |
+
"extracted_data": parsed if parsed else None,
|
| 272 |
"raw_llm_output": llm_text,
|
| 273 |
+
"raw_llm_dump_path": raw_dump,
|
| 274 |
"kpis": kpis
|
| 275 |
}
|
| 276 |
|
| 277 |
+
# ---------------- Agent orchestration and chat ----------------
|
| 278 |
def parse_and_execute(model_text: str, history: list) -> str:
|
| 279 |
payload = extract_json_safely(model_text)
|
| 280 |
if not payload:
|
| 281 |
+
return "No valid tool JSON found in model output. Raw output saved for debugging."
|
| 282 |
+
|
| 283 |
+
if isinstance(payload, dict):
|
| 284 |
+
cmds = [payload]
|
| 285 |
+
else:
|
| 286 |
+
cmds = payload
|
| 287 |
|
|
|
|
| 288 |
results = []
|
| 289 |
last_contact_id = None
|
| 290 |
|
|
|
|
| 292 |
if not isinstance(cmd, dict):
|
| 293 |
continue
|
| 294 |
tool = cmd.get("tool")
|
| 295 |
+
args = cmd.get("args", {})
|
| 296 |
|
| 297 |
if tool == "create_record":
|
| 298 |
+
module = args.get("module_name", "Contacts")
|
| 299 |
+
record = args.get("record_data", {})
|
| 300 |
+
res = create_record(module, record)
|
| 301 |
results.append(f"create_record -> {res}")
|
| 302 |
+
# attempt to capture id
|
| 303 |
try:
|
| 304 |
rj = json.loads(res)
|
| 305 |
+
if isinstance(rj, dict) and "data" in rj and isinstance(rj["data"], list) and rj["data"]:
|
| 306 |
+
last_contact_id = rj["data"][0].get("details", {}).get("id")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
except Exception:
|
| 308 |
pass
|
| 309 |
|
|
|
|
| 317 |
else:
|
| 318 |
results.append(f"Unknown tool: {tool}")
|
| 319 |
|
| 320 |
+
return "\n".join(results) if results else "No actionable tool calls executed."
|
| 321 |
|
|
|
|
| 322 |
def chat_logic(message: str, file_path: Optional[str], history: list) -> str:
|
|
|
|
| 323 |
if file_path:
|
| 324 |
+
logger.info("chat_logic: processing file %s", file_path)
|
| 325 |
doc = process_document(file_path)
|
| 326 |
+
status = doc.get("status")
|
| 327 |
+
if status in ("success", "partial"):
|
| 328 |
+
extracted = doc.get("extracted_data")
|
| 329 |
+
raw_llm = doc.get("raw_llm_output")
|
| 330 |
+
dump_path = doc.get("raw_llm_dump_path")
|
| 331 |
+
kpis = doc.get("kpis", {})
|
| 332 |
+
extracted_pretty = json.dumps(extracted, indent=2) if extracted else "(no structured JSON parsed)"
|
| 333 |
+
msg = (
|
| 334 |
+
f"### 📄 Extraction Result for **{doc.get('file')}**\n"
|
| 335 |
+
f"Status: {status}\n"
|
| 336 |
+
f"KPI Score: {kpis.get('score')} Rating: {kpis.get('rating')}\n"
|
| 337 |
+
f"OCR Confidence: {kpis.get('ocr_score', 'N/A')}\n\n"
|
| 338 |
+
f"Extracted JSON:\n```json\n{extracted_pretty}\n```\n"
|
|
|
|
| 339 |
)
|
| 340 |
+
if dump_path:
|
| 341 |
+
msg += f"\n⚠️ The model output could not be parsed into strict JSON. Raw LLM output saved to: `{dump_path}`\n"
|
| 342 |
+
msg += "You can inspect that file to debug the model response or prompt."
|
| 343 |
+
msg += "\nType 'Create Invoice' to persist when ready."
|
| 344 |
+
return msg
|
| 345 |
else:
|
| 346 |
+
return f"Error during processing: {doc.get('error')}"
|
| 347 |
|
| 348 |
+
# text-only interaction
|
| 349 |
hist_txt = "\n".join([f"U: {h[0]}\nA: {h[1]}" for h in history]) if history else ""
|
| 350 |
prompt = get_agent_prompt(hist_txt, message)
|
| 351 |
gen = local_llm_generate(prompt, max_tokens=256)
|
| 352 |
gen_text = gen.get("text", "")
|
| 353 |
|
|
|
|
| 354 |
tool_payload = extract_json_safely(gen_text)
|
| 355 |
if tool_payload:
|
| 356 |
return parse_and_execute(gen_text, history)
|
| 357 |
|
| 358 |
+
# if not a tool call, return the LLM text (or clear error)
|
| 359 |
+
if gen_text:
|
| 360 |
+
return gen_text
|
| 361 |
+
else:
|
| 362 |
+
return "No response from model."
|
| 363 |
|
| 364 |
+
# ---------------- Gradio wrapper ----------------
|
| 365 |
def chat_handler(msg, hist):
|
| 366 |
txt = msg.get("text", "")
|
| 367 |
files = msg.get("files", [])
|