One2SkipAfew commited on
Commit
a8c31fd
·
verified ·
1 Parent(s): 5c0ccc6

Upload 15 files

Browse files
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: yellow
5
- colorTo: gray
6
  sdk: docker
7
- pinned: false
8
- short_description: OCR
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }