PrathameshRaut commited on
Commit
3bb95cb
·
verified ·
1 Parent(s): 9e3f09d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +135 -110
app.py CHANGED
@@ -1,31 +1,35 @@
1
  import os
2
- import base64
3
  import json
4
- import httpx
5
- from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Security
6
- from fastapi.responses import JSONResponse
7
- from fastapi.security.api_key import APIKeyHeader
8
  import subprocess
9
  import tempfile
10
  from pathlib import Path
11
 
 
 
 
 
 
 
 
12
  app = FastAPI(
13
  title="Document Extraction API",
14
  description="Extract structured data from documents using Sarvam Vision",
15
- version="1.0.0"
16
  )
17
 
18
  # ---------------------------------------------------------------------------
19
- # Supported document types — no default parameters, fully user-defined
20
  # ---------------------------------------------------------------------------
21
 
22
  SUPPORTED_TYPES = {
23
- "income_certificate": "Income certificate issued by a government authority",
24
- "caste_certificate": "Caste certificate issued by a government authority",
25
- "domicile_certificate": "Domicile / residence certificate issued by a government authority",
26
  }
27
 
28
- SARVAM_API_URL = "https://api.sarvam.ai/v1/chat/completions"
29
  SARVAM_API_KEY = os.environ.get("SARVAM_API_KEY", "")
30
  API_SECRET_KEY = os.environ.get("API_SECRET_KEY", "")
31
 
@@ -35,13 +39,9 @@ API_SECRET_KEY = os.environ.get("API_SECRET_KEY", "")
35
 
36
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
37
 
38
-
39
  async def verify_api_key(key: str = Security(api_key_header)):
40
  if not API_SECRET_KEY:
41
- raise HTTPException(
42
- status_code=500,
43
- detail="API_SECRET_KEY is not configured on the server"
44
- )
45
  if not key or key != API_SECRET_KEY:
46
  raise HTTPException(
47
  status_code=401,
@@ -49,120 +49,146 @@ async def verify_api_key(key: str = Security(api_key_header)):
49
  )
50
 
51
  # ---------------------------------------------------------------------------
52
- # Helpers
53
  # ---------------------------------------------------------------------------
54
 
55
- SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".tiff", ".bmp"}
56
- SUPPORTED_IMAGE_MIME = {
57
- "image/jpeg", "image/jpg", "image/png", "image/gif",
58
- "image/webp", "image/tiff", "image/bmp"
59
- }
60
-
61
 
62
- def convert_to_images(file_bytes: bytes, content_type: str, filename: str) -> list[str]:
63
  """
64
- Convert any uploaded file into a list of base64-encoded PNG images.
65
- Native images -> single-item list.
66
- PDF / DOCX / PPTX / etc. -> LibreOffice -> PDF -> pdftoppm -> pages.
67
  """
68
  suffix = Path(filename).suffix.lower()
69
 
70
- if content_type in SUPPORTED_IMAGE_MIME or suffix in SUPPORTED_IMAGE_EXTENSIONS:
71
- return [base64.b64encode(file_bytes).decode()]
72
 
 
73
  with tempfile.TemporaryDirectory() as tmpdir:
74
  src = os.path.join(tmpdir, "input" + suffix)
75
  with open(src, "wb") as f:
76
  f.write(file_bytes)
77
 
78
- if suffix == ".pdf":
79
- pdf_path = src
80
- else:
81
- result = subprocess.run(
82
- ["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, src],
83
- capture_output=True, timeout=120
84
- )
85
- if result.returncode != 0:
86
- raise HTTPException(
87
- status_code=500,
88
- detail=f"File conversion failed: {result.stderr.decode()}"
89
- )
90
- pdfs = [f for f in os.listdir(tmpdir) if f.endswith(".pdf")]
91
- if not pdfs:
92
- raise HTTPException(status_code=500, detail="PDF conversion produced no output")
93
- pdf_path = os.path.join(tmpdir, pdfs[0])
94
-
95
- out_prefix = os.path.join(tmpdir, "page")
96
  result = subprocess.run(
97
- ["pdftoppm", "-r", "150", "-png", pdf_path, out_prefix],
98
  capture_output=True, timeout=120
99
  )
100
  if result.returncode != 0:
101
  raise HTTPException(
102
  status_code=500,
103
- detail=f"PDF rasterisation failed: {result.stderr.decode()}"
104
  )
 
 
 
 
 
 
105
 
106
- pages = sorted([
107
- os.path.join(tmpdir, f)
108
- for f in os.listdir(tmpdir)
109
- if f.startswith("page") and f.endswith(".png")
110
- ])
111
- if not pages:
112
- raise HTTPException(status_code=500, detail="No pages extracted from document")
113
 
114
- return [base64.b64encode(open(p, "rb").read()).decode() for p in pages]
 
 
 
 
 
 
 
 
 
 
 
115
 
 
116
 
117
- def build_prompt(doc_type: str, parameters: list[str]) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  description = SUPPORTED_TYPES[doc_type]
119
- param_list = "\n".join(f" - {p}" for p in parameters)
120
- return f"""You are a document extraction assistant. The document provided is a {description}.
 
 
 
 
 
121
 
122
- Your task is to carefully read the document and extract the following fields:
123
  {param_list}
124
 
125
  Rules:
126
  1. Return ONLY a valid JSON object. No explanations, no markdown, no code fences.
127
  2. Use exactly the field names listed above as JSON keys.
128
- 3. If a field is not found or not legible in the document, set its value to null.
129
- 4. Do not invent or infer values that are not explicitly present in the document.
130
 
131
  Respond with the JSON object only."""
132
 
133
-
134
- async def call_sarvam_vision(images_b64: list[str], prompt: str) -> dict:
135
- if not SARVAM_API_KEY:
136
- raise HTTPException(status_code=500, detail="SARVAM_API_KEY is not configured on the server")
137
-
138
- content = [
139
- {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img}"}}
140
- for img in images_b64
141
- ]
142
- content.append({"type": "text", "text": prompt})
143
-
144
- payload = {
145
- "model": "sarvam-m",
146
- "messages": [{"role": "user", "content": content}],
147
- "max_tokens": 2048,
148
- "temperature": 0
149
- }
150
-
151
- async with httpx.AsyncClient(timeout=120) as client:
152
- resp = await client.post(
153
- SARVAM_API_URL,
154
- json=payload,
155
- headers={
156
- "Authorization": f"Bearer {SARVAM_API_KEY}",
157
- "Content-Type": "application/json"
158
  }
159
  )
160
 
161
  if resp.status_code != 200:
162
- raise HTTPException(
163
- status_code=resp.status_code,
164
- detail=f"Sarvam API error: {resp.text}"
165
- )
166
 
167
  raw = resp.json()["choices"][0]["message"]["content"].strip()
168
 
@@ -177,7 +203,6 @@ async def call_sarvam_vision(images_b64: list[str], prompt: str) -> dict:
177
  except json.JSONDecodeError:
178
  return {"raw_response": raw, "parse_error": "Model did not return valid JSON"}
179
 
180
-
181
  # ---------------------------------------------------------------------------
182
  # Routes
183
  # ---------------------------------------------------------------------------
@@ -190,18 +215,13 @@ def root():
190
  "docs": "/docs"
191
  }
192
 
193
-
194
  @app.get("/types")
195
  def get_supported_types():
196
  """List all supported document types."""
197
  return {
198
- "supported_types": {
199
- k: {"description": v}
200
- for k, v in SUPPORTED_TYPES.items()
201
- }
202
  }
203
 
204
-
205
  @app.post("/extract", dependencies=[Security(verify_api_key)])
206
  async def extract_document(
207
  file: UploadFile = File(..., description="Document file (image, PDF, DOCX, PPTX, etc.)"),
@@ -228,7 +248,7 @@ async def extract_document(
228
  }
229
  )
230
 
231
- # Parse and validate parameters
232
  param_list = [p.strip() for p in parameters.split(",") if p.strip()]
233
  if not param_list:
234
  raise HTTPException(
@@ -236,35 +256,40 @@ async def extract_document(
236
  detail="'parameters' must contain at least one field name (e.g. 'name,income,date_of_issue')"
237
  )
238
 
239
- # Read file
240
  file_bytes = await file.read()
241
  if not file_bytes:
242
  raise HTTPException(status_code=400, detail="Uploaded file is empty")
243
 
244
  content_type = file.content_type or ""
245
- filename = file.filename or "document"
246
 
247
- # Convert to images
248
  try:
249
- images_b64 = convert_to_images(file_bytes, content_type, filename)
250
  except HTTPException:
251
  raise
252
  except Exception as e:
253
- raise HTTPException(status_code=500, detail=f"File processing error: {str(e)}")
254
 
255
- # Call model
256
- prompt = build_prompt(document_type, param_list)
257
- extracted = await call_sarvam_vision(images_b64, prompt)
 
 
 
 
 
 
 
258
 
259
  return JSONResponse(content={
260
  "document_type": document_type,
261
  "filename": filename,
262
- "pages_processed": len(images_b64),
263
  "parameters_requested": param_list,
264
  "extracted_data": extracted
265
  })
266
 
267
-
268
  @app.get("/health")
269
  def health():
270
  return {"status": "ok"}
 
1
  import os
2
+ import io
3
  import json
4
+ 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
13
+
14
+ import httpx
15
+ from sarvamai import SarvamAI
16
+
17
  app = FastAPI(
18
  title="Document Extraction API",
19
  description="Extract structured data from documents using Sarvam Vision",
20
+ version="3.0.0"
21
  )
22
 
23
  # ---------------------------------------------------------------------------
24
+ # Supported document types
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
 
 
39
 
40
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
41
 
 
42
  async def verify_api_key(key: str = Security(api_key_header)):
43
  if not API_SECRET_KEY:
44
+ raise HTTPException(status_code=500, detail="API_SECRET_KEY is not configured on the server")
 
 
 
45
  if not key or key != API_SECRET_KEY:
46
  raise HTTPException(
47
  status_code=401,
 
49
  )
50
 
51
  # ---------------------------------------------------------------------------
52
+ # File conversion — DOCX/PPTX → PDF via LibreOffice; images/PDFs pass through
53
  # ---------------------------------------------------------------------------
54
 
55
+ 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:
72
  f.write(file_bytes)
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  result = subprocess.run(
75
+ ["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, src],
76
  capture_output=True, timeout=120
77
  )
78
  if result.returncode != 0:
79
  raise HTTPException(
80
  status_code=500,
81
+ detail=f"File conversion to PDF failed: {result.stderr.decode()}"
82
  )
83
+ pdfs = [f for f in os.listdir(tmpdir) if f.endswith(".pdf")]
84
+ if not pdfs:
85
+ raise HTTPException(status_code=500, detail="PDF conversion produced no output")
86
+ out_path = os.path.join(tmpdir, pdfs[0])
87
+ with open(out_path, "rb") as f:
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)
113
+ tmp_path = tmp.name
114
+
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()
125
+
126
+ if status.job_state not in ("Completed", "PartiallyCompleted"):
127
+ raise HTTPException(
128
+ status_code=500,
129
+ detail=f"Sarvam document processing ended with state: {status.job_state}"
130
+ )
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
145
+
146
+ finally:
147
+ for path in (tmp_path, zip_path):
148
+ try:
149
+ os.unlink(path)
150
+ except FileNotFoundError:
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:
158
  description = SUPPORTED_TYPES[doc_type]
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:
179
+ resp = await http.post(
180
+ "https://api.sarvam.ai/v1/chat/completions",
181
+ headers={"api-subscription-key": SARVAM_API_KEY, "Content-Type": "application/json"},
182
+ json={
183
+ "model": "sarvam-m",
184
+ "messages": [{"role": "user", "content": prompt}],
185
+ "max_tokens": 2048,
186
+ "temperature": 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  }
188
  )
189
 
190
  if resp.status_code != 200:
191
+ raise HTTPException(status_code=resp.status_code, detail=f"Sarvam chat error: {resp.text}")
 
 
 
192
 
193
  raw = resp.json()["choices"][0]["message"]["content"].strip()
194
 
 
203
  except json.JSONDecodeError:
204
  return {"raw_response": raw, "parse_error": "Model did not return valid JSON"}
205
 
 
206
  # ---------------------------------------------------------------------------
207
  # Routes
208
  # ---------------------------------------------------------------------------
 
215
  "docs": "/docs"
216
  }
217
 
 
218
  @app.get("/types")
219
  def get_supported_types():
220
  """List all supported document types."""
221
  return {
222
+ "supported_types": {k: {"description": v} for k, v in SUPPORTED_TYPES.items()}
 
 
 
223
  }
224
 
 
225
  @app.post("/extract", dependencies=[Security(verify_api_key)])
226
  async def extract_document(
227
  file: UploadFile = File(..., description="Document file (image, PDF, DOCX, PPTX, etc.)"),
 
248
  }
249
  )
250
 
251
+ # Parse parameters
252
  param_list = [p.strip() for p in parameters.split(",") if p.strip()]
253
  if not param_list:
254
  raise HTTPException(
 
256
  detail="'parameters' must contain at least one field name (e.g. 'name,income,date_of_issue')"
257
  )
258
 
259
+ # Read uploaded file
260
  file_bytes = await file.read()
261
  if not file_bytes:
262
  raise HTTPException(status_code=400, detail="Uploaded file is empty")
263
 
264
  content_type = file.content_type or ""
265
+ filename = file.filename or "document"
266
 
267
+ # Convert to PDF/image if needed
268
  try:
269
+ processed_bytes, processed_name = prepare_file(file_bytes, content_type, filename)
270
  except HTTPException:
271
  raise
272
  except Exception as e:
273
+ raise HTTPException(status_code=500, detail=f"File preparation error: {str(e)}")
274
 
275
+ # Step 1: OCR + text extraction via Sarvam Document Intelligence (blocking SDK call in thread)
276
+ try:
277
+ raw_text = await asyncio.to_thread(extract_text_with_sdk, processed_bytes, processed_name)
278
+ except HTTPException:
279
+ raise
280
+ except Exception as e:
281
+ raise HTTPException(status_code=500, detail=f"Document intelligence error: {str(e)}")
282
+
283
+ # Step 2: JSON extraction via Sarvam Chat
284
+ extracted = await extract_json_via_chat(document_type, raw_text, param_list)
285
 
286
  return JSONResponse(content={
287
  "document_type": document_type,
288
  "filename": filename,
 
289
  "parameters_requested": param_list,
290
  "extracted_data": extracted
291
  })
292
 
 
293
  @app.get("/health")
294
  def health():
295
  return {"status": "ok"}