route-ocr-apis / api.py
hikmat123's picture
add validation error
33aef74
Raw
History Blame Contribute Delete
17.5 kB
import os
import io
import re
import json
import uuid
from difflib import SequenceMatcher
from typing import Optional, Dict, Any
from fastapi.responses import RedirectResponse
from fastapi import (
FastAPI,
File,
UploadFile,
HTTPException
)
from PIL import Image
from dotenv import load_dotenv
from pydantic import BaseModel
# OCR pipeline
import app
# =========================================================
# LOAD ENVIRONMENT
# =========================================================
load_dotenv(override=True)
# =========================================================
# FALLBACK MODELS
# =========================================================
# Tried in order when a model-level failure occurs (high demand / overload /
# model not found / unsupported). Imported from app.py so there is a single
# source of truth; if app.py doesn't define it for some reason, fall back to
# a locally defined copy so this module still works standalone.
try:
FALLBACK_MODELS = app.FALLBACK_MODELS
except AttributeError:
FALLBACK_MODELS = [
"gemini-3.1-pro-preview", # Primary choice: latest and most powerful
"gemini-3.0-pro", # Fallback 1: previous generation Pro, more stable
"gemini-2.5-flash-image-preview", # Fallback 2: Flash version, fast speed
"gemini-2.5-flash", # Ultimate fallback: most stable Flash
]
# =========================================================
# GEMINI API KEY + MODEL ROTATION STATE
# =========================================================
# These remember the last combination that worked, so the next request
# starts from there instead of always retrying everything from scratch.
CURRENT_KEY_INDEX = 0
CURRENT_MODEL_INDEX = 0
def get_gemini_api_keys():
"""
Load all Gemini keys from environment variables / HF Secrets.
Example:
GEMINI_API_KEY_1
GEMINI_API_KEY_2
GEMINI_API_KEY_3
"""
keys = []
for var, val in os.environ.items():
if var.startswith("GEMINI_API_KEY") and val:
masked = "*" * max(len(val.strip()) - 4, 0) + val.strip()[-4:]
print(f"using this API key: {var} = {masked}")
keys.append(val.strip())
keys.sort()
return keys
def is_quota_error(error_message: str) -> bool:
"""
Detect Gemini quota / rate-limit errors. These are tied to a specific
API key, so the correct response is to rotate to the NEXT KEY while
keeping the same model.
"""
error_message = error_message.lower()
quota_patterns = [
"quota",
"rate limit",
"resource exhausted",
"429",
"too many requests",
"limit exceeded",
"exceeded your current quota",
"billing",
"401",
"unauthenticated",
"invalid authentication",
"api key not valid"
]
return any(pattern in error_message for pattern in quota_patterns)
def is_high_demand_error(error_message: str) -> bool:
"""
Detect Gemini overload / high-demand / model-unavailable errors. These
are tied to the MODEL itself (the model is overloaded or doesn't exist
on this account/region), so the correct response is to move to the
NEXT FALLBACK MODEL rather than trying more keys against the same
broken/overloaded model.
"""
error_message = error_message.lower()
high_demand_patterns = [
"high demand",
"overloaded",
"service unavailable",
"503",
"model is overloaded",
"currently unavailable",
"try again later",
"unavailable. please retry",
"is unavailable", # raised by app.run_gemini_correction for unknown models
"404",
"not found",
"is not supported",
"not supported for",
"invalid model",
"unknown model",
"repeated/duplicate coordinates", # raised by app.run_pipeline when coordinates_suspect is True
]
return any(pattern in error_message for pattern in high_demand_patterns)
def run_pipeline_with_fallback(image, progress):
"""
Run app.run_pipeline with automatic rotation across both Gemini API keys
and fallback models.
Strategy:
- Outer loop: iterate over FALLBACK_MODELS, starting from the last
known-good model index.
- Inner loop: for each model, iterate over all API keys, starting
from the last known-good key index.
- Quota/rate-limit error on a key -> try the next key, same model.
- High-demand/overload/model-not-found error -> abandon remaining
keys for this model and move straight to the next fallback model.
- Any other/unrecognized error -> raised immediately, no further
rotation (it's not something key or model rotation can fix).
- On success, remember which key + model worked so the next request
starts there instead of from the top.
"""
global CURRENT_KEY_INDEX, CURRENT_MODEL_INDEX
api_keys = get_gemini_api_keys()
if not api_keys:
raise Exception("No Gemini API keys configured.")
total_keys = len(api_keys)
total_models = len(FALLBACK_MODELS)
last_error: Optional[Exception] = None
for model_offset in range(total_models):
model_idx = (CURRENT_MODEL_INDEX + model_offset) % total_models
model_name = FALLBACK_MODELS[model_idx]
print(f"[Gemini] Trying model {model_idx + 1}/{total_models}: {model_name}")
model_failed_high_demand = False
for key_offset in range(total_keys):
idx = (CURRENT_KEY_INDEX + key_offset) % total_keys
api_key = api_keys[idx]
try:
print(f"[Gemini] Trying key {idx + 1}/{total_keys} with model {model_name}")
result = app.run_pipeline(
image=image,
ocr_engine="PaddleOCR",
api_key=api_key,
model=model_name,
progress=progress
)
# Success — remember this combination for next time.
CURRENT_KEY_INDEX = idx
CURRENT_MODEL_INDEX = model_idx
print(f"[Gemini] Success with key {idx + 1} on model {model_name}")
return result
except Exception as e:
last_error = e
error_message = str(e)
print(f"[Gemini] Key {idx + 1} on model {model_name} failed: {error_message}")
if is_quota_error(error_message):
print("[Gemini] Quota/rate-limit detected. Trying next key...")
continue
if is_high_demand_error(error_message):
print(
"[Gemini] High demand / overload / unavailable model "
"detected. Switching to next fallback model..."
)
model_failed_high_demand = True
break
# Unrecognized error type — don't keep blindly rotating keys
# or models for something rotation can't fix (e.g. a bad
# image, malformed schema, programming error).
raise
if model_failed_high_demand:
continue
print(f"[Gemini] All keys exhausted (quota errors) for model {model_name}. Trying next fallback model...")
raise Exception(
f"All Gemini API keys and fallback models failed. Last error: {last_error}"
)
# =========================================================
# FASTAPI APP
# =========================================================
app_api = FastAPI(
title="Route OCR Extraction API",
version="4.0.0"
)
# =========================================================
# MEMORY STORAGE
# =========================================================
uploaded_files: Dict[str, bytes] = {}
# =========================================================
# RESPONSE MODELS
# =========================================================
class RootResponse(BaseModel):
message: str
class UploadResponse(BaseModel):
file_id: str
filename: str
message: str
class ExtractRequest(BaseModel):
file_id: str
class ExtractResponse(BaseModel):
status: str
message: str
extracted_data: Optional[Dict[str, Any]] = None
debug: Optional[Any] = None
route_document_detected: bool = False
confidence_score: int = 0
error: Optional[str] = None
class EvaluateRequest(BaseModel):
file_id: str
ground_truth: str
class EvaluateResponse(BaseModel):
status: str
message: str
accuracy_score: float
route_document_detected: bool
confidence_score: int
extracted_text: str
ground_truth: str
debug: Optional[Any] = None
class ErrorResponse(BaseModel):
detail: str
# =========================================================
# SAFE PROGRESS CALLBACK
# =========================================================
def empty_progress(*args, **kwargs):
pass
# =========================================================
# SAFELY CAUGHT BY EXCEPTION NOW (Validate logic moved to app.py)
# =========================================================
# =========================================================
# OCR ACCURACY EVALUATION
# =========================================================
def evaluate_ocr_accuracy(extracted_text: str, ground_truth: str):
"""
Compare OCR extracted text with actual text.
"""
# Normalize text
extracted_text = extracted_text.lower().strip()
ground_truth = ground_truth.lower().strip()
# Remove extra spaces
extracted_text = re.sub(r'\s+', ' ', extracted_text)
ground_truth = re.sub(r'\s+', ' ', ground_truth)
# Similarity ratio
similarity = SequenceMatcher(None, extracted_text, ground_truth).ratio()
accuracy_score = round(similarity * 100, 2)
return accuracy_score
# =========================================================
# ROOT ENDPOINT
# =========================================================
@app_api.get("/")
def root():
return RedirectResponse(url="/docs")
@app_api.get("/health")
async def health():
return {"message": "OK"}
# =========================================================
# UPLOAD ENDPOINT
# =========================================================
@app_api.post(
"/upload",
response_model=UploadResponse,
responses={
500: {"model": ErrorResponse}
}
)
async def upload_document(file: UploadFile = File(...)):
try:
file_id = str(uuid.uuid4())
contents = await file.read()
uploaded_files[file_id] = contents
return UploadResponse(
file_id=file_id,
filename=file.filename or "unknown",
message="File uploaded successfully."
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Upload failed: {str(e)}"
)
# =========================================================
# EXTRACT ENDPOINT
# =========================================================
@app_api.post(
"/extract",
response_model=ExtractResponse,
responses={
400: {"model": ErrorResponse},
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def extract_data(payload: ExtractRequest):
try:
file_id = payload.file_id
# -------------------------------------------------
# Validate file exists
# -------------------------------------------------
if file_id not in uploaded_files:
raise HTTPException(
status_code=404,
detail="File ID not found."
)
# -------------------------------------------------
# Load image
# -------------------------------------------------
contents = uploaded_files[file_id]
image_pil = Image.open(io.BytesIO(contents)).convert("RGB")
# -------------------------------------------------
# Run OCR pipeline with key + model fallback
# -------------------------------------------------
json_result_str, debug_info = run_pipeline_with_fallback(
image=image_pil,
progress=empty_progress
)
# -------------------------------------------------
# Build searchable text
# -------------------------------------------------
searchable_text = ""
if isinstance(debug_info, str):
searchable_text += debug_info
searchable_text += str(json_result_str)
# -------------------------------------------------
# Parse extracted JSON
# -------------------------------------------------
extracted_data = json.loads(json_result_str)
if "error" in extracted_data:
raise HTTPException(
status_code=400,
detail=extracted_data["error"]
)
# -------------------------------------------------
# Success response
# -------------------------------------------------
return ExtractResponse(
status="success",
message="Route extraction completed successfully.",
extracted_data=extracted_data,
debug=debug_info,
route_document_detected=True,
confidence_score=100
)
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
if "Validation failed" in str(e) or "Irrelevant document" in str(e):
error_detail = str(e).split("Validation failed: ")[-1] if "Validation failed: " in str(e) else str(e)
raise HTTPException(
status_code=400,
detail=error_detail
)
raise HTTPException(
status_code=500,
detail=str(e)
)
# =========================================================
# EVALUATE OCR ENDPOINT
# =========================================================
@app_api.post(
"/evaluate",
response_model=EvaluateResponse,
responses={
400: {"model": ErrorResponse},
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def evaluate_ocr(payload: EvaluateRequest):
try:
file_id = payload.file_id
ground_truth = payload.ground_truth
# -------------------------------------------------
# Validate file exists
# -------------------------------------------------
if file_id not in uploaded_files:
raise HTTPException(
status_code=404,
detail="File ID not found."
)
# -------------------------------------------------
# Load image
# -------------------------------------------------
contents = uploaded_files[file_id]
image_pil = Image.open(io.BytesIO(contents)).convert("RGB")
# -------------------------------------------------
# Run OCR pipeline with key + model fallback
# -------------------------------------------------
json_result_str, debug_info = run_pipeline_with_fallback(
image=image_pil,
progress=empty_progress
)
# -------------------------------------------------
# Extract searchable OCR text
# -------------------------------------------------
extracted_text = ""
if isinstance(debug_info, str):
extracted_text += debug_info
extracted_text += str(json_result_str)
extracted_data = json.loads(json_result_str)
if "error" in extracted_data:
raise HTTPException(
status_code=400,
detail=extracted_data["error"]
)
# -------------------------------------------------
# Calculate OCR accuracy
# -------------------------------------------------
accuracy_score = evaluate_ocr_accuracy(
extracted_text=extracted_text,
ground_truth=ground_truth
)
# -------------------------------------------------
# Return evaluation response
# -------------------------------------------------
return EvaluateResponse(
status="success",
message="OCR evaluation completed successfully.",
accuracy_score=accuracy_score,
route_document_detected=True,
confidence_score=100,
extracted_text=extracted_text,
ground_truth=ground_truth,
debug=debug_info
)
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
if "Validation failed" in str(e) or "Irrelevant document" in str(e):
error_detail = str(e).split("Validation failed: ")[-1] if "Validation failed: " in str(e) else str(e)
raise HTTPException(
status_code=400,
detail=error_detail
)
raise HTTPException(
status_code=500,
detail=str(e)
)
# =========================================================
# MAIN
# =========================================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app_api,
host="0.0.0.0",
port=8000
)