PrathameshRaut commited on
Commit
e31d34c
·
verified ·
1 Parent(s): a9d4323

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +145 -93
app.py CHANGED
@@ -18,12 +18,13 @@ from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Security
18
  from fastapi.responses import JSONResponse
19
  from fastapi.security.api_key import APIKeyHeader
20
 
 
21
  from sarvamai import SarvamAI
22
 
23
  app = FastAPI(
24
  title="Document Extraction API",
25
- description="Extract structured data from documents using Sarvam Document Intelligence",
26
- version="4.0.0"
27
  )
28
 
29
  # ---------------------------------------------------------------------------
@@ -41,11 +42,11 @@ API_SECRET_KEY = os.environ.get("API_SECRET_KEY", "")
41
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
42
 
43
  # ---------------------------------------------------------------------------
44
- # Logging config
45
  # ---------------------------------------------------------------------------
46
 
47
- LOG_REPO_ID = "PrathameshRaut/VisionModelLogs"
48
- LOG_FILENAME = "extraction_logs.xlsx"
49
 
50
  LOG_HEADERS = [
51
  "timestamp_utc",
@@ -54,16 +55,17 @@ LOG_HEADERS = [
54
  "file_type",
55
  "document_type",
56
  "parameters_requested",
57
- "raw_vision_output", # raw output from Document Intelligence
58
- "extracted_json", # final JSON sent to caller
59
- "status", # "success" or "error"
60
  "error_detail",
61
  ]
62
 
 
63
  _log_lock = threading.Lock()
64
 
65
  # ---------------------------------------------------------------------------
66
- # Auth
67
  # ---------------------------------------------------------------------------
68
 
69
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
@@ -91,8 +93,10 @@ def _style_header_row(ws):
91
 
92
 
93
  def _download_or_create_workbook() -> openpyxl.Workbook:
 
94
  if not HF_TOKEN:
95
  raise RuntimeError("HF_TOKEN secret is not set — cannot write logs")
 
96
  try:
97
  local_path = hf_hub_download(
98
  repo_id=LOG_REPO_ID,
@@ -103,15 +107,24 @@ def _download_or_create_workbook() -> openpyxl.Workbook:
103
  )
104
  wb = openpyxl.load_workbook(local_path)
105
  except Exception:
 
106
  wb = openpyxl.Workbook()
107
  ws = wb.active
108
  ws.title = "Logs"
109
  ws.append(LOG_HEADERS)
110
  _style_header_row(ws)
 
111
  col_widths = {
112
- "A": 22, "B": 30, "C": 16, "D": 14,
113
- "E": 22, "F": 35, "G": 60, "H": 60,
114
- "I": 12, "J": 40,
 
 
 
 
 
 
 
115
  }
116
  for col, width in col_widths.items():
117
  ws.column_dimensions[col].width = width
@@ -120,9 +133,11 @@ def _download_or_create_workbook() -> openpyxl.Workbook:
120
 
121
 
122
  def _upload_workbook(wb: openpyxl.Workbook):
 
123
  buf = io.BytesIO()
124
  wb.save(buf)
125
  buf.seek(0)
 
126
  api = HfApi(token=HF_TOKEN)
127
  api.upload_file(
128
  path_or_fileobj=buf,
@@ -139,12 +154,14 @@ def append_log(
139
  file_type: str,
140
  document_type: str,
141
  parameters: list[str],
142
- raw_vision_output: str,
143
  extracted_json: dict | None,
144
  status: str,
145
  error_detail: str = "",
146
  ):
 
147
  if not HF_TOKEN:
 
148
  return
149
 
150
  row = [
@@ -154,7 +171,7 @@ def append_log(
154
  file_type,
155
  document_type,
156
  ", ".join(parameters),
157
- (raw_vision_output or "")[:5000],
158
  json.dumps(extracted_json, ensure_ascii=False) if extracted_json else "",
159
  status,
160
  error_detail[:1000],
@@ -164,8 +181,10 @@ def append_log(
164
  try:
165
  wb = _download_or_create_workbook()
166
  ws = wb.active
 
167
  ws.append(row)
168
 
 
169
  data_font = Font(name="Arial", size=10)
170
  wrap_align = Alignment(vertical="top", wrap_text=True)
171
  row_idx = ws.max_row
@@ -173,6 +192,7 @@ def append_log(
173
  cell.font = data_font
174
  cell.alignment = wrap_align
175
 
 
176
  if row_idx % 2 == 0:
177
  fill = PatternFill("solid", start_color="DCE6F1")
178
  for cell in ws[row_idx]:
@@ -180,6 +200,7 @@ def append_log(
180
 
181
  _upload_workbook(wb)
182
  except Exception as e:
 
183
  print(f"[LOG WARNING] Failed to write log entry: {e}")
184
 
185
 
@@ -218,44 +239,49 @@ def prepare_file(file_bytes: bytes, content_type: str, filename: str) -> tuple[b
218
 
219
 
220
  # ---------------------------------------------------------------------------
221
- # THE ONLY MODEL CALL: Sarvam Document Intelligence with extraction instructions
222
  # ---------------------------------------------------------------------------
223
 
224
- def extract_with_document_intelligence(
225
- file_bytes: bytes,
226
- filename: str,
227
- doc_type: str,
228
- parameters: list[str],
229
- ) -> tuple[dict, str]:
230
  """
231
- Single call to Sarvam Document Intelligence.
232
- The vision model reads the document AND returns structured JSON directly.
233
- No second model call needed.
 
 
234
 
235
- Returns: (extracted_json_dict, raw_output_string)
 
 
236
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  if not SARVAM_API_KEY:
238
  raise HTTPException(status_code=500, detail="SARVAM_API_KEY is not configured on the server")
239
 
240
  client = SarvamAI(api_subscription_key=SARVAM_API_KEY)
241
 
242
- description = SUPPORTED_TYPES[doc_type]
243
- param_list = ", ".join(parameters)
244
- param_bullets = "\n".join(f"- {p}" for p in parameters)
245
-
246
- # This instruction goes directly to the vision model upfront.
247
- # It reads the document AND extracts — no second model needed.
248
- instructions = (
249
- f"This document is a {description}.\n"
250
- f"Your task: extract ONLY the following fields and return a single valid JSON object.\n"
251
- f"Fields to extract:\n{param_bullets}\n\n"
252
- f"Rules:\n"
253
- f"1. Return ONLY a valid JSON object. No explanation, no markdown, no code fences.\n"
254
- f"2. Use exactly these field names as JSON keys: {param_list}\n"
255
- f"3. If a field is not found or not legible, set its value to null.\n"
256
- f"4. Do not add any fields not listed above."
257
- )
258
-
259
  suffix = Path(filename).suffix.lower()
260
  with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
261
  tmp.write(file_bytes)
@@ -264,11 +290,7 @@ def extract_with_document_intelligence(
264
  zip_path = tmp_path + "_output.zip"
265
 
266
  try:
267
- job = client.document_intelligence.create_job(
268
- language="en-IN",
269
- output_format="json", # ask for JSON output
270
- description=instructions, # tell it exactly what fields to extract
271
- )
272
  job.upload_file(tmp_path)
273
  job.start()
274
  status = job.wait_until_complete()
@@ -281,43 +303,15 @@ def extract_with_document_intelligence(
281
 
282
  job.download_output(zip_path)
283
 
284
- raw_output = ""
285
  with zipfile.ZipFile(zip_path, "r") as z:
286
- all_files = sorted(z.namelist())
287
- json_files = [n for n in all_files if n.endswith(".json")]
288
- md_files = [n for n in all_files if n.endswith(".md")]
289
-
290
- # Prefer .json output files first
291
- for name in json_files:
292
- content = z.read(name).decode("utf-8", errors="replace").strip()
293
- if content:
294
- raw_output = content
295
- break
296
-
297
- # Fallback to .md if no JSON file found
298
- if not raw_output and md_files:
299
- raw_output = "\n\n".join(
300
- z.read(n).decode("utf-8", errors="replace") for n in md_files
301
- )
302
-
303
- if not raw_output:
304
- raise HTTPException(status_code=500, detail="No output found in Sarvam result")
305
-
306
- # Clean up any <think>...</think> blocks or markdown fences the model may add
307
- clean = re.sub(r"<think>.*?</think>", "", raw_output, flags=re.DOTALL).strip()
308
- clean = re.sub(r"^```[a-z]*\n?", "", clean, flags=re.MULTILINE)
309
- clean = clean.rstrip("`").strip()
310
-
311
- try:
312
- extracted = json.loads(clean)
313
- except json.JSONDecodeError:
314
- # Graceful degradation — return raw so caller still gets something
315
- extracted = {
316
- "raw_response": clean,
317
- "parse_error": "Model did not return valid JSON"
318
- }
319
 
320
- return extracted, raw_output
321
 
322
  finally:
323
  for path in (tmp_path, zip_path):
@@ -326,6 +320,55 @@ def extract_with_document_intelligence(
326
  except FileNotFoundError:
327
  pass
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
  # ---------------------------------------------------------------------------
331
  # Routes
@@ -334,12 +377,11 @@ def extract_with_document_intelligence(
334
  @app.get("/")
335
  def root():
336
  return {
337
- "message": "Document Extraction API powered by Sarvam Document Intelligence",
338
  "supported_document_types": list(SUPPORTED_TYPES.keys()),
339
  "docs": "/docs"
340
  }
341
 
342
-
343
  @app.get("/types")
344
  def get_supported_types():
345
  return {"supported_types": {k: {"description": v} for k, v in SUPPORTED_TYPES.items()}}
@@ -353,7 +395,6 @@ async def extract_document(
353
  ):
354
  """
355
  Extract structured JSON data from an uploaded document.
356
- Uses a single Sarvam Document Intelligence call — no second model.
357
  Requires header: **X-API-Key: your-secret-key**
358
  """
359
 
@@ -381,7 +422,9 @@ async def extract_document(
381
  filename = file.filename or "document"
382
  file_size = len(file_bytes)
383
 
384
- # Step 1 — convert DOCX/PPTX to PDF if needed
 
 
385
  try:
386
  processed_bytes, processed_name = prepare_file(file_bytes, content_type, filename)
387
  except HTTPException as exc:
@@ -392,12 +435,8 @@ async def extract_document(
392
  )
393
  raise
394
 
395
- # Step 2 — single vision model call: read + extract in one shot
396
  try:
397
- extracted, raw_output = await asyncio.to_thread(
398
- extract_with_document_intelligence,
399
- processed_bytes, processed_name, document_type, param_list
400
- )
401
  except HTTPException as exc:
402
  asyncio.get_event_loop().run_in_executor(
403
  None, append_log,
@@ -406,11 +445,24 @@ async def extract_document(
406
  )
407
  raise
408
 
409
- # Fire-and-forget logging
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  asyncio.get_event_loop().run_in_executor(
411
  None, append_log,
412
  filename, file_size, content_type, document_type, param_list,
413
- raw_output, extracted, "success", ""
414
  )
415
 
416
  return JSONResponse(content={
 
18
  from fastapi.responses import JSONResponse
19
  from fastapi.security.api_key import APIKeyHeader
20
 
21
+ import httpx
22
  from sarvamai import SarvamAI
23
 
24
  app = FastAPI(
25
  title="Document Extraction API",
26
+ description="Extract structured data from documents using Sarvam Vision",
27
+ version="3.0.0"
28
  )
29
 
30
  # ---------------------------------------------------------------------------
 
42
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
43
 
44
  # ---------------------------------------------------------------------------
45
+ # Logging config — edit these two constants to match your HF dataset repo
46
  # ---------------------------------------------------------------------------
47
 
48
+ LOG_REPO_ID = "PrathameshRaut/VisionModelLogs" # dataset repo you create on HF
49
+ LOG_FILENAME = "extraction_logs.xlsx" # file inside that repo
50
 
51
  LOG_HEADERS = [
52
  "timestamp_utc",
 
55
  "file_type",
56
  "document_type",
57
  "parameters_requested",
58
+ "model_raw_thinking", # raw markdown text from Sarvam Document Intelligence
59
+ "extracted_json", # final JSON response sent to caller
60
+ "status", # "success" or "error"
61
  "error_detail",
62
  ]
63
 
64
+ # A threading lock so concurrent requests don't corrupt the xlsx
65
  _log_lock = threading.Lock()
66
 
67
  # ---------------------------------------------------------------------------
68
+ # Auth — X-API-Key header guard
69
  # ---------------------------------------------------------------------------
70
 
71
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
 
93
 
94
 
95
  def _download_or_create_workbook() -> openpyxl.Workbook:
96
+ """Download the existing log workbook from HF, or create a fresh one."""
97
  if not HF_TOKEN:
98
  raise RuntimeError("HF_TOKEN secret is not set — cannot write logs")
99
+
100
  try:
101
  local_path = hf_hub_download(
102
  repo_id=LOG_REPO_ID,
 
107
  )
108
  wb = openpyxl.load_workbook(local_path)
109
  except Exception:
110
+ # File doesn't exist yet — start fresh
111
  wb = openpyxl.Workbook()
112
  ws = wb.active
113
  ws.title = "Logs"
114
  ws.append(LOG_HEADERS)
115
  _style_header_row(ws)
116
+ # Set column widths
117
  col_widths = {
118
+ "A": 22, # timestamp
119
+ "B": 30, # filename
120
+ "C": 16, # file_size
121
+ "D": 14, # file_type
122
+ "E": 22, # document_type
123
+ "F": 35, # parameters
124
+ "G": 60, # model_raw_thinking
125
+ "H": 60, # extracted_json
126
+ "I": 12, # status
127
+ "J": 40, # error_detail
128
  }
129
  for col, width in col_widths.items():
130
  ws.column_dimensions[col].width = width
 
133
 
134
 
135
  def _upload_workbook(wb: openpyxl.Workbook):
136
+ """Save workbook to a buffer and push to HF dataset repo."""
137
  buf = io.BytesIO()
138
  wb.save(buf)
139
  buf.seek(0)
140
+
141
  api = HfApi(token=HF_TOKEN)
142
  api.upload_file(
143
  path_or_fileobj=buf,
 
154
  file_type: str,
155
  document_type: str,
156
  parameters: list[str],
157
+ raw_thinking: str,
158
  extracted_json: dict | None,
159
  status: str,
160
  error_detail: str = "",
161
  ):
162
+ """Thread-safe: download → append row → upload."""
163
  if not HF_TOKEN:
164
+ # Logging silently skipped if token not configured
165
  return
166
 
167
  row = [
 
171
  file_type,
172
  document_type,
173
  ", ".join(parameters),
174
+ (raw_thinking or "")[:5000], # cap at 5 000 chars to keep xlsx sane
175
  json.dumps(extracted_json, ensure_ascii=False) if extracted_json else "",
176
  status,
177
  error_detail[:1000],
 
181
  try:
182
  wb = _download_or_create_workbook()
183
  ws = wb.active
184
+
185
  ws.append(row)
186
 
187
+ # Style the new data row
188
  data_font = Font(name="Arial", size=10)
189
  wrap_align = Alignment(vertical="top", wrap_text=True)
190
  row_idx = ws.max_row
 
192
  cell.font = data_font
193
  cell.alignment = wrap_align
194
 
195
+ # Zebra striping: light blue on even rows
196
  if row_idx % 2 == 0:
197
  fill = PatternFill("solid", start_color="DCE6F1")
198
  for cell in ws[row_idx]:
 
200
 
201
  _upload_workbook(wb)
202
  except Exception as e:
203
+ # Logging must never crash the main API
204
  print(f"[LOG WARNING] Failed to write log entry: {e}")
205
 
206
 
 
239
 
240
 
241
  # ---------------------------------------------------------------------------
242
+ # Text cleanup strip base64-encoded image blobs from OCR output
243
  # ---------------------------------------------------------------------------
244
 
245
+ def clean_raw_text(text: str) -> str:
 
 
 
 
 
246
  """
247
+ Remove base64-encoded image data that Sarvam Document Intelligence
248
+ carries over from PDFs/images into the markdown output.
249
+
250
+ These blobs are never useful for field extraction and can easily push
251
+ the prompt far beyond sarvam-m's 7 192-token context window.
252
 
253
+ Two patterns are handled:
254
+ 1. Markdown image syntax: ![alt](data:image/png;base64,<data>)
255
+ 2. Bare data URIs that leaked outside markdown syntax
256
  """
257
+ # Pattern 1 — markdown image embed
258
+ text = re.sub(
259
+ r'!\[[^\]]*\]\(data:[a-zA-Z]+/[a-zA-Z+\-]+;base64,[A-Za-z0-9+/=\s]+\)',
260
+ '',
261
+ text,
262
+ flags=re.DOTALL,
263
+ )
264
+ # Pattern 2 — bare data URI (must be at least 100 chars of base64 to avoid
265
+ # accidentally removing short legitimate strings)
266
+ text = re.sub(
267
+ r'data:[a-zA-Z]+/[a-zA-Z+\-]+;base64,[A-Za-z0-9+/=\s]{100,}',
268
+ '',
269
+ text,
270
+ flags=re.DOTALL,
271
+ )
272
+ return text.strip()
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Step 1: Extract raw text via Sarvam Document Intelligence SDK
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
280
  if not SARVAM_API_KEY:
281
  raise HTTPException(status_code=500, detail="SARVAM_API_KEY is not configured on the server")
282
 
283
  client = SarvamAI(api_subscription_key=SARVAM_API_KEY)
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  suffix = Path(filename).suffix.lower()
286
  with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
287
  tmp.write(file_bytes)
 
290
  zip_path = tmp_path + "_output.zip"
291
 
292
  try:
293
+ job = client.document_intelligence.create_job(language="en-IN", output_format="md")
 
 
 
 
294
  job.upload_file(tmp_path)
295
  job.start()
296
  status = job.wait_until_complete()
 
303
 
304
  job.download_output(zip_path)
305
 
 
306
  with zipfile.ZipFile(zip_path, "r") as z:
307
+ md_files = sorted(n for n in z.namelist() if n.endswith(".md"))
308
+ if not md_files:
309
+ raise HTTPException(status_code=500, detail="No markdown output found in Sarvam result")
310
+ full_text = "\n\n".join(
311
+ z.read(name).decode("utf-8", errors="replace") for name in md_files
312
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
+ return full_text
315
 
316
  finally:
317
  for path in (tmp_path, zip_path):
 
320
  except FileNotFoundError:
321
  pass
322
 
323
+ # ---------------------------------------------------------------------------
324
+ # Step 2: Extract structured JSON via Sarvam Chat (sarvam-m)
325
+ # ---------------------------------------------------------------------------
326
+
327
+ async def extract_json_via_chat(doc_type: str, raw_text: str, parameters: list[str]) -> dict:
328
+ description = SUPPORTED_TYPES[doc_type]
329
+ param_list = "\n".join(f" - {p}" for p in parameters)
330
+
331
+ prompt = f"""You are a document data extraction assistant. Below is the full text extracted from a {description}.
332
+ --- DOCUMENT TEXT START ---
333
+ {raw_text}
334
+ --- DOCUMENT TEXT END ---
335
+ Extract the following fields from the document text above:
336
+ {param_list}
337
+ Rules:
338
+ 1. Return ONLY a valid JSON object. No explanations, no markdown, no code fences.
339
+ 2. Use exactly the field names listed above as JSON keys.
340
+ 3. If a field is not found or not legible, set its value to null.
341
+ 4. Do not invent or infer values not explicitly present in the document.
342
+ Respond with the JSON object only."""
343
+
344
+ async with httpx.AsyncClient(timeout=60) as http:
345
+ resp = await http.post(
346
+ "https://api.sarvam.ai/v1/chat/completions",
347
+ headers={"api-subscription-key": SARVAM_API_KEY, "Content-Type": "application/json"},
348
+ json={
349
+ "model": "sarvam-m",
350
+ "messages": [{"role": "user", "content": prompt}],
351
+ "max_tokens": 2048,
352
+ "temperature": 0
353
+ }
354
+ )
355
+
356
+ if resp.status_code != 200:
357
+ raise HTTPException(status_code=resp.status_code, detail=f"Sarvam chat error: {resp.text}")
358
+
359
+ raw = resp.json()["choices"][0]["message"]["content"].strip()
360
+
361
+ raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
362
+
363
+ if raw.startswith("```"):
364
+ raw = raw.split("\n", 1)[-1]
365
+ if raw.endswith("```"):
366
+ raw = raw[: raw.rfind("```")]
367
+
368
+ try:
369
+ return json.loads(raw.strip())
370
+ except json.JSONDecodeError:
371
+ return {"raw_response": raw, "parse_error": "Model did not return valid JSON"}
372
 
373
  # ---------------------------------------------------------------------------
374
  # Routes
 
377
  @app.get("/")
378
  def root():
379
  return {
380
+ "message": "Document Extraction API powered by Sarvam Vision",
381
  "supported_document_types": list(SUPPORTED_TYPES.keys()),
382
  "docs": "/docs"
383
  }
384
 
 
385
  @app.get("/types")
386
  def get_supported_types():
387
  return {"supported_types": {k: {"description": v} for k, v in SUPPORTED_TYPES.items()}}
 
395
  ):
396
  """
397
  Extract structured JSON data from an uploaded document.
 
398
  Requires header: **X-API-Key: your-secret-key**
399
  """
400
 
 
422
  filename = file.filename or "document"
423
  file_size = len(file_bytes)
424
 
425
+ raw_text = ""
426
+ extracted = {}
427
+
428
  try:
429
  processed_bytes, processed_name = prepare_file(file_bytes, content_type, filename)
430
  except HTTPException as exc:
 
435
  )
436
  raise
437
 
 
438
  try:
439
+ raw_text = await asyncio.to_thread(extract_text_with_sdk, processed_bytes, processed_name)
 
 
 
440
  except HTTPException as exc:
441
  asyncio.get_event_loop().run_in_executor(
442
  None, append_log,
 
445
  )
446
  raise
447
 
448
+ # Strip base64 image blobs before sending to sarvam-m
449
+ raw_text = clean_raw_text(raw_text)
450
+
451
+ try:
452
+ extracted = await extract_json_via_chat(document_type, raw_text, param_list)
453
+ except HTTPException as exc:
454
+ asyncio.get_event_loop().run_in_executor(
455
+ None, append_log,
456
+ filename, file_size, content_type, document_type, param_list,
457
+ raw_text, None, "error", str(exc.detail)
458
+ )
459
+ raise
460
+
461
+ # Fire-and-forget logging (don't block the response)
462
  asyncio.get_event_loop().run_in_executor(
463
  None, append_log,
464
  filename, file_size, content_type, document_type, param_list,
465
+ raw_text, extracted, "success", ""
466
  )
467
 
468
  return JSONResponse(content={