Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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
|
| 27 |
-
version="
|
| 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
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
|
| 48 |
-
LOG_REPO_ID = "PrathameshRaut/VisionModelLogs"
|
| 49 |
-
LOG_FILENAME = "extraction_logs.xlsx"
|
| 50 |
|
| 51 |
LOG_HEADERS = [
|
| 52 |
"timestamp_utc",
|
|
@@ -55,17 +54,16 @@ LOG_HEADERS = [
|
|
| 55 |
"file_type",
|
| 56 |
"document_type",
|
| 57 |
"parameters_requested",
|
| 58 |
-
"
|
| 59 |
-
"extracted_json",
|
| 60 |
-
"status",
|
| 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
|
| 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,
|
| 119 |
-
"
|
| 120 |
-
"
|
| 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 |
-
|
| 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 |
-
(
|
| 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 |
-
#
|
| 243 |
# ---------------------------------------------------------------------------
|
| 244 |
|
| 245 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
"""
|
| 247 |
-
|
| 248 |
-
|
| 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 |
-
|
| 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,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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 440 |
-
|
| 441 |
-
|
| 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 |
-
|
| 458 |
)
|
| 459 |
raise
|
| 460 |
|
| 461 |
-
# Fire-and-forget logging
|
| 462 |
asyncio.get_event_loop().run_in_executor(
|
| 463 |
None, append_log,
|
| 464 |
filename, file_size, content_type, document_type, param_list,
|
| 465 |
-
|
| 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={
|