Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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
|
| 26 |
-
version="
|
| 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 |
-
"
|
| 58 |
-
"extracted_json",
|
| 59 |
-
"status",
|
| 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,
|
| 113 |
-
"
|
| 114 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
(
|
| 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 |
-
#
|
| 222 |
# ---------------------------------------------------------------------------
|
| 223 |
|
| 224 |
-
def
|
| 225 |
-
file_bytes: bytes,
|
| 226 |
-
filename: str,
|
| 227 |
-
doc_type: str,
|
| 228 |
-
parameters: list[str],
|
| 229 |
-
) -> tuple[dict, str]:
|
| 230 |
"""
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
| 234 |
|
| 235 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
asyncio.get_event_loop().run_in_executor(
|
| 411 |
None, append_log,
|
| 412 |
filename, file_size, content_type, document_type, param_list,
|
| 413 |
-
|
| 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: 
|
| 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={
|