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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +93 -145
app.py CHANGED
@@ -18,13 +18,12 @@ 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
- 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,11 +41,11 @@ API_SECRET_KEY = os.environ.get("API_SECRET_KEY", "")
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,17 +54,16 @@ LOG_HEADERS = [
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,10 +91,8 @@ def _style_header_row(ws):
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,24 +103,15 @@ def _download_or_create_workbook() -> openpyxl.Workbook:
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,11 +120,9 @@ def _download_or_create_workbook() -> openpyxl.Workbook:
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,14 +139,12 @@ def append_log(
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,7 +154,7 @@ def append_log(
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,10 +164,8 @@ def append_log(
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,7 +173,6 @@ def append_log(
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,7 +180,6 @@ def append_log(
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,49 +218,44 @@ def prepare_file(file_bytes: bytes, content_type: str, filename: str) -> tuple[b
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,7 +264,11 @@ def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
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,15 +281,43 @@ def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
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,55 +326,6 @@ def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
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,11 +334,12 @@ Respond with the JSON object only."""
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,6 +353,7 @@ async def extract_document(
395
  ):
396
  """
397
  Extract structured JSON data from an uploaded document.
 
398
  Requires header: **X-API-Key: your-secret-key**
399
  """
400
 
@@ -422,9 +381,7 @@ async def extract_document(
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,34 +392,25 @@ async def extract_document(
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,
443
- filename, file_size, content_type, document_type, param_list,
444
- "", None, "error", str(exc.detail)
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={
 
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
  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
  "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
 
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
  )
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
 
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
  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
  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
  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
  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
 
181
  _upload_workbook(wb)
182
  except Exception as e:
 
183
  print(f"[LOG WARNING] Failed to write log entry: {e}")
184
 
185
 
 
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
  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
 
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
  except FileNotFoundError:
327
  pass
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
  # ---------------------------------------------------------------------------
331
  # Routes
 
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
  ):
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
  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
  )
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,
404
  filename, file_size, content_type, document_type, param_list,
405
+ "", None, "error", str(exc.detail)
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={