Spaces:
Sleeping
Sleeping
| 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 | |
| # ========================================================= | |
| def root(): | |
| return RedirectResponse(url="/docs") | |
| async def health(): | |
| return {"message": "OK"} | |
| # ========================================================= | |
| # UPLOAD ENDPOINT | |
| # ========================================================= | |
| 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 | |
| # ========================================================= | |
| 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 | |
| # ========================================================= | |
| 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 | |
| ) |