vachaspathi commited on
Commit
8f61322
·
verified ·
1 Parent(s): 274213e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +183 -228
app.py CHANGED
@@ -1,11 +1,12 @@
1
- # app.py — MCP server (single-file, refined)
2
- # - Robust JSON extraction/repair
3
- # - Safer model loading (use dtype param)
4
- # - Better logging and error handling
5
- # - Uses prompts.py functions
 
6
 
7
  from mcp.server.fastmcp import FastMCP
8
- from typing import Optional, Any, Dict, List
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 math
 
17
 
18
- # --- Import OCR Engine & Prompts ---
 
 
 
 
19
  try:
20
- from ocr_engine import extract_text_and_conf
21
- from prompts import get_ocr_extraction_prompt, get_agent_prompt
22
- except ImportError:
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
- # --- NEW: Evaluation / KPI Logic (Integrated OCR Score) ---
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 _remove_wrapping_code_blocks(s: str) -> str:
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
- # 1) Keep printable characters (allow newline/tab)
116
  s = "".join(ch for ch in s if (ch == "\n" or ch == "\t" or (32 <= ord(ch) <= 0x10FFFF)))
117
- # 2) Remove trailing commas before } or ]
118
  s = re.sub(r",\s*(\}|])", r"\1", s)
119
- # 3) Convert lone single-quoted JSON to double quotes only if safe
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 an LLM output string.
129
- Returns Python object or None.
 
 
 
 
130
  """
131
  if not text:
132
  return None
133
 
134
- # 1) Direct parse
135
  parsed = _try_json_loads(text)
136
  if parsed is not None:
137
  return parsed
138
 
139
- # 2) Marker-based extraction
140
- marker_pattern = re.compile(r"<<<JSON>>>\s*([\s\S]*?)\s*<<<END_JSON>>>", re.IGNORECASE)
141
- m = marker_pattern.search(text)
142
  if m:
143
- candidate = _remove_wrapping_code_blocks(m.group(1))
144
- parsed = _try_json_loads(candidate)
145
- if parsed is not None:
146
- return parsed
147
- repaired = _attempt_simple_repairs(candidate)
148
  try:
149
- return json.loads(repaired)
150
  except Exception as e:
151
- logger.warning("Marker JSON parse/repair failed: %s", e)
152
 
153
- # 3) Fallback: try to extract largest balanced {...} span
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
- brace_spans.append((start, i))
162
- # sort by length desc
163
- brace_spans = sorted(brace_spans, key=lambda t: t[1] - t[0], reverse=True)
164
- seen = set()
165
- for start, end in brace_spans:
166
- if (start, end) in seen:
167
  continue
168
- seen.add((start, end))
169
- candidate = text[start:end+1].strip()
170
- if len(candidate) < 20:
171
- continue
172
- candidate = _remove_wrapping_code_blocks(candidate)
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(repaired)
179
  except Exception:
180
  continue
181
 
182
- # 4) Try arrays as last resort
183
- arr_match = re.search(r"(\[[\s\S]*\])", text)
184
- if arr_match:
185
- candidate = _remove_wrapping_code_blocks(arr_match.group(1))
186
- parsed = _try_json_loads(candidate)
187
- if parsed is not None:
188
- return parsed
189
- repaired = _attempt_simple_repairs(candidate)
190
  try:
191
- return json.loads(repaired)
192
- except:
193
  pass
194
 
195
- # 5) Give up
196
- excerpt = text[:2000] if len(text) > 2000 else text
197
- logger.error("Failed to extract JSON. Excerpt:\n%s", excerpt)
198
  return None
199
 
200
- def _normalize_local_path_args(args: Any) -> Any:
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
- # prefer dtype over deprecated torch_dtype
221
- dtype = torch.float16 if torch.cuda.is_available() else torch.float32
 
 
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 loaded.")
225
  except Exception as e:
226
- logger.exception("Model load error: %s", e)
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
- # pipeline output shapes vary; try to extract generated text robustly
241
- if isinstance(out, list) and len(out) > 0:
 
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 failed: %s", e)
257
- return {"text": f"Error: {e}", "raw": None}
258
 
259
- # --- Tools (Zoho) ---
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=10)
266
  if r.status_code == 200:
267
- return {"Authorization": f"Zoho-oauthtoken {r.json().get('access_token')}"}
268
- logger.error("Zoho token error: %s", r.text)
 
 
 
269
  except Exception as e:
270
- logger.exception("Token request failed: %s", e)
271
- return {}
272
 
 
273
  @mcp.tool()
274
  def create_record(module_name: str, record_data: dict) -> str:
275
- h = _get_valid_token_headers()
276
- if not h:
277
- return json.dumps({"status": "error", "message": "Auth Failed"})
278
  try:
279
- r = requests.post(f"{API_BASE}/{module_name}", headers=h, json={"data": [record_data]}, timeout=15)
280
- 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})
281
  except Exception as e:
282
  logger.exception("create_record failed: %s", e)
283
- return json.dumps({"status": "error", "message": str(e)})
284
 
285
  @mcp.tool()
286
  def create_invoice(data: dict) -> str:
287
- h = _get_valid_token_headers()
288
- if not h:
289
- return json.dumps({"status": "error", "message": "Auth Failed"})
290
  try:
291
- r = requests.post(f"{INVOICE_API_BASE}/invoices", headers=h,
292
- params={"organization_id": ORGANIZATION_ID}, json=data, timeout=15)
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": "error", "message": str(e)})
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 at path: {file_path}"}
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 or failed."}
307
 
308
- # 2) LLM extraction
309
  prompt = get_ocr_extraction_prompt(raw_text, page_count=1)
310
- res = local_llm_generate(prompt, max_tokens=512)
311
- llm_text = res.get("text", "")
312
- data = extract_json_safely(llm_text)
313
 
314
- # 3) KPI calculation
315
- kpis = {"score": 0, "rating": "Fail", "issues": ["Extraction Failed"]}
316
- if data:
317
- kpis = calculate_extraction_confidence(data, ocr_score)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
  return {
320
- "status": "success",
321
  "file": os.path.basename(file_path),
322
- "extracted_data": data if data else {"raw": llm_text},
323
  "raw_llm_output": llm_text,
 
324
  "kpis": kpis
325
  }
326
 
327
- # --- Executor for tool-calls ---
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 call found in model output."
 
 
 
 
 
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 = _normalize_local_path_args(cmd.get("args", {}))
342
 
343
  if tool == "create_record":
344
- module_name = args.get("module_name", "Contacts")
345
- record_data = args.get("record_data", {})
346
- res = create_record(module_name, record_data)
347
  results.append(f"create_record -> {res}")
 
348
  try:
349
  rj = json.loads(res)
350
- # try to locate id in common locations
351
- if isinstance(rj, dict):
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 commands executed."
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("Processing uploaded file: %s", file_path)
376
  doc = process_document(file_path)
377
- if doc.get("status") == "success":
378
- data = doc.get("extracted_data")
379
- kpi = doc.get("kpis", {})
380
- extracted_json = json.dumps(data, indent=2) if not isinstance(data, str) else data
381
- rating_emoji = "🟢" if kpi.get('rating') == 'High' else ("🟡" if kpi.get('rating') == 'Medium' else "🔴")
382
- issues_txt = "\n".join([f"- {i}" for i in kpi.get('issues', [])]) if kpi.get('issues') else "None"
383
-
384
- return (
385
- f"### 📄 Extraction Complete: **{doc.get('file')}**\n"
386
- f"**Combined Confidence:** {rating_emoji} {kpi.get('score')}/100\n"
387
- f"*(OCR Signal: {kpi.get('ocr_score')}% | Data Quality: {kpi.get('semantic_score')}%)*\n\n"
388
- f"**Issues Detected:**\n{issues_txt}\n\n"
389
- f"```json\n{extracted_json}\n```\n\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"OCR/Extraction error: {doc.get('error')}"
394
 
395
- # PHASE: Text Interaction -> Agent orchestrator
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
- # Otherwise return the LLM text (human facing)
407
- return gen_text or "No response from model."
 
 
 
408
 
409
- # --- UI wrapper ---
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", [])