PrathameshRaut commited on
Commit
1ba4cf2
·
verified ·
1 Parent(s): 849fa49

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +200 -59
app.py CHANGED
@@ -5,8 +5,14 @@ import zipfile
5
  import asyncio
6
  import subprocess
7
  import tempfile
 
 
8
  from pathlib import Path
9
 
 
 
 
 
10
  from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Security
11
  from fastapi.responses import JSONResponse
12
  from fastapi.security.api_key import APIKeyHeader
@@ -25,13 +31,37 @@ app = FastAPI(
25
  # ---------------------------------------------------------------------------
26
 
27
  SUPPORTED_TYPES = {
28
- "income_certificate": "Income certificate issued by a government authority",
29
- "caste_certificate": "Caste certificate issued by a government authority",
30
- "domicile_certificate":"Domicile / residence certificate issued by a government authority",
31
  }
32
 
33
- SARVAM_API_KEY = os.environ.get("SARVAM_API_KEY", "")
34
- API_SECRET_KEY = os.environ.get("API_SECRET_KEY", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  # ---------------------------------------------------------------------------
37
  # Auth — X-API-Key header guard
@@ -48,6 +78,131 @@ async def verify_api_key(key: str = Security(api_key_header)):
48
  detail="Invalid or missing API key. Send it as request header: X-API-Key: <your-key>"
49
  )
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  # ---------------------------------------------------------------------------
52
  # File conversion — DOCX/PPTX → PDF via LibreOffice; images/PDFs pass through
53
  # ---------------------------------------------------------------------------
@@ -56,16 +211,10 @@ NATIVE_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg"}
56
  NATIVE_MIME = {"application/pdf", "image/png", "image/jpeg", "image/jpg"}
57
 
58
  def prepare_file(file_bytes: bytes, content_type: str, filename: str) -> tuple[bytes, str]:
59
- """
60
- Returns (file_bytes, filename) ready to upload to Sarvam.
61
- Converts DOCX/PPTX/etc. to PDF first if needed.
62
- """
63
  suffix = Path(filename).suffix.lower()
64
-
65
  if suffix in NATIVE_EXTENSIONS or content_type in NATIVE_MIME:
66
  return file_bytes, filename
67
 
68
- # Convert to PDF via LibreOffice
69
  with tempfile.TemporaryDirectory() as tmpdir:
70
  src = os.path.join(tmpdir, "input" + suffix)
71
  with open(src, "wb") as f:
@@ -88,25 +237,15 @@ def prepare_file(file_bytes: bytes, content_type: str, filename: str) -> tuple[b
88
  return f.read(), Path(filename).stem + ".pdf"
89
 
90
  # ---------------------------------------------------------------------------
91
- # Step 1: Extract raw text from document via Sarvam Document Intelligence SDK
92
  # ---------------------------------------------------------------------------
93
 
94
  def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
95
- """
96
- Uses the official sarvamai SDK to:
97
- 1. Create a job
98
- 2. Upload the file
99
- 3. Start processing
100
- 4. Wait for completion
101
- 5. Download ZIP output
102
- 6. Return concatenated markdown text
103
- """
104
  if not SARVAM_API_KEY:
105
  raise HTTPException(status_code=500, detail="SARVAM_API_KEY is not configured on the server")
106
 
107
  client = SarvamAI(api_subscription_key=SARVAM_API_KEY)
108
 
109
- # Write file to a temp path — SDK's upload_file expects a file path
110
  suffix = Path(filename).suffix.lower()
111
  with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
112
  tmp.write(file_bytes)
@@ -115,10 +254,7 @@ def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
115
  zip_path = tmp_path + "_output.zip"
116
 
117
  try:
118
- job = client.document_intelligence.create_job(
119
- language="en-IN",
120
- output_format="md"
121
- )
122
  job.upload_file(tmp_path)
123
  job.start()
124
  status = job.wait_until_complete()
@@ -131,14 +267,12 @@ def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
131
 
132
  job.download_output(zip_path)
133
 
134
- # Unpack ZIP and join all markdown pages in order
135
  with zipfile.ZipFile(zip_path, "r") as z:
136
  md_files = sorted(n for n in z.namelist() if n.endswith(".md"))
137
  if not md_files:
138
  raise HTTPException(status_code=500, detail="No markdown output found in Sarvam result")
139
  full_text = "\n\n".join(
140
- z.read(name).decode("utf-8", errors="replace")
141
- for name in md_files
142
  )
143
 
144
  return full_text
@@ -151,7 +285,7 @@ def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
151
  pass
152
 
153
  # ---------------------------------------------------------------------------
154
- # Step 2: Extract structured JSON from raw text via Sarvam Chat (sarvam-m)
155
  # ---------------------------------------------------------------------------
156
 
157
  async def extract_json_via_chat(doc_type: str, raw_text: str, parameters: list[str]) -> dict:
@@ -159,20 +293,16 @@ async def extract_json_via_chat(doc_type: str, raw_text: str, parameters: list[s
159
  param_list = "\n".join(f" - {p}" for p in parameters)
160
 
161
  prompt = f"""You are a document data extraction assistant. Below is the full text extracted from a {description}.
162
-
163
  --- DOCUMENT TEXT START ---
164
  {raw_text}
165
  --- DOCUMENT TEXT END ---
166
-
167
  Extract the following fields from the document text above:
168
  {param_list}
169
-
170
  Rules:
171
  1. Return ONLY a valid JSON object. No explanations, no markdown, no code fences.
172
  2. Use exactly the field names listed above as JSON keys.
173
  3. If a field is not found or not legible, set its value to null.
174
  4. Do not invent or infer values not explicitly present in the document.
175
-
176
  Respond with the JSON object only."""
177
 
178
  async with httpx.AsyncClient(timeout=60) as http:
@@ -192,11 +322,9 @@ Respond with the JSON object only."""
192
 
193
  raw = resp.json()["choices"][0]["message"]["content"].strip()
194
 
195
- # Strip <think>...</think> reasoning block if present
196
  import re
197
  raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
198
 
199
- # Strip markdown fences if the model wraps its response
200
  if raw.startswith("```"):
201
  raw = raw.split("\n", 1)[-1]
202
  if raw.endswith("```"):
@@ -221,10 +349,8 @@ def root():
221
 
222
  @app.get("/types")
223
  def get_supported_types():
224
- """List all supported document types."""
225
- return {
226
- "supported_types": {k: {"description": v} for k, v in SUPPORTED_TYPES.items()}
227
- }
228
 
229
  @app.post("/extract", dependencies=[Security(verify_api_key)])
230
  async def extract_document(
@@ -234,15 +360,9 @@ async def extract_document(
234
  ):
235
  """
236
  Extract structured JSON data from an uploaded document.
237
-
238
  Requires header: **X-API-Key: your-secret-key**
239
-
240
- - **file**: The document (image, PDF, DOCX, PPTX, etc.)
241
- - **document_type**: Must be a supported type
242
- - **parameters**: You define what to extract — any field names, comma-separated
243
  """
244
 
245
- # Validate document type
246
  if document_type not in SUPPORTED_TYPES:
247
  raise HTTPException(
248
  status_code=400,
@@ -252,7 +372,6 @@ async def extract_document(
252
  }
253
  )
254
 
255
- # Parse parameters
256
  param_list = [p.strip() for p in parameters.split(",") if p.strip()]
257
  if not param_list:
258
  raise HTTPException(
@@ -260,32 +379,53 @@ async def extract_document(
260
  detail="'parameters' must contain at least one field name (e.g. 'name,income,date_of_issue')"
261
  )
262
 
263
- # Read uploaded file
264
- file_bytes = await file.read()
265
  if not file_bytes:
266
  raise HTTPException(status_code=400, detail="Uploaded file is empty")
267
 
268
  content_type = file.content_type or ""
269
  filename = file.filename or "document"
 
 
 
 
270
 
271
- # Convert to PDF/image if needed
272
  try:
273
  processed_bytes, processed_name = prepare_file(file_bytes, content_type, filename)
274
- except HTTPException:
 
 
 
 
 
275
  raise
276
- except Exception as e:
277
- raise HTTPException(status_code=500, detail=f"File preparation error: {str(e)}")
278
 
279
- # Step 1: OCR + text extraction via Sarvam Document Intelligence (blocking SDK call in thread)
280
  try:
281
  raw_text = await asyncio.to_thread(extract_text_with_sdk, processed_bytes, processed_name)
282
- except HTTPException:
 
 
 
 
 
283
  raise
284
- except Exception as e:
285
- raise HTTPException(status_code=500, detail=f"Document intelligence error: {str(e)}")
286
 
287
- # Step 2: JSON extraction via Sarvam Chat
288
- extracted = await extract_json_via_chat(document_type, raw_text, param_list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
  return JSONResponse(content={
291
  "document_type": document_type,
@@ -294,6 +434,7 @@ async def extract_document(
294
  "extracted_data": extracted
295
  })
296
 
 
297
  @app.get("/health")
298
  def health():
299
  return {"status": "ok"}
 
5
  import asyncio
6
  import subprocess
7
  import tempfile
8
+ import threading
9
+ from datetime import datetime, timezone
10
  from pathlib import Path
11
 
12
+ import openpyxl
13
+ from openpyxl.styles import Font, PatternFill, Alignment
14
+ from huggingface_hub import HfApi, hf_hub_download
15
+
16
  from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Security
17
  from fastapi.responses import JSONResponse
18
  from fastapi.security.api_key import APIKeyHeader
 
31
  # ---------------------------------------------------------------------------
32
 
33
  SUPPORTED_TYPES = {
34
+ "income_certificate": "Income certificate issued by a government authority",
35
+ "caste_certificate": "Caste certificate issued by a government authority",
36
+ "domicile_certificate": "Domicile / residence certificate issued by a government authority",
37
  }
38
 
39
+ SARVAM_API_KEY = os.environ.get("SARVAM_API_KEY", "")
40
+ API_SECRET_KEY = os.environ.get("API_SECRET_KEY", "")
41
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Logging config — edit these two constants to match your HF dataset repo
45
+ # ---------------------------------------------------------------------------
46
+
47
+ LOG_REPO_ID = "PrathameshRaut/VisionModelLogs" # dataset repo you create on HF
48
+ LOG_FILENAME = "extraction_logs.xlsx" # file inside that repo
49
+
50
+ LOG_HEADERS = [
51
+ "timestamp_utc",
52
+ "filename",
53
+ "file_size_bytes",
54
+ "file_type",
55
+ "document_type",
56
+ "parameters_requested",
57
+ "model_raw_thinking", # raw markdown text from Sarvam Document Intelligence
58
+ "extracted_json", # final JSON response sent to caller
59
+ "status", # "success" or "error"
60
+ "error_detail",
61
+ ]
62
+
63
+ # A threading lock so concurrent requests don't corrupt the xlsx
64
+ _log_lock = threading.Lock()
65
 
66
  # ---------------------------------------------------------------------------
67
  # Auth — X-API-Key header guard
 
78
  detail="Invalid or missing API key. Send it as request header: X-API-Key: <your-key>"
79
  )
80
 
81
+ # ---------------------------------------------------------------------------
82
+ # Excel log helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _style_header_row(ws):
86
+ header_fill = PatternFill("solid", start_color="1F4E79")
87
+ header_font = Font(bold=True, color="FFFFFF", name="Arial", size=10)
88
+ for cell in ws[1]:
89
+ cell.fill = header_fill
90
+ cell.font = header_font
91
+ cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
92
+
93
+
94
+ def _download_or_create_workbook() -> openpyxl.Workbook:
95
+ """Download the existing log workbook from HF, or create a fresh one."""
96
+ if not HF_TOKEN:
97
+ raise RuntimeError("HF_TOKEN secret is not set — cannot write logs")
98
+
99
+ try:
100
+ local_path = hf_hub_download(
101
+ repo_id=LOG_REPO_ID,
102
+ filename=LOG_FILENAME,
103
+ repo_type="dataset",
104
+ token=HF_TOKEN,
105
+ force_download=True,
106
+ )
107
+ wb = openpyxl.load_workbook(local_path)
108
+ except Exception:
109
+ # File doesn't exist yet — start fresh
110
+ wb = openpyxl.Workbook()
111
+ ws = wb.active
112
+ ws.title = "Logs"
113
+ ws.append(LOG_HEADERS)
114
+ _style_header_row(ws)
115
+ # Set column widths
116
+ col_widths = {
117
+ "A": 22, # timestamp
118
+ "B": 30, # filename
119
+ "C": 16, # file_size
120
+ "D": 14, # file_type
121
+ "E": 22, # document_type
122
+ "F": 35, # parameters
123
+ "G": 60, # model_raw_thinking
124
+ "H": 60, # extracted_json
125
+ "I": 12, # status
126
+ "J": 40, # error_detail
127
+ }
128
+ for col, width in col_widths.items():
129
+ ws.column_dimensions[col].width = width
130
+ ws.row_dimensions[1].height = 28
131
+ return wb
132
+
133
+
134
+ def _upload_workbook(wb: openpyxl.Workbook):
135
+ """Save workbook to a buffer and push to HF dataset repo."""
136
+ buf = io.BytesIO()
137
+ wb.save(buf)
138
+ buf.seek(0)
139
+
140
+ api = HfApi(token=HF_TOKEN)
141
+ api.upload_file(
142
+ path_or_fileobj=buf,
143
+ path_in_repo=LOG_FILENAME,
144
+ repo_id=LOG_REPO_ID,
145
+ repo_type="dataset",
146
+ commit_message=f"Add log entry {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC",
147
+ )
148
+
149
+
150
+ def append_log(
151
+ filename: str,
152
+ file_size: int,
153
+ file_type: str,
154
+ document_type: str,
155
+ parameters: list[str],
156
+ raw_thinking: str,
157
+ extracted_json: dict | None,
158
+ status: str,
159
+ error_detail: str = "",
160
+ ):
161
+ """Thread-safe: download → append row → upload."""
162
+ if not HF_TOKEN:
163
+ # Logging silently skipped if token not configured
164
+ return
165
+
166
+ row = [
167
+ datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
168
+ filename,
169
+ file_size,
170
+ file_type,
171
+ document_type,
172
+ ", ".join(parameters),
173
+ (raw_thinking or "")[:5000], # cap at 5 000 chars to keep xlsx sane
174
+ json.dumps(extracted_json, ensure_ascii=False) if extracted_json else "",
175
+ status,
176
+ error_detail[:1000],
177
+ ]
178
+
179
+ with _log_lock:
180
+ try:
181
+ wb = _download_or_create_workbook()
182
+ ws = wb.active
183
+
184
+ ws.append(row)
185
+
186
+ # Style the new data row
187
+ data_font = Font(name="Arial", size=10)
188
+ wrap_align = Alignment(vertical="top", wrap_text=True)
189
+ row_idx = ws.max_row
190
+ for cell in ws[row_idx]:
191
+ cell.font = data_font
192
+ cell.alignment = wrap_align
193
+
194
+ # Zebra striping: light blue on even rows
195
+ if row_idx % 2 == 0:
196
+ fill = PatternFill("solid", start_color="DCE6F1")
197
+ for cell in ws[row_idx]:
198
+ cell.fill = fill
199
+
200
+ _upload_workbook(wb)
201
+ except Exception as e:
202
+ # Logging must never crash the main API
203
+ print(f"[LOG WARNING] Failed to write log entry: {e}")
204
+
205
+
206
  # ---------------------------------------------------------------------------
207
  # File conversion — DOCX/PPTX → PDF via LibreOffice; images/PDFs pass through
208
  # ---------------------------------------------------------------------------
 
211
  NATIVE_MIME = {"application/pdf", "image/png", "image/jpeg", "image/jpg"}
212
 
213
  def prepare_file(file_bytes: bytes, content_type: str, filename: str) -> tuple[bytes, str]:
 
 
 
 
214
  suffix = Path(filename).suffix.lower()
 
215
  if suffix in NATIVE_EXTENSIONS or content_type in NATIVE_MIME:
216
  return file_bytes, filename
217
 
 
218
  with tempfile.TemporaryDirectory() as tmpdir:
219
  src = os.path.join(tmpdir, "input" + suffix)
220
  with open(src, "wb") as f:
 
237
  return f.read(), Path(filename).stem + ".pdf"
238
 
239
  # ---------------------------------------------------------------------------
240
+ # Step 1: Extract raw text via Sarvam Document Intelligence SDK
241
  # ---------------------------------------------------------------------------
242
 
243
  def extract_text_with_sdk(file_bytes: bytes, filename: str) -> str:
 
 
 
 
 
 
 
 
 
244
  if not SARVAM_API_KEY:
245
  raise HTTPException(status_code=500, detail="SARVAM_API_KEY is not configured on the server")
246
 
247
  client = SarvamAI(api_subscription_key=SARVAM_API_KEY)
248
 
 
249
  suffix = Path(filename).suffix.lower()
250
  with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
251
  tmp.write(file_bytes)
 
254
  zip_path = tmp_path + "_output.zip"
255
 
256
  try:
257
+ job = client.document_intelligence.create_job(language="en-IN", output_format="md")
 
 
 
258
  job.upload_file(tmp_path)
259
  job.start()
260
  status = job.wait_until_complete()
 
267
 
268
  job.download_output(zip_path)
269
 
 
270
  with zipfile.ZipFile(zip_path, "r") as z:
271
  md_files = sorted(n for n in z.namelist() if n.endswith(".md"))
272
  if not md_files:
273
  raise HTTPException(status_code=500, detail="No markdown output found in Sarvam result")
274
  full_text = "\n\n".join(
275
+ z.read(name).decode("utf-8", errors="replace") for name in md_files
 
276
  )
277
 
278
  return full_text
 
285
  pass
286
 
287
  # ---------------------------------------------------------------------------
288
+ # Step 2: Extract structured JSON via Sarvam Chat (sarvam-m)
289
  # ---------------------------------------------------------------------------
290
 
291
  async def extract_json_via_chat(doc_type: str, raw_text: str, parameters: list[str]) -> dict:
 
293
  param_list = "\n".join(f" - {p}" for p in parameters)
294
 
295
  prompt = f"""You are a document data extraction assistant. Below is the full text extracted from a {description}.
 
296
  --- DOCUMENT TEXT START ---
297
  {raw_text}
298
  --- DOCUMENT TEXT END ---
 
299
  Extract the following fields from the document text above:
300
  {param_list}
 
301
  Rules:
302
  1. Return ONLY a valid JSON object. No explanations, no markdown, no code fences.
303
  2. Use exactly the field names listed above as JSON keys.
304
  3. If a field is not found or not legible, set its value to null.
305
  4. Do not invent or infer values not explicitly present in the document.
 
306
  Respond with the JSON object only."""
307
 
308
  async with httpx.AsyncClient(timeout=60) as http:
 
322
 
323
  raw = resp.json()["choices"][0]["message"]["content"].strip()
324
 
 
325
  import re
326
  raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
327
 
 
328
  if raw.startswith("```"):
329
  raw = raw.split("\n", 1)[-1]
330
  if raw.endswith("```"):
 
349
 
350
  @app.get("/types")
351
  def get_supported_types():
352
+ return {"supported_types": {k: {"description": v} for k, v in SUPPORTED_TYPES.items()}}
353
+
 
 
354
 
355
  @app.post("/extract", dependencies=[Security(verify_api_key)])
356
  async def extract_document(
 
360
  ):
361
  """
362
  Extract structured JSON data from an uploaded document.
 
363
  Requires header: **X-API-Key: your-secret-key**
 
 
 
 
364
  """
365
 
 
366
  if document_type not in SUPPORTED_TYPES:
367
  raise HTTPException(
368
  status_code=400,
 
372
  }
373
  )
374
 
 
375
  param_list = [p.strip() for p in parameters.split(",") if p.strip()]
376
  if not param_list:
377
  raise HTTPException(
 
379
  detail="'parameters' must contain at least one field name (e.g. 'name,income,date_of_issue')"
380
  )
381
 
382
+ file_bytes = await file.read()
 
383
  if not file_bytes:
384
  raise HTTPException(status_code=400, detail="Uploaded file is empty")
385
 
386
  content_type = file.content_type or ""
387
  filename = file.filename or "document"
388
+ file_size = len(file_bytes)
389
+
390
+ raw_text = ""
391
+ extracted = {}
392
 
 
393
  try:
394
  processed_bytes, processed_name = prepare_file(file_bytes, content_type, filename)
395
+ except HTTPException as exc:
396
+ asyncio.get_event_loop().run_in_executor(
397
+ None, append_log,
398
+ filename, file_size, content_type, document_type, param_list,
399
+ "", None, "error", str(exc.detail)
400
+ )
401
  raise
 
 
402
 
 
403
  try:
404
  raw_text = await asyncio.to_thread(extract_text_with_sdk, processed_bytes, processed_name)
405
+ except HTTPException as exc:
406
+ asyncio.get_event_loop().run_in_executor(
407
+ None, append_log,
408
+ filename, file_size, content_type, document_type, param_list,
409
+ "", None, "error", str(exc.detail)
410
+ )
411
  raise
 
 
412
 
413
+ try:
414
+ extracted = await extract_json_via_chat(document_type, raw_text, param_list)
415
+ except HTTPException as exc:
416
+ asyncio.get_event_loop().run_in_executor(
417
+ None, append_log,
418
+ filename, file_size, content_type, document_type, param_list,
419
+ raw_text, None, "error", str(exc.detail)
420
+ )
421
+ raise
422
+
423
+ # Fire-and-forget logging (don't block the response)
424
+ asyncio.get_event_loop().run_in_executor(
425
+ None, append_log,
426
+ filename, file_size, content_type, document_type, param_list,
427
+ raw_text, extracted, "success", ""
428
+ )
429
 
430
  return JSONResponse(content={
431
  "document_type": document_type,
 
434
  "extracted_data": extracted
435
  })
436
 
437
+
438
  @app.get("/health")
439
  def health():
440
  return {"status": "ok"}