Spaces:
Sleeping
Sleeping
Upload 15 files
Browse files- Dockerfile +36 -0
- README.md +24 -7
- dependencies.py +31 -0
- ocr_service.py +101 -0
- requirements.txt +14 -0
- routers/__pycache__/verify_address.cpython-313.pyc +0 -0
- routers/__pycache__/verify_bank.cpython-313.pyc +0 -0
- routers/__pycache__/verify_credit.cpython-313.pyc +0 -0
- routers/__pycache__/verify_financial.cpython-313.pyc +0 -0
- routers/__pycache__/verify_id.cpython-313.pyc +0 -0
- routers/verify_address.py +137 -0
- routers/verify_bank.py +105 -0
- routers/verify_credit.py +103 -0
- routers/verify_id.py +197 -0
- searchworks.py +288 -0
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Install necessary system dependencies for OpenCV/EasyOCR (e.g. libgl)
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
libglib2.0-0 \
|
| 7 |
+
libgl1 \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Hugging Face Spaces requires port 7860
|
| 11 |
+
EXPOSE 7860
|
| 12 |
+
|
| 13 |
+
# Create a non-root user with UID 1000 (required by HF Spaces)
|
| 14 |
+
RUN useradd -m -u 1000 user
|
| 15 |
+
WORKDIR /home/user/app
|
| 16 |
+
|
| 17 |
+
# Copy the requirements file and install dependencies
|
| 18 |
+
# Note: Use --extra-index-url for CPU-only torch to save space in the image
|
| 19 |
+
COPY --chown=user requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Copy the application code (routers, dependencies, core services)
|
| 23 |
+
COPY --chown=user dependencies.py .
|
| 24 |
+
COPY --chown=user searchworks.py .
|
| 25 |
+
COPY --chown=user ocr_service.py .
|
| 26 |
+
COPY --chown=user routers/ ./routers/
|
| 27 |
+
|
| 28 |
+
# Pre-download the EasyOCR model weights at build time
|
| 29 |
+
# English ('en') is required for our identity and address verification.
|
| 30 |
+
RUN python -c "import easyocr; reader = easyocr.Reader(['en'], gpu=False); print('--- [BUILD] EasyOCR Model Cached ---')"
|
| 31 |
+
|
| 32 |
+
# Switch to the non-root user
|
| 33 |
+
USER user
|
| 34 |
+
|
| 35 |
+
# Command to run the application on the port required by HF Spaces
|
| 36 |
+
CMD ["uvicorn", "ocr_service:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,28 @@
|
|
| 1 |
---
|
| 2 |
-
title: Yimlo
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
short_description: OCR
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Yimlo OCR Verification
|
| 3 |
+
emoji: 🛡️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Yimlo OCR Verification Microservice
|
| 11 |
+
|
| 12 |
+
This is the official OCR backend for [yimlo.africa](https://yimlo.africa). It provides identity validation, proof of residence analysis, and bank/credit document verification.
|
| 13 |
+
|
| 14 |
+
## Architecture
|
| 15 |
+
- **Framework**: FastAPI
|
| 16 |
+
- **OCR Engine**: EasyOCR
|
| 17 |
+
- **Hosting**: Docker on HuggingFace Spaces
|
| 18 |
+
- **Integration**: SearchWorks 360 API
|
| 19 |
+
|
| 20 |
+
## Required Secrets
|
| 21 |
+
To run this service in production, the following secrets must be configured in your HuggingFace Space settings:
|
| 22 |
+
- `SW360_SESSION_TOKEN`: Your SearchWorks API key.
|
| 23 |
+
|
| 24 |
+
## Local Development
|
| 25 |
+
```bash
|
| 26 |
+
docker build -t yimlo-ocr .
|
| 27 |
+
docker run -p 7860:7860 -e SW360_SESSION_TOKEN=your_token yimlo-ocr
|
| 28 |
+
```
|
dependencies.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import easyocr
|
| 3 |
+
import torch
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image, ImageOps
|
| 6 |
+
|
| 7 |
+
# Initialize EasyOCR Reader globally so it stays in memory once
|
| 8 |
+
# This will use GPU (CUDA) if available
|
| 9 |
+
reader = easyocr.Reader(['en'], gpu=torch.cuda.is_available())
|
| 10 |
+
|
| 11 |
+
def resize_image(image: Image.Image, max_dim: int = 1500) -> Image.Image:
|
| 12 |
+
"""Resizes image if either dimension exceeds max_dim to speed up CPU OCR."""
|
| 13 |
+
w, h = image.size
|
| 14 |
+
if w <= max_dim and h <= max_dim:
|
| 15 |
+
return image
|
| 16 |
+
|
| 17 |
+
scale = max_dim / max(w, h)
|
| 18 |
+
new_size = (int(w * scale), int(h * scale))
|
| 19 |
+
print(f"--- [CPU OPTIMIZATION] Resizing from {w}x{h} to {new_size[0]}x{new_size[1]} ---")
|
| 20 |
+
return image.resize(new_size, Image.Resampling.LANCZOS)
|
| 21 |
+
|
| 22 |
+
def normalize_text(text: str) -> str:
|
| 23 |
+
"""Removes special characters, extra spaces, and converts to uppercase for reliable matching."""
|
| 24 |
+
if not text: return ""
|
| 25 |
+
text = text.upper()
|
| 26 |
+
return re.sub(r'[^A-Z0-9]', '', text)
|
| 27 |
+
|
| 28 |
+
def preprocess_image(image: Image.Image) -> Image.Image:
|
| 29 |
+
"""Applies basic grayscale to reduce dimensionality while preserving natural contrast for EasyOCR."""
|
| 30 |
+
image = ImageOps.grayscale(image)
|
| 31 |
+
return image
|
ocr_service.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import numpy as np
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
|
| 6 |
+
# Initialize the main app
|
| 7 |
+
app = FastAPI(title="Yimlo OCR Verification Microservices")
|
| 8 |
+
|
| 9 |
+
# Setup allowed origins for production and local development
|
| 10 |
+
origins = [
|
| 11 |
+
"http://localhost:5173", # Local development
|
| 12 |
+
"http://127.0.0.1:5173", # Local development (IP)
|
| 13 |
+
"http://localhost:4173", # Local preview
|
| 14 |
+
"http://127.0.0.1:4173", # Local preview (IP)
|
| 15 |
+
"https://yimlo.africa", # Live Production
|
| 16 |
+
"https://www.yimlo.africa" # Live Production (www)
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
# Enable CORS so the React frontend can communicate with this API
|
| 20 |
+
app.add_middleware(
|
| 21 |
+
CORSMiddleware,
|
| 22 |
+
allow_origins=origins,
|
| 23 |
+
allow_credentials=True,
|
| 24 |
+
allow_methods=["*"],
|
| 25 |
+
allow_headers=["*"],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Import the modular verification routers
|
| 29 |
+
from routers import verify_id, verify_address, verify_bank, verify_credit
|
| 30 |
+
|
| 31 |
+
# Include routes from each dedicated service module.
|
| 32 |
+
app.include_router(verify_id.router, tags=["Proof of ID"])
|
| 33 |
+
app.include_router(verify_address.router, tags=["Proof of Address"])
|
| 34 |
+
app.include_router(verify_bank.router, tags=["Bank Account"])
|
| 35 |
+
app.include_router(verify_credit.router, tags=["Credit Score"])
|
| 36 |
+
|
| 37 |
+
# Include the SW360 Live Verification mock endpoints
|
| 38 |
+
from searchworks import verify_person, verify_address as sw_verify_address, verify_bank, verify_credit
|
| 39 |
+
from fastapi import Form, HTTPException
|
| 40 |
+
|
| 41 |
+
@app.post("/live-verify/id", tags=["SearchWorks Mock"])
|
| 42 |
+
async def live_verify_id(
|
| 43 |
+
idNumber: str = Form(...),
|
| 44 |
+
firstName: str = Form(...),
|
| 45 |
+
surname: str = Form(...)
|
| 46 |
+
):
|
| 47 |
+
try:
|
| 48 |
+
result = verify_person(idNumber, firstName, surname)
|
| 49 |
+
print(f"--- [SW360] Result: verified={result.get('verified')}, message={result.get('message')} ---")
|
| 50 |
+
return result
|
| 51 |
+
except Exception as e:
|
| 52 |
+
import traceback
|
| 53 |
+
traceback.print_exc()
|
| 54 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 55 |
+
|
| 56 |
+
@app.post("/live-verify/address", tags=["SearchWorks Mock"])
|
| 57 |
+
async def live_verify_address_sw(
|
| 58 |
+
idNumber: str = Form(...),
|
| 59 |
+
address: str = Form(...)
|
| 60 |
+
):
|
| 61 |
+
try:
|
| 62 |
+
return sw_verify_address(idNumber, address)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 65 |
+
|
| 66 |
+
@app.post("/live-verify/bank", tags=["SearchWorks Mock"])
|
| 67 |
+
async def live_verify_bank_sw(
|
| 68 |
+
idNumber: str = Form(...),
|
| 69 |
+
surname: str = Form(...),
|
| 70 |
+
initials: str = Form(...),
|
| 71 |
+
accountType: str = Form(...),
|
| 72 |
+
accountNumber: str = Form(...),
|
| 73 |
+
branchCode: str = Form(...)
|
| 74 |
+
):
|
| 75 |
+
try:
|
| 76 |
+
return verify_bank(accountType, accountNumber, branchCode, initials, surname, idNumber)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 79 |
+
|
| 80 |
+
@app.post("/live-verify/credit", tags=["SearchWorks Mock"])
|
| 81 |
+
async def live_verify_credit_sw(
|
| 82 |
+
idNumber: str = Form(...),
|
| 83 |
+
surname: str = Form(...),
|
| 84 |
+
initials: str = Form(...)
|
| 85 |
+
):
|
| 86 |
+
try:
|
| 87 |
+
return verify_credit(idNumber, surname, initials)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
import uvicorn
|
| 94 |
+
# Make sure we initialize the GPU reader model before accepting traffic
|
| 95 |
+
from dependencies import reader
|
| 96 |
+
print("--- [STARTUP] Warming up EasyOCR model... ---")
|
| 97 |
+
dummy_img = np.zeros((100, 100, 3), dtype=np.uint8)
|
| 98 |
+
reader.readtext(dummy_img)
|
| 99 |
+
print("--- [STARTUP] Warmup complete. Modular Verification Services Online. ---")
|
| 100 |
+
|
| 101 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
+
fastapi==0.111.0
|
| 3 |
+
uvicorn==0.30.1
|
| 4 |
+
python-multipart==0.0.9
|
| 5 |
+
torch
|
| 6 |
+
torchvision
|
| 7 |
+
easyocr
|
| 8 |
+
Pillow==10.3.0
|
| 9 |
+
pymupdf
|
| 10 |
+
python-dotenv
|
| 11 |
+
requests
|
| 12 |
+
deepface
|
| 13 |
+
tf-keras
|
| 14 |
+
opencv-python-headless
|
routers/__pycache__/verify_address.cpython-313.pyc
ADDED
|
Binary file (6.77 kB). View file
|
|
|
routers/__pycache__/verify_bank.cpython-313.pyc
ADDED
|
Binary file (5.47 kB). View file
|
|
|
routers/__pycache__/verify_credit.cpython-313.pyc
ADDED
|
Binary file (5.36 kB). View file
|
|
|
routers/__pycache__/verify_financial.cpython-313.pyc
ADDED
|
Binary file (6.19 kB). View file
|
|
|
routers/__pycache__/verify_id.cpython-313.pyc
ADDED
|
Binary file (11 kB). View file
|
|
|
routers/verify_address.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import fitz # PyMuPDF
|
| 4 |
+
from fastapi import APIRouter, File, UploadFile, Form, HTTPException
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
from PIL import Image
|
| 7 |
+
import numpy as np
|
| 8 |
+
from difflib import SequenceMatcher
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
from dependencies import reader, resize_image, normalize_text, preprocess_image
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger("ocr_service.address")
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
@router.post("/verify-address")
|
| 17 |
+
async def verify_address(
|
| 18 |
+
file: UploadFile = File(...),
|
| 19 |
+
addressLine1: str = Form(...),
|
| 20 |
+
addressLine2: str = Form(""), # Optional
|
| 21 |
+
city: str = Form(...),
|
| 22 |
+
country: str = Form(...),
|
| 23 |
+
postalCode: str = Form(...)
|
| 24 |
+
):
|
| 25 |
+
valid_extensions = (".png", ".jpg", ".jpeg", ".pdf", ".webp")
|
| 26 |
+
if not file.filename.lower().endswith(valid_extensions):
|
| 27 |
+
raise HTTPException(status_code=400, detail="Invalid file type. Only JPG, PNG, and PDF are supported for OCR.")
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
contents = await file.read()
|
| 31 |
+
extracted_text = ""
|
| 32 |
+
|
| 33 |
+
if file.filename.lower().endswith(".pdf"):
|
| 34 |
+
pdf_document = fitz.open(stream=contents, filetype="pdf")
|
| 35 |
+
for page_num in range(len(pdf_document)):
|
| 36 |
+
page = pdf_document.load_page(page_num)
|
| 37 |
+
page_text = page.get_text()
|
| 38 |
+
extracted_text += page_text + "\n"
|
| 39 |
+
|
| 40 |
+
if len(normalize_text(page_text)) < 50:
|
| 41 |
+
pix = page.get_pixmap(dpi=300)
|
| 42 |
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
| 43 |
+
results = reader.readtext(np.array(img), detail=0, paragraph=True)
|
| 44 |
+
extracted_text += "\n".join(results) + "\n"
|
| 45 |
+
pdf_document.close()
|
| 46 |
+
else:
|
| 47 |
+
start_time = time.time()
|
| 48 |
+
original_image = Image.open(BytesIO(contents))
|
| 49 |
+
original_image = resize_image(original_image)
|
| 50 |
+
processed_image = preprocess_image(original_image)
|
| 51 |
+
|
| 52 |
+
results = reader.readtext(np.array(processed_image), detail=0, paragraph=True)
|
| 53 |
+
extracted_text = "\n".join(results)
|
| 54 |
+
print(f"--- [PERF] Address OCR took {time.time() - start_time:.2f}s ---")
|
| 55 |
+
|
| 56 |
+
with open("last_ocr_debug_address.txt", "w", encoding="utf-8") as debug_file:
|
| 57 |
+
debug_file.write(extracted_text)
|
| 58 |
+
|
| 59 |
+
normalized_extracted = normalize_text(extracted_text)
|
| 60 |
+
|
| 61 |
+
targets = [addressLine1, city, country, postalCode]
|
| 62 |
+
if addressLine2.strip():
|
| 63 |
+
targets.append(addressLine2)
|
| 64 |
+
|
| 65 |
+
valid_targets = []
|
| 66 |
+
for t in targets:
|
| 67 |
+
norm_t = normalize_text(t)
|
| 68 |
+
if norm_t:
|
| 69 |
+
valid_targets.append((t, norm_t))
|
| 70 |
+
|
| 71 |
+
print(f"--- Address Verification ---")
|
| 72 |
+
print(f"Expected targets: {[t[1] for t in valid_targets]}")
|
| 73 |
+
print(f"Extracted (Normalized): {normalized_extracted}")
|
| 74 |
+
|
| 75 |
+
results = {}
|
| 76 |
+
all_matched = True
|
| 77 |
+
missing_fields = []
|
| 78 |
+
|
| 79 |
+
for original, target in valid_targets:
|
| 80 |
+
if target in normalized_extracted:
|
| 81 |
+
results[original] = {"matched": True, "found": target, "ratio": 1.0}
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
# Exact match required for very short strings (like area codes)
|
| 85 |
+
if len(target) < 5:
|
| 86 |
+
results[original] = {"matched": False, "found": "", "ratio": 0.0}
|
| 87 |
+
all_matched = False
|
| 88 |
+
missing_fields.append(original)
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
chunk_len = len(target)
|
| 92 |
+
best_ratio = 0.0
|
| 93 |
+
found_text = ""
|
| 94 |
+
|
| 95 |
+
search_range = range(max(0, len(normalized_extracted) - chunk_len + 1))
|
| 96 |
+
for i in search_range:
|
| 97 |
+
for offset in [-1, 0, 1]:
|
| 98 |
+
current_len = chunk_len + offset
|
| 99 |
+
if current_len <= 0 or i + current_len > len(normalized_extracted):
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
chunk = normalized_extracted[i:i + current_len]
|
| 103 |
+
|
| 104 |
+
norm_target = target
|
| 105 |
+
norm_found = chunk
|
| 106 |
+
for char in ["0", "O", "Q", "D"]: norm_target, norm_found = norm_target.replace(char, "0"), norm_found.replace(char, "0")
|
| 107 |
+
for char in ["1", "I", "L", "7", "|"]: norm_target, norm_found = norm_target.replace(char, "1"), norm_found.replace(char, "1")
|
| 108 |
+
|
| 109 |
+
ratio = SequenceMatcher(None, norm_target, norm_found).ratio()
|
| 110 |
+
if ratio > best_ratio:
|
| 111 |
+
best_ratio = ratio
|
| 112 |
+
found_text = chunk
|
| 113 |
+
|
| 114 |
+
# High threshold specifically for addresses to avert false positives
|
| 115 |
+
threshold = 0.88
|
| 116 |
+
|
| 117 |
+
if original.upper() == "SOUTH AFRICA" and best_ratio < threshold:
|
| 118 |
+
results[original] = {"matched": True, "found": found_text, "ratio": best_ratio, "note": "Assumed match (local document)"}
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
if best_ratio >= threshold:
|
| 122 |
+
results[original] = {"matched": True, "found": found_text, "ratio": best_ratio}
|
| 123 |
+
else:
|
| 124 |
+
results[original] = {"matched": False, "found": found_text, "ratio": best_ratio}
|
| 125 |
+
all_matched = False
|
| 126 |
+
missing_fields.append(original)
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
"match": all_matched,
|
| 130 |
+
"message": "Verification successful." if all_matched else f"Verification failed. Could not find: {', '.join(missing_fields)} on the document.",
|
| 131 |
+
"results": results
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
import traceback
|
| 136 |
+
traceback.print_exc()
|
| 137 |
+
raise HTTPException(status_code=500, detail=f"OCR processing failed: {str(e)}")
|
routers/verify_bank.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fitz # PyMuPDF
|
| 2 |
+
from fastapi import APIRouter, File, UploadFile, Form, HTTPException
|
| 3 |
+
from io import BytesIO
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import numpy as np
|
| 6 |
+
from difflib import SequenceMatcher
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
from dependencies import reader, resize_image, normalize_text, preprocess_image
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("ocr_service.bank")
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
@router.post("/verify-bank-ocr")
|
| 15 |
+
async def verify_bank_ocr(
|
| 16 |
+
file: UploadFile = File(...),
|
| 17 |
+
bankName: str = Form(...),
|
| 18 |
+
accountNumber: str = Form(...),
|
| 19 |
+
idNumber: str = Form(...),
|
| 20 |
+
fullName: str = Form(...)
|
| 21 |
+
):
|
| 22 |
+
targets = [bankName, accountNumber, idNumber, fullName]
|
| 23 |
+
threshold = 0.75
|
| 24 |
+
|
| 25 |
+
valid_extensions = (".png", ".jpg", ".jpeg", ".pdf", ".webp")
|
| 26 |
+
if not file.filename.lower().endswith(valid_extensions):
|
| 27 |
+
raise HTTPException(status_code=400, detail="Invalid file type. Only JPG, PNG, and PDF are supported for OCR.")
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
contents = await file.read()
|
| 31 |
+
extracted_text = ""
|
| 32 |
+
|
| 33 |
+
if file.filename.lower().endswith(".pdf"):
|
| 34 |
+
pdf_document = fitz.open(stream=contents, filetype="pdf")
|
| 35 |
+
for page_num in range(len(pdf_document)):
|
| 36 |
+
page = pdf_document.load_page(page_num)
|
| 37 |
+
page_text = page.get_text()
|
| 38 |
+
extracted_text += page_text + "\n"
|
| 39 |
+
|
| 40 |
+
if len(normalize_text(page_text)) < 50:
|
| 41 |
+
pix = page.get_pixmap(dpi=300)
|
| 42 |
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
| 43 |
+
results = reader.readtext(np.array(img), detail=0, paragraph=True)
|
| 44 |
+
extracted_text += "\n".join(results) + "\n"
|
| 45 |
+
pdf_document.close()
|
| 46 |
+
else:
|
| 47 |
+
original_image = Image.open(BytesIO(contents))
|
| 48 |
+
original_image = resize_image(original_image)
|
| 49 |
+
processed_image = preprocess_image(original_image)
|
| 50 |
+
results = reader.readtext(np.array(processed_image), detail=0, paragraph=True)
|
| 51 |
+
extracted_text = "\n".join(results)
|
| 52 |
+
|
| 53 |
+
normalized_extracted = normalize_text(extracted_text)
|
| 54 |
+
normalized_targets = [normalize_text(t) for t in targets]
|
| 55 |
+
|
| 56 |
+
results = {}
|
| 57 |
+
all_matched = True
|
| 58 |
+
missing_fields = []
|
| 59 |
+
|
| 60 |
+
for idx, target in enumerate(normalized_targets):
|
| 61 |
+
original = targets[idx]
|
| 62 |
+
if target in normalized_extracted:
|
| 63 |
+
results[original] = {"matched": True, "found": original}
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
chunk_len = len(target)
|
| 67 |
+
best_ratio = 0.0
|
| 68 |
+
found_text = ""
|
| 69 |
+
|
| 70 |
+
search_range = range(max(0, len(normalized_extracted) - chunk_len + 1))
|
| 71 |
+
for i in search_range:
|
| 72 |
+
for offset in [-1, 0, 1, 2]:
|
| 73 |
+
current_len = chunk_len + offset
|
| 74 |
+
if current_len <= 0 or i + current_len > len(normalized_extracted):
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
chunk = normalized_extracted[i:i + current_len]
|
| 78 |
+
|
| 79 |
+
norm_target = target
|
| 80 |
+
norm_found = chunk
|
| 81 |
+
for char in ["0", "O", "Q", "D"]: norm_target, norm_found = norm_target.replace(char, "0"), norm_found.replace(char, "0")
|
| 82 |
+
for char in ["1", "I", "L", "7", "|"]: norm_target, norm_found = norm_target.replace(char, "1"), norm_found.replace(char, "1")
|
| 83 |
+
|
| 84 |
+
ratio = SequenceMatcher(None, norm_target, norm_found).ratio()
|
| 85 |
+
if ratio > best_ratio:
|
| 86 |
+
best_ratio = ratio
|
| 87 |
+
found_text = chunk
|
| 88 |
+
|
| 89 |
+
if best_ratio >= threshold:
|
| 90 |
+
results[original] = {"matched": True, "found": found_text, "ratio": best_ratio}
|
| 91 |
+
else:
|
| 92 |
+
results[original] = {"matched": False, "found": found_text, "ratio": best_ratio}
|
| 93 |
+
all_matched = False
|
| 94 |
+
missing_fields.append(original)
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
"match": all_matched,
|
| 98 |
+
"message": "Verification successful." if all_matched else f"Verification failed. Could not find: {', '.join(missing_fields)} on the document.",
|
| 99 |
+
"results": results
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
import traceback
|
| 104 |
+
traceback.print_exc()
|
| 105 |
+
raise HTTPException(status_code=500, detail=f"OCR processing failed: {str(e)}")
|
routers/verify_credit.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fitz # PyMuPDF
|
| 2 |
+
from fastapi import APIRouter, File, UploadFile, Form, HTTPException
|
| 3 |
+
from io import BytesIO
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import numpy as np
|
| 6 |
+
from difflib import SequenceMatcher
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
from dependencies import reader, resize_image, normalize_text, preprocess_image
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("ocr_service.credit")
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
@router.post("/verify-credit-ocr")
|
| 15 |
+
async def verify_credit_ocr(
|
| 16 |
+
file: UploadFile = File(...),
|
| 17 |
+
idNumber: str = Form(...),
|
| 18 |
+
fullName: str = Form(...)
|
| 19 |
+
):
|
| 20 |
+
targets = [idNumber, fullName]
|
| 21 |
+
threshold = 0.75
|
| 22 |
+
|
| 23 |
+
valid_extensions = (".png", ".jpg", ".jpeg", ".pdf", ".webp")
|
| 24 |
+
if not file.filename.lower().endswith(valid_extensions):
|
| 25 |
+
raise HTTPException(status_code=400, detail="Invalid file type. Only JPG, PNG, and PDF are supported for OCR.")
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
contents = await file.read()
|
| 29 |
+
extracted_text = ""
|
| 30 |
+
|
| 31 |
+
if file.filename.lower().endswith(".pdf"):
|
| 32 |
+
pdf_document = fitz.open(stream=contents, filetype="pdf")
|
| 33 |
+
for page_num in range(len(pdf_document)):
|
| 34 |
+
page = pdf_document.load_page(page_num)
|
| 35 |
+
page_text = page.get_text()
|
| 36 |
+
extracted_text += page_text + "\n"
|
| 37 |
+
|
| 38 |
+
if len(normalize_text(page_text)) < 50:
|
| 39 |
+
pix = page.get_pixmap(dpi=300)
|
| 40 |
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
| 41 |
+
results = reader.readtext(np.array(img), detail=0, paragraph=True)
|
| 42 |
+
extracted_text += "\n".join(results) + "\n"
|
| 43 |
+
pdf_document.close()
|
| 44 |
+
else:
|
| 45 |
+
original_image = Image.open(BytesIO(contents))
|
| 46 |
+
original_image = resize_image(original_image)
|
| 47 |
+
processed_image = preprocess_image(original_image)
|
| 48 |
+
results = reader.readtext(np.array(processed_image), detail=0, paragraph=True)
|
| 49 |
+
extracted_text = "\n".join(results)
|
| 50 |
+
|
| 51 |
+
normalized_extracted = normalize_text(extracted_text)
|
| 52 |
+
normalized_targets = [normalize_text(t) for t in targets]
|
| 53 |
+
|
| 54 |
+
results = {}
|
| 55 |
+
all_matched = True
|
| 56 |
+
missing_fields = []
|
| 57 |
+
|
| 58 |
+
for idx, target in enumerate(normalized_targets):
|
| 59 |
+
original = targets[idx]
|
| 60 |
+
if target in normalized_extracted:
|
| 61 |
+
results[original] = {"matched": True, "found": original}
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
chunk_len = len(target)
|
| 65 |
+
best_ratio = 0.0
|
| 66 |
+
found_text = ""
|
| 67 |
+
|
| 68 |
+
search_range = range(max(0, len(normalized_extracted) - chunk_len + 1))
|
| 69 |
+
for i in search_range:
|
| 70 |
+
for offset in [-1, 0, 1, 2]:
|
| 71 |
+
current_len = chunk_len + offset
|
| 72 |
+
if current_len <= 0 or i + current_len > len(normalized_extracted):
|
| 73 |
+
continue
|
| 74 |
+
|
| 75 |
+
chunk = normalized_extracted[i:i + current_len]
|
| 76 |
+
|
| 77 |
+
norm_target = target
|
| 78 |
+
norm_found = chunk
|
| 79 |
+
for char in ["0", "O", "Q", "D"]: norm_target, norm_found = norm_target.replace(char, "0"), norm_found.replace(char, "0")
|
| 80 |
+
for char in ["1", "I", "L", "7", "|"]: norm_target, norm_found = norm_target.replace(char, "1"), norm_found.replace(char, "1")
|
| 81 |
+
|
| 82 |
+
ratio = SequenceMatcher(None, norm_target, norm_found).ratio()
|
| 83 |
+
if ratio > best_ratio:
|
| 84 |
+
best_ratio = ratio
|
| 85 |
+
found_text = chunk
|
| 86 |
+
|
| 87 |
+
if best_ratio >= threshold:
|
| 88 |
+
results[original] = {"matched": True, "found": found_text, "ratio": best_ratio}
|
| 89 |
+
else:
|
| 90 |
+
results[original] = {"matched": False, "found": found_text, "ratio": best_ratio}
|
| 91 |
+
all_matched = False
|
| 92 |
+
missing_fields.append(original)
|
| 93 |
+
|
| 94 |
+
return {
|
| 95 |
+
"match": all_matched,
|
| 96 |
+
"message": "Verification successful." if all_matched else f"Verification failed. Could not find: {', '.join(missing_fields)} on the document.",
|
| 97 |
+
"results": results
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
except Exception as e:
|
| 101 |
+
import traceback
|
| 102 |
+
traceback.print_exc()
|
| 103 |
+
raise HTTPException(status_code=500, detail=f"OCR processing failed: {str(e)}")
|
routers/verify_id.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import tempfile
|
| 4 |
+
import fitz # PyMuPDF
|
| 5 |
+
from fastapi import APIRouter, File, UploadFile, Form, HTTPException
|
| 6 |
+
from io import BytesIO
|
| 7 |
+
from PIL import Image
|
| 8 |
+
import numpy as np
|
| 9 |
+
from difflib import SequenceMatcher
|
| 10 |
+
from deepface import DeepFace
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
from dependencies import reader, resize_image, normalize_text, preprocess_image
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("ocr_service.id")
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
@router.post("/verify-document")
|
| 19 |
+
async def verify_document(
|
| 20 |
+
file: UploadFile = File(...),
|
| 21 |
+
firstName: str = Form(...),
|
| 22 |
+
middleName: str = Form(""), # Optional
|
| 23 |
+
lastName: str = Form(...),
|
| 24 |
+
idNumber: str = Form(...),
|
| 25 |
+
selfie: UploadFile = File(None)
|
| 26 |
+
):
|
| 27 |
+
valid_extensions = (".png", ".jpg", ".jpeg", ".pdf", ".webp")
|
| 28 |
+
if not file.filename.lower().endswith(valid_extensions):
|
| 29 |
+
raise HTTPException(status_code=400, detail="Invalid file type. Only JPG, PNG, and PDF are supported for OCR.")
|
| 30 |
+
|
| 31 |
+
print(f"--- [API] /verify-document started for file: {file.filename} ---")
|
| 32 |
+
start_total = time.time()
|
| 33 |
+
try:
|
| 34 |
+
contents = await file.read()
|
| 35 |
+
extracted_text = ""
|
| 36 |
+
|
| 37 |
+
if file.filename.lower().endswith(".pdf"):
|
| 38 |
+
pdf_document = fitz.open(stream=contents, filetype="pdf")
|
| 39 |
+
for page_num in range(len(pdf_document)):
|
| 40 |
+
page = pdf_document.load_page(page_num)
|
| 41 |
+
page_text = page.get_text()
|
| 42 |
+
extracted_text += page_text + "\n"
|
| 43 |
+
|
| 44 |
+
if len(normalize_text(page_text)) < 50:
|
| 45 |
+
pix = page.get_pixmap(dpi=300)
|
| 46 |
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
| 47 |
+
processed_img = preprocess_image(img)
|
| 48 |
+
results = reader.readtext(np.array(processed_img), detail=0, paragraph=True)
|
| 49 |
+
extracted_text += "\n".join(results) + "\n"
|
| 50 |
+
|
| 51 |
+
# For face matching on PDFs, we use the first page as the document reference
|
| 52 |
+
first_page = pdf_document.load_page(0)
|
| 53 |
+
pix = first_page.get_pixmap(dpi=300)
|
| 54 |
+
original_image = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
| 55 |
+
original_image = resize_image(original_image)
|
| 56 |
+
pdf_document.close()
|
| 57 |
+
else:
|
| 58 |
+
start_time = time.time()
|
| 59 |
+
original_image = Image.open(BytesIO(contents))
|
| 60 |
+
|
| 61 |
+
if original_image.mode == "RGBA" or original_image.mode == "P":
|
| 62 |
+
background = Image.new("RGB", original_image.size, (255, 255, 255))
|
| 63 |
+
if original_image.mode == "RGBA":
|
| 64 |
+
background.paste(original_image, mask=original_image.split()[3])
|
| 65 |
+
else:
|
| 66 |
+
background.paste(original_image)
|
| 67 |
+
original_image = background
|
| 68 |
+
elif original_image.mode != "RGB":
|
| 69 |
+
original_image = original_image.convert("RGB")
|
| 70 |
+
|
| 71 |
+
original_image = resize_image(original_image)
|
| 72 |
+
processed_image = preprocess_image(original_image)
|
| 73 |
+
|
| 74 |
+
results = reader.readtext(np.array(processed_image), detail=0, paragraph=True)
|
| 75 |
+
extracted_text = "\n".join(results)
|
| 76 |
+
print(f"--- [PERF] Image OCR took {time.time() - start_time:.2f}s ---")
|
| 77 |
+
|
| 78 |
+
logger.info(f"OCR extracted {len(extracted_text)} chars from {file.filename}")
|
| 79 |
+
|
| 80 |
+
targets = [firstName, lastName, idNumber]
|
| 81 |
+
if middleName.strip():
|
| 82 |
+
targets.insert(0, f"{firstName}{middleName}")
|
| 83 |
+
targets.append(middleName)
|
| 84 |
+
|
| 85 |
+
normalized_targets = [normalize_text(t) for t in targets]
|
| 86 |
+
normalized_extracted = normalize_text(extracted_text)
|
| 87 |
+
|
| 88 |
+
print(f"--- ID OCR Verification ---")
|
| 89 |
+
print(f"Expected: {normalized_targets}")
|
| 90 |
+
print(f"Extracted (Normalized): {normalized_extracted}")
|
| 91 |
+
|
| 92 |
+
results = {}
|
| 93 |
+
all_matched = True
|
| 94 |
+
missing_fields = []
|
| 95 |
+
|
| 96 |
+
for idx, target in enumerate(normalized_targets):
|
| 97 |
+
original = targets[idx]
|
| 98 |
+
field_type = "ID" if original == idNumber else "Name"
|
| 99 |
+
|
| 100 |
+
if target in normalized_extracted:
|
| 101 |
+
results[original] = {"matched": True, "found": original}
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
chunk_len = len(target)
|
| 105 |
+
best_ratio = 0.0
|
| 106 |
+
found_text = ""
|
| 107 |
+
|
| 108 |
+
search_range = range(max(0, len(normalized_extracted) - chunk_len + 1))
|
| 109 |
+
for i in search_range:
|
| 110 |
+
for offset in [-1, 0, 1, 2]:
|
| 111 |
+
current_len = chunk_len + offset
|
| 112 |
+
if current_len <= 0 or i + current_len > len(normalized_extracted):
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
chunk = normalized_extracted[i:i + current_len]
|
| 116 |
+
norm_target = target
|
| 117 |
+
norm_found = chunk
|
| 118 |
+
|
| 119 |
+
if field_type == "ID":
|
| 120 |
+
for char in ["0", "O", "Q", "D"]: norm_target, norm_found = norm_target.replace(char, "0"), norm_found.replace(char, "0")
|
| 121 |
+
for char in ["1", "I", "L", "7", "|"]: norm_target, norm_found = norm_target.replace(char, "1"), norm_found.replace(char, "1")
|
| 122 |
+
for char in ["8", "B", "9", "G"]: norm_target, norm_found = norm_target.replace(char, "8"), norm_found.replace(char, "8")
|
| 123 |
+
for char in ["5", "S"]: norm_target, norm_found = norm_target.replace(char, "5"), norm_found.replace(char, "5")
|
| 124 |
+
for char in ["6", "G"]: norm_target, norm_found = norm_target.replace(char, "6"), norm_found.replace(char, "6")
|
| 125 |
+
|
| 126 |
+
ratio = SequenceMatcher(None, norm_target, norm_found).ratio()
|
| 127 |
+
if ratio > best_ratio:
|
| 128 |
+
best_ratio = ratio
|
| 129 |
+
found_text = chunk
|
| 130 |
+
|
| 131 |
+
# Strict thresholds specifically designed for ID Documents ensuring accuracy while allowing for slight OCR artifacts
|
| 132 |
+
threshold = 0.72 if field_type == "ID" else 0.65
|
| 133 |
+
|
| 134 |
+
if best_ratio >= threshold:
|
| 135 |
+
results[original] = {"matched": True, "found": found_text, "ratio": best_ratio}
|
| 136 |
+
if original == f"{firstName}{middleName}":
|
| 137 |
+
results[firstName] = {"matched": True, "found": "Via Combined Match"}
|
| 138 |
+
if middleName.strip():
|
| 139 |
+
results[middleName] = {"matched": True, "found": "Via Combined Match"}
|
| 140 |
+
print(f"Fuzzy match for '{original}': {found_text} (ratio: {best_ratio:.2f})")
|
| 141 |
+
else:
|
| 142 |
+
if results.get(original, {}).get("matched"): continue
|
| 143 |
+
results[original] = {"matched": False, "found": found_text, "ratio": best_ratio}
|
| 144 |
+
all_matched = False
|
| 145 |
+
missing_fields.append(original)
|
| 146 |
+
print(f"FAILED match for '{original}': best found '{found_text}' (ratio: {best_ratio:.2f})")
|
| 147 |
+
|
| 148 |
+
# --- FACE MATCHING ---
|
| 149 |
+
face_match_result = None
|
| 150 |
+
if selfie:
|
| 151 |
+
print("--- [BIOMETRIC] Starting Face Matching Analysis ---")
|
| 152 |
+
try:
|
| 153 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp_id, \
|
| 154 |
+
tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp_selfie:
|
| 155 |
+
|
| 156 |
+
original_image.save(tmp_id.name)
|
| 157 |
+
selfie_bytes = await selfie.read()
|
| 158 |
+
selfie_img = Image.open(BytesIO(selfie_bytes))
|
| 159 |
+
if selfie_img.mode != "RGB":
|
| 160 |
+
selfie_img = selfie_img.convert("RGB")
|
| 161 |
+
selfie_img.save(tmp_selfie.name)
|
| 162 |
+
|
| 163 |
+
verify_res = DeepFace.verify(
|
| 164 |
+
img1_path = tmp_id.name,
|
| 165 |
+
img2_path = tmp_selfie.name,
|
| 166 |
+
model_name = "VGG-Face",
|
| 167 |
+
detector_backend = "opencv",
|
| 168 |
+
enforce_detection = False
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
face_match_result = {
|
| 172 |
+
"verified": verify_res.get("verified"),
|
| 173 |
+
"distance": verify_res.get("distance"),
|
| 174 |
+
"threshold": verify_res.get("threshold"),
|
| 175 |
+
"model": verify_res.get("model")
|
| 176 |
+
}
|
| 177 |
+
print(f"--- [BIOMETRIC] Result: {face_match_result['verified']} (dist: {face_match_result['distance']:.3f}) ---")
|
| 178 |
+
|
| 179 |
+
os.unlink(tmp_id.name)
|
| 180 |
+
os.unlink(tmp_selfie.name)
|
| 181 |
+
except Exception as fe:
|
| 182 |
+
print(f"--- [BIOMETRIC] Error during face match: {str(fe)} ---")
|
| 183 |
+
face_match_result = {"error": str(fe)}
|
| 184 |
+
|
| 185 |
+
response_data = {
|
| 186 |
+
"match": all_matched,
|
| 187 |
+
"faceMatch": face_match_result,
|
| 188 |
+
"message": "Verification successful." if all_matched else f"Verification failed. Could not find: {', '.join(missing_fields)} on the document.",
|
| 189 |
+
"results": results
|
| 190 |
+
}
|
| 191 |
+
print(f"--- [PERF] /verify-document complete! Total time: {time.time() - start_total:.2f}s ---")
|
| 192 |
+
return response_data
|
| 193 |
+
|
| 194 |
+
except Exception as e:
|
| 195 |
+
import traceback
|
| 196 |
+
logger.error(f"OCR processing failed: {traceback.format_exc()}")
|
| 197 |
+
raise HTTPException(status_code=500, detail=f"OCR processing failed: {str(e)}")
|
searchworks.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
import requests
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("searchworks")
|
| 8 |
+
|
| 9 |
+
# Resolve .env path relative to this script
|
| 10 |
+
_env_path = Path(__file__).resolve().parent / ".env"
|
| 11 |
+
|
| 12 |
+
def _get_token():
|
| 13 |
+
"""
|
| 14 |
+
Retrieves the SW360 token from environment variables.
|
| 15 |
+
Standardized for both local development (.env) and cloud production (HF Secrets).
|
| 16 |
+
"""
|
| 17 |
+
# Attempt to load .env only if it exists locally
|
| 18 |
+
if _env_path.exists():
|
| 19 |
+
load_dotenv(_env_path, override=True)
|
| 20 |
+
|
| 21 |
+
token = os.getenv("SW360_SESSION_TOKEN")
|
| 22 |
+
if not token:
|
| 23 |
+
# Check if the aliased vars are used (legacy fallback)
|
| 24 |
+
token = os.getenv("CSI_PERSON_VERIFICATION") or os.getenv("CSI_POR")
|
| 25 |
+
|
| 26 |
+
if not token:
|
| 27 |
+
# In production, we don't want to crash on import, but we need to warn
|
| 28 |
+
logger.warning("SW360_SESSION_TOKEN is missing from environment.")
|
| 29 |
+
return ""
|
| 30 |
+
|
| 31 |
+
return token
|
| 32 |
+
|
| 33 |
+
# URLs
|
| 34 |
+
URL_VALIDATE_TOKEN = "https://uatrest.searchworks.co.za/auth/validatetoken/"
|
| 35 |
+
URL_PERSON_VERIFY = "https://uatrest.searchworks.co.za/individual/csipersonrecords/personverification/nameidnumber/"
|
| 36 |
+
URL_ADDRESS_VERIFY = "https://uatrest.searchworks.co.za/csi/proofofresidence/"
|
| 37 |
+
URL_BANK_VERIFY = "https://uatrest.searchworks.co.za/credit/csiaccountverification/"
|
| 38 |
+
URL_CREDIT_VERIFY = "https://uatrest.searchworks.co.za/credit/combinedreport/consumer/"
|
| 39 |
+
|
| 40 |
+
def validate_session():
|
| 41 |
+
"""Optional helper to test the session token"""
|
| 42 |
+
token = _get_token()
|
| 43 |
+
payload = {
|
| 44 |
+
"SessionToken": token
|
| 45 |
+
}
|
| 46 |
+
response = requests.post(URL_VALIDATE_TOKEN, json=payload)
|
| 47 |
+
return response.json()
|
| 48 |
+
|
| 49 |
+
def verify_person(id_number: str, first_name: str, surname: str, reference: str = "itsme_id_check"):
|
| 50 |
+
"""
|
| 51 |
+
Submits ID and Name details to SearchWorks 360 for live verification.
|
| 52 |
+
"""
|
| 53 |
+
token = _get_token()
|
| 54 |
+
|
| 55 |
+
payload = {
|
| 56 |
+
"sessionToken": token,
|
| 57 |
+
"reference": reference,
|
| 58 |
+
"idNumber": id_number,
|
| 59 |
+
"firstName": first_name,
|
| 60 |
+
"surname": surname
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
logger.info(f"Verifying Person: {first_name} {surname} ({id_number})")
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
response = requests.post(URL_PERSON_VERIFY, json=payload)
|
| 67 |
+
response.raise_for_status()
|
| 68 |
+
data = response.json()
|
| 69 |
+
|
| 70 |
+
logger.debug(f"Raw response keys: {list(data.keys())}")
|
| 71 |
+
|
| 72 |
+
# Determine Success based on actual API response structure
|
| 73 |
+
is_successful = False
|
| 74 |
+
message = "Verification failed."
|
| 75 |
+
|
| 76 |
+
resp_obj = data.get("ResponseObject")
|
| 77 |
+
if resp_obj is None:
|
| 78 |
+
# No ResponseObject — likely an error message
|
| 79 |
+
message = data.get("ResponseMessage", "No response from SearchWorks")
|
| 80 |
+
elif isinstance(resp_obj, list) and len(resp_obj) > 0:
|
| 81 |
+
# Handle array format (legacy/alternative)
|
| 82 |
+
person_info = resp_obj[0].get("PersonInformation", {})
|
| 83 |
+
ha_info = resp_obj[0].get("HomeAffairsInformation", {})
|
| 84 |
+
verified_status = ha_info.get("VerifiedStatus", "")
|
| 85 |
+
auth_status = person_info.get("AuthenticationStatus", "")
|
| 86 |
+
|
| 87 |
+
if verified_status.upper() == "YES" or "SUCCESSFUL" in auth_status.upper():
|
| 88 |
+
is_successful = True
|
| 89 |
+
message = "Live Verification Successful"
|
| 90 |
+
else:
|
| 91 |
+
message = auth_status or f"VerifiedStatus: {verified_status}"
|
| 92 |
+
elif isinstance(resp_obj, dict):
|
| 93 |
+
# Handle direct object format (current UAT structure)
|
| 94 |
+
person_info = resp_obj.get("PersonInformation", {})
|
| 95 |
+
ha_info = resp_obj.get("HomeAffairsInformation", {})
|
| 96 |
+
verified_status = ha_info.get("VerifiedStatus", "")
|
| 97 |
+
|
| 98 |
+
if verified_status.upper() == "YES":
|
| 99 |
+
is_successful = True
|
| 100 |
+
message = "Live Verification Successful"
|
| 101 |
+
else:
|
| 102 |
+
message = f"Home Affairs VerifiedStatus: {verified_status}"
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
"verified": is_successful,
|
| 106 |
+
"message": message,
|
| 107 |
+
"raw_data": data
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
except requests.exceptions.RequestException as e:
|
| 111 |
+
logger.error(f"SearchWorks API Request Failed: {e}")
|
| 112 |
+
return {
|
| 113 |
+
"verified": False,
|
| 114 |
+
"message": f"External API Error: {str(e)}",
|
| 115 |
+
"raw_data": None
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def verify_address(id_number: str, address: str, reference: str = "itsme_address_check"):
|
| 120 |
+
"""
|
| 121 |
+
Submits Proof of Residence details to SearchWorks 360 for live verification.
|
| 122 |
+
"""
|
| 123 |
+
token = _get_token()
|
| 124 |
+
|
| 125 |
+
# Normalize ID for SearchWorks (Remove spaces/dashes)
|
| 126 |
+
clean_id = id_number.replace(" ", "").replace("-", "")
|
| 127 |
+
|
| 128 |
+
payload = {
|
| 129 |
+
"SessionToken": token,
|
| 130 |
+
"Reference": reference,
|
| 131 |
+
"IDNumber": clean_id,
|
| 132 |
+
"Address": address
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
logger.info(f"Verifying Address for {clean_id} via CSI Proof of Residence")
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
response = requests.post(URL_ADDRESS_VERIFY, json=payload)
|
| 139 |
+
response.raise_for_status()
|
| 140 |
+
data = response.json()
|
| 141 |
+
|
| 142 |
+
# Determine Success based on response data (Payload might vary by UAT/Prod)
|
| 143 |
+
# Assuming SearchWorks returns a 'ResponseObject' or similar flag
|
| 144 |
+
is_successful = False
|
| 145 |
+
message = "Address check processed."
|
| 146 |
+
registry_address = None
|
| 147 |
+
|
| 148 |
+
resp_obj = data.get("ResponseObject")
|
| 149 |
+
if resp_obj:
|
| 150 |
+
status = str(resp_obj.get("Status", "")).upper()
|
| 151 |
+
if status == "SUCCESS" or status == "MATCH":
|
| 152 |
+
is_successful = True
|
| 153 |
+
message = "Live Address Verification Successful"
|
| 154 |
+
|
| 155 |
+
registry_address = resp_obj.get("RegistryAddress") or resp_obj.get("RecordedAddress")
|
| 156 |
+
|
| 157 |
+
# Enhanced mismatch feedback
|
| 158 |
+
if not is_successful:
|
| 159 |
+
response_msg = data.get("ResponseMessage", "")
|
| 160 |
+
if "NOT FOUND" in response_msg.upper() or not resp_obj:
|
| 161 |
+
message = "No official record found in national database."
|
| 162 |
+
else:
|
| 163 |
+
message = f"Discrepancy detected: {response_msg or 'Registry data does not align with your input.'}"
|
| 164 |
+
|
| 165 |
+
return {
|
| 166 |
+
"verified": is_successful,
|
| 167 |
+
"message": message,
|
| 168 |
+
"registry_address": registry_address,
|
| 169 |
+
"raw_data": data
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
except requests.exceptions.RequestException as e:
|
| 173 |
+
logger.error(f"SearchWorks API Request Failed: {e}")
|
| 174 |
+
return {
|
| 175 |
+
"verified": False,
|
| 176 |
+
"message": f"External API Error: {str(e)}",
|
| 177 |
+
"raw_data": None
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
def verify_bank(account_type: str, account_number: str, branch_code: str, initials: str, surname: str, id_number: str, reference: str = "itsme_bank_check"):
|
| 181 |
+
token = _get_token()
|
| 182 |
+
clean_id = id_number.replace(" ", "").replace("-", "")
|
| 183 |
+
|
| 184 |
+
payload = {
|
| 185 |
+
"SessionToken": token,
|
| 186 |
+
"Reference": reference,
|
| 187 |
+
"RequestType": "AccountVerification",
|
| 188 |
+
"AccountType": account_type,
|
| 189 |
+
"AccountNumber": account_number,
|
| 190 |
+
"BranchCode": branch_code,
|
| 191 |
+
"IdentificationType": "IDNumber",
|
| 192 |
+
"Initials": initials,
|
| 193 |
+
"Surname": surname,
|
| 194 |
+
"IDNumber": clean_id,
|
| 195 |
+
"CompanyName": "",
|
| 196 |
+
"CompanyRegistrationNumber": "",
|
| 197 |
+
"TaxNumber": "",
|
| 198 |
+
"PhoneNumber": "",
|
| 199 |
+
"EmailAddress": ""
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
logger.info(f"Verifying Bank Account for {surname} ({account_number})")
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
response = requests.post(URL_BANK_VERIFY, json=payload)
|
| 206 |
+
response.raise_for_status()
|
| 207 |
+
data = response.json()
|
| 208 |
+
|
| 209 |
+
is_successful = False
|
| 210 |
+
message = "Bank check processed."
|
| 211 |
+
mismatches = []
|
| 212 |
+
|
| 213 |
+
resp_obj = data.get("ResponseObject")
|
| 214 |
+
if resp_obj:
|
| 215 |
+
status = str(resp_obj.get("Status", "")).upper()
|
| 216 |
+
if status == "SUCCESS" or "ACTIVE" in status:
|
| 217 |
+
is_successful = True
|
| 218 |
+
message = "Live Bank Verification Successful"
|
| 219 |
+
else:
|
| 220 |
+
message = resp_obj.get("ResponseMessage", "Account Verification Failed")
|
| 221 |
+
|
| 222 |
+
# If there's a specific mismatches structure from SW360 we could parse it here:
|
| 223 |
+
if resp_obj.get("MismatchedFields"):
|
| 224 |
+
for field in resp_obj.get("MismatchedFields"):
|
| 225 |
+
mismatches.append({"field": field, "expected": "Profile Value", "given": "Registry Discrepancy"})
|
| 226 |
+
|
| 227 |
+
return {
|
| 228 |
+
"verified": is_successful,
|
| 229 |
+
"message": message,
|
| 230 |
+
"mismatches": mismatches,
|
| 231 |
+
"raw_data": data
|
| 232 |
+
}
|
| 233 |
+
except requests.exceptions.RequestException as e:
|
| 234 |
+
logger.error(f"SearchWorks API Request Failed: {e}")
|
| 235 |
+
return {
|
| 236 |
+
"verified": False,
|
| 237 |
+
"message": f"External API Error: {str(e)}",
|
| 238 |
+
"mismatches": [],
|
| 239 |
+
"raw_data": None
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
def verify_credit(id_number: str, surname: str, initials: str, reference: str = "itsme_credit_check"):
|
| 243 |
+
token = _get_token()
|
| 244 |
+
clean_id = id_number.replace(" ", "").replace("-", "")
|
| 245 |
+
|
| 246 |
+
payload = {
|
| 247 |
+
"SessionToken": token,
|
| 248 |
+
"Reference": reference,
|
| 249 |
+
"IDNumber": clean_id,
|
| 250 |
+
"Surname": surname,
|
| 251 |
+
"Initials": initials
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
logger.info(f"Fetching Credit Report for {surname} ({clean_id})")
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
response = requests.post(URL_CREDIT_VERIFY, json=payload)
|
| 258 |
+
response.raise_for_status()
|
| 259 |
+
data = response.json()
|
| 260 |
+
|
| 261 |
+
is_successful = False
|
| 262 |
+
message = "Credit report fetched."
|
| 263 |
+
credit_score = None
|
| 264 |
+
risk_indicator = None
|
| 265 |
+
|
| 266 |
+
resp_obj = data.get("ResponseObject")
|
| 267 |
+
if resp_obj:
|
| 268 |
+
is_successful = True
|
| 269 |
+
# Adjust the extraction based on the actual UAT structure
|
| 270 |
+
credit_score = resp_obj.get("Score", "Unknown")
|
| 271 |
+
risk_indicator = resp_obj.get("RiskBand", "Average")
|
| 272 |
+
|
| 273 |
+
return {
|
| 274 |
+
"verified": is_successful,
|
| 275 |
+
"message": message,
|
| 276 |
+
"creditScore": credit_score,
|
| 277 |
+
"riskIndicator": risk_indicator,
|
| 278 |
+
"raw_data": data
|
| 279 |
+
}
|
| 280 |
+
except requests.exceptions.RequestException as e:
|
| 281 |
+
logger.error(f"SearchWorks API Request Failed: {e}")
|
| 282 |
+
return {
|
| 283 |
+
"verified": False,
|
| 284 |
+
"message": f"External API Error: {str(e)}",
|
| 285 |
+
"creditScore": None,
|
| 286 |
+
"riskIndicator": None,
|
| 287 |
+
"raw_data": None
|
| 288 |
+
}
|