Upload folder using huggingface_hub
Browse files- .dockerignore +54 -0
- .env.example +19 -0
- .gitignore +72 -0
- Dockerfile +57 -0
- README.md +499 -3
- app.py +224 -0
- data_service.py +119 -0
- manage_cache.py +105 -0
- ml_ocr_service.py +121 -0
- ml_service.py +102 -0
- models/__init__.py +3 -0
- models/matcher.py +436 -0
- requirements.txt +22 -0
- test_service.py +248 -0
- utils/__init__.py +3 -0
- utils/ocr.py +147 -0
- utils/text_processor.py +178 -0
.dockerignore
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
|
| 8 |
+
# Virtual Environment
|
| 9 |
+
venv/
|
| 10 |
+
env/
|
| 11 |
+
ENV/
|
| 12 |
+
.venv
|
| 13 |
+
|
| 14 |
+
# IDE
|
| 15 |
+
.vscode/
|
| 16 |
+
.idea/
|
| 17 |
+
*.swp
|
| 18 |
+
*.swo
|
| 19 |
+
|
| 20 |
+
# Environment
|
| 21 |
+
.env
|
| 22 |
+
.env.local
|
| 23 |
+
|
| 24 |
+
# Data files (will be mounted)
|
| 25 |
+
*.csv
|
| 26 |
+
*.db
|
| 27 |
+
*.sqlite
|
| 28 |
+
|
| 29 |
+
# FAISS indexes (will be built at runtime)
|
| 30 |
+
*.index
|
| 31 |
+
*.meta.npy
|
| 32 |
+
|
| 33 |
+
# Logs
|
| 34 |
+
*.log
|
| 35 |
+
|
| 36 |
+
# Tests
|
| 37 |
+
test_*.py
|
| 38 |
+
tests/
|
| 39 |
+
.pytest_cache/
|
| 40 |
+
|
| 41 |
+
# Documentation (not needed in image)
|
| 42 |
+
*.md
|
| 43 |
+
docs/
|
| 44 |
+
|
| 45 |
+
# Git
|
| 46 |
+
.git/
|
| 47 |
+
.gitignore
|
| 48 |
+
|
| 49 |
+
# OS
|
| 50 |
+
.DS_Store
|
| 51 |
+
Thumbs.db
|
| 52 |
+
|
| 53 |
+
# Setup scripts
|
| 54 |
+
setup.sh
|
.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ML Service Configuration
|
| 2 |
+
CSV_PATH=../post/all_india_pincode_directory_2025.csv
|
| 3 |
+
ML_PORT=8000
|
| 4 |
+
ML_HOST=0.0.0.0
|
| 5 |
+
|
| 6 |
+
# DIGIPIN API
|
| 7 |
+
DIGIPIN_API_URL=http://localhost:5002
|
| 8 |
+
|
| 9 |
+
# Model Configuration
|
| 10 |
+
MODEL_NAME=sentence-transformers/all-MiniLM-L6-v2
|
| 11 |
+
|
| 12 |
+
# Tesseract OCR Path (Optional - comment out if using system default)
|
| 13 |
+
# TESSERACT_PATH=/usr/bin/tesseract
|
| 14 |
+
|
| 15 |
+
# For Windows users:
|
| 16 |
+
# TESSERACT_PATH=C:\Program Files\Tesseract-OCR\tesseract.exe
|
| 17 |
+
|
| 18 |
+
# Security (Never expose these in production images)
|
| 19 |
+
# API_KEY=your-api-key-here
|
.gitignore
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Virtual Environment
|
| 24 |
+
venv/
|
| 25 |
+
env/
|
| 26 |
+
ENV/
|
| 27 |
+
.venv
|
| 28 |
+
|
| 29 |
+
# IDE
|
| 30 |
+
.vscode/
|
| 31 |
+
.idea/
|
| 32 |
+
*.swp
|
| 33 |
+
*.swo
|
| 34 |
+
*~
|
| 35 |
+
|
| 36 |
+
# Environment variables
|
| 37 |
+
.env
|
| 38 |
+
.env.local
|
| 39 |
+
|
| 40 |
+
# Data files
|
| 41 |
+
*.db
|
| 42 |
+
*.sqlite
|
| 43 |
+
*.sqlite3
|
| 44 |
+
|
| 45 |
+
# FAISS indexes
|
| 46 |
+
*.index
|
| 47 |
+
*.meta.npy
|
| 48 |
+
|
| 49 |
+
# Logs
|
| 50 |
+
*.log
|
| 51 |
+
|
| 52 |
+
# OS
|
| 53 |
+
.DS_Store
|
| 54 |
+
Thumbs.db
|
| 55 |
+
|
| 56 |
+
# Jupyter
|
| 57 |
+
.ipynb_checkpoints/
|
| 58 |
+
|
| 59 |
+
# Models cache
|
| 60 |
+
.cache/
|
| 61 |
+
models_cache/
|
| 62 |
+
cache/
|
| 63 |
+
|
| 64 |
+
# Test files
|
| 65 |
+
.pytest_cache/
|
| 66 |
+
.coverage
|
| 67 |
+
htmlcov/
|
| 68 |
+
PERSISTENCE_UPDATE_SUMMARY.md
|
| 69 |
+
CACHE_QUICK_REF.md
|
| 70 |
+
CACHE_PERSISTENCE.md
|
| 71 |
+
IMPLEMENTATION_COMPLETE.md
|
| 72 |
+
ISUAL_FLOW.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-stage build for ML Service
|
| 2 |
+
FROM python:3.11-slim as builder
|
| 3 |
+
|
| 4 |
+
# Install system dependencies
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
tesseract-ocr \
|
| 7 |
+
tesseract-ocr-eng \
|
| 8 |
+
libgomp1 \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Create app directory
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
# Copy requirements first for better caching
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
|
| 17 |
+
# Install Python dependencies
|
| 18 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 19 |
+
pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Production stage
|
| 22 |
+
FROM python:3.11-slim
|
| 23 |
+
|
| 24 |
+
# Install runtime dependencies
|
| 25 |
+
RUN apt-get update && apt-get install -y \
|
| 26 |
+
tesseract-ocr \
|
| 27 |
+
tesseract-ocr-eng \
|
| 28 |
+
libgomp1 \
|
| 29 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 30 |
+
|
| 31 |
+
# Create non-root user for security
|
| 32 |
+
RUN useradd -m -u 1000 mluser && \
|
| 33 |
+
mkdir -p /app && \
|
| 34 |
+
chown -R mluser:mluser /app
|
| 35 |
+
|
| 36 |
+
# Set working directory
|
| 37 |
+
WORKDIR /app
|
| 38 |
+
|
| 39 |
+
# Copy Python packages from builder
|
| 40 |
+
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
| 41 |
+
COPY --from=builder /usr/local/bin /usr/local/bin
|
| 42 |
+
|
| 43 |
+
# Copy application code
|
| 44 |
+
COPY --chown=mluser:mluser . .
|
| 45 |
+
|
| 46 |
+
# Switch to non-root user
|
| 47 |
+
USER mluser
|
| 48 |
+
|
| 49 |
+
# Expose port
|
| 50 |
+
EXPOSE 8000
|
| 51 |
+
|
| 52 |
+
# Health check
|
| 53 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 54 |
+
CMD python -c "import requests; requests.get('http://localhost:8000/health')"
|
| 55 |
+
|
| 56 |
+
# Run the application
|
| 57 |
+
CMD ["python", "main.py"]
|
README.md
CHANGED
|
@@ -1,3 +1,499 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
+
# ML Services Setup
|
| 3 |
+
|
| 4 |
+
## Requirements
|
| 5 |
+
|
| 6 |
+
### Python Packages
|
| 7 |
+
All Python dependencies can be installed using pip:
|
| 8 |
+
```bash
|
| 9 |
+
pip install pandas fastapi uvicorn pytesseract python-multipart certifi faiss-cpu sentence-transformers
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
### Tesseract OCR
|
| 13 |
+
The OCR service requires Tesseract OCR to be installed on your system:
|
| 14 |
+
|
| 15 |
+
#### Windows
|
| 16 |
+
1. Download the installer from the official Tesseract GitHub releases:
|
| 17 |
+
https://github.com/UB-Mannheim/tesseract/wiki
|
| 18 |
+
2. Run the installer. The default install location is `C:\Program Files\Tesseract-OCR\`
|
| 19 |
+
3. Add Tesseract to your system PATH:
|
| 20 |
+
- Right-click on 'This PC' or 'My Computer'
|
| 21 |
+
- Click 'Properties'
|
| 22 |
+
- Click 'Advanced system settings'
|
| 23 |
+
- Click 'Environment Variables'
|
| 24 |
+
- Under 'System Variables', find and select 'Path'
|
| 25 |
+
- Click 'Edit'
|
| 26 |
+
- Click 'New'
|
| 27 |
+
- Add `C:\Program Files\Tesseract-OCR\` (or your custom install path)
|
| 28 |
+
- Click 'OK' on all windows
|
| 29 |
+
|
| 30 |
+
Alternatively, if you have Chocolatey package manager installed:
|
| 31 |
+
```powershell
|
| 32 |
+
choco install tesseract
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
#### Verify Installation
|
| 36 |
+
After installing, you can verify Tesseract is working by running:
|
| 37 |
+
```powershell
|
| 38 |
+
tesseract --version
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## Services
|
| 42 |
+
- `data_service.py` - Pincode database and REST API
|
| 43 |
+
- `ml_service.py` - Address matching using FAISS and sentence embeddings
|
| 44 |
+
- `ml_ocr_service.py` - OCR and address matching combined service
|
| 45 |
+
|
| 46 |
+
## Running the Services
|
| 47 |
+
Each service can be run using uvicorn:
|
| 48 |
+
```bash
|
| 49 |
+
uvicorn data_service:app --reload
|
| 50 |
+
uvicorn ml_service:app --reload
|
| 51 |
+
uvicorn ml_ocr_service:app --reload
|
| 52 |
+
```
|
| 53 |
+
=======
|
| 54 |
+
# ML Microservice - AI-Powered Address Matching
|
| 55 |
+
|
| 56 |
+
## Overview
|
| 57 |
+
This is the ML microservice for the AI-Powered Delivery Post Office Identification System (Challenge 1). It provides intelligent address matching, OCR text extraction, and post office identification using state-of-the-art NLP models.
|
| 58 |
+
|
| 59 |
+
## Features
|
| 60 |
+
|
| 61 |
+
### ✅ Challenge 1 Implementation
|
| 62 |
+
- **Address Matching**: AI-powered similarity search using sentence transformers
|
| 63 |
+
- **OCR Extraction**: Extract addresses from parcel images using Tesseract
|
| 64 |
+
- **Smart Confidence Scoring**: Multi-factor confidence calculation
|
| 65 |
+
- **FAISS Index**: Fast similarity search across 165K+ post offices
|
| 66 |
+
- **Address Normalization**: Clean and standardize address text
|
| 67 |
+
- **Explainable AI**: Highlight matching tokens for transparency
|
| 68 |
+
- **Model Persistence**: Automatic caching for fast startup on subsequent runs
|
| 69 |
+
|
| 70 |
+
## Architecture
|
| 71 |
+
|
| 72 |
+
```
|
| 73 |
+
ml/
|
| 74 |
+
├── main.py # FastAPI application entry point
|
| 75 |
+
├── requirements.txt # Python dependencies
|
| 76 |
+
├── Dockerfile # Docker configuration
|
| 77 |
+
├── models/
|
| 78 |
+
│ ├── __init__.py
|
| 79 |
+
│ └── matcher.py # Address matching with FAISS
|
| 80 |
+
├── utils/
|
| 81 |
+
│ ├── __init__.py
|
| 82 |
+
│ ├── text_processor.py # Text normalization utilities
|
| 83 |
+
│ └── ocr.py # OCR extraction utilities
|
| 84 |
+
└── .env.example # Environment variables template
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## Tech Stack
|
| 88 |
+
|
| 89 |
+
- **Framework**: FastAPI 0.115.4
|
| 90 |
+
- **ML Model**: sentence-transformers (all-MiniLM-L6-v2)
|
| 91 |
+
- **Search**: FAISS (Facebook AI Similarity Search)
|
| 92 |
+
- **OCR**: Tesseract OCR / pytesseract
|
| 93 |
+
- **Data**: pandas, numpy
|
| 94 |
+
|
| 95 |
+
## Installation
|
| 96 |
+
|
| 97 |
+
### Prerequisites
|
| 98 |
+
- Python 3.11+
|
| 99 |
+
- Tesseract OCR installed on system
|
| 100 |
+
|
| 101 |
+
#### Install Tesseract:
|
| 102 |
+
|
| 103 |
+
**macOS:**
|
| 104 |
+
```bash
|
| 105 |
+
brew install tesseract
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
**Ubuntu/Debian:**
|
| 109 |
+
```bash
|
| 110 |
+
sudo apt-get update
|
| 111 |
+
sudo apt-get install tesseract-ocr tesseract-ocr-eng
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
**Windows:**
|
| 115 |
+
Download from: https://github.com/UB-Mannheim/tesseract/wiki
|
| 116 |
+
Add to PATH or set TESSERACT_PATH in .env
|
| 117 |
+
|
| 118 |
+
### Setup
|
| 119 |
+
|
| 120 |
+
1. **Create virtual environment:**
|
| 121 |
+
```bash
|
| 122 |
+
cd ml
|
| 123 |
+
python -m venv venv
|
| 124 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
2. **Install dependencies:**
|
| 128 |
+
```bash
|
| 129 |
+
pip install -r requirements.txt
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
3. **Configure environment:**
|
| 133 |
+
```bash
|
| 134 |
+
cp .env.example .env
|
| 135 |
+
# Edit .env with your settings
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
4. **Run the service:**
|
| 139 |
+
```bash
|
| 140 |
+
python main.py
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
The service will start on `http://localhost:8000`
|
| 144 |
+
|
| 145 |
+
## API Endpoints
|
| 146 |
+
|
| 147 |
+
### 1. Health Check
|
| 148 |
+
```bash
|
| 149 |
+
GET /health
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
**Response:**
|
| 153 |
+
```json
|
| 154 |
+
{
|
| 155 |
+
"status": "healthy",
|
| 156 |
+
"model_loaded": true,
|
| 157 |
+
"index_loaded": true,
|
| 158 |
+
"total_records": 165629
|
| 159 |
+
}
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
### 2. OCR Text Extraction
|
| 163 |
+
```bash
|
| 164 |
+
POST /api/ml/ocr
|
| 165 |
+
Content-Type: multipart/form-data
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
**Request:**
|
| 169 |
+
```bash
|
| 170 |
+
curl -X POST "http://localhost:8000/api/ml/ocr" \
|
| 171 |
+
-F "file=@parcel_image.jpg"
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
**Response:**
|
| 175 |
+
```json
|
| 176 |
+
{
|
| 177 |
+
"raw_text": "Kothimir Post Office\nAsifabad District\nTelangana 504273",
|
| 178 |
+
"clean_text": "kothimir post office asifabad district telangana 504273",
|
| 179 |
+
"confidence": 0.85
|
| 180 |
+
}
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
### 3. Address Normalization
|
| 184 |
+
```bash
|
| 185 |
+
POST /api/ml/normalize
|
| 186 |
+
Content-Type: application/json
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
**Request:**
|
| 190 |
+
```bash
|
| 191 |
+
curl -X POST "http://localhost:8000/api/ml/normalize" \
|
| 192 |
+
-H "Content-Type: application/json" \
|
| 193 |
+
-d '{"text": "Kothimir PO, Asifabad Dist, TG-504273"}'
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
**Response:**
|
| 197 |
+
```json
|
| 198 |
+
{
|
| 199 |
+
"original": "Kothimir PO, Asifabad Dist, TG-504273",
|
| 200 |
+
"normalized": "kothimir po asifabad dist tg 504273",
|
| 201 |
+
"cleaned": "kothimir post office asifabad district telangana 504273"
|
| 202 |
+
}
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
### 4. Address Matching (Main Endpoint)
|
| 206 |
+
```bash
|
| 207 |
+
POST /api/ml/match
|
| 208 |
+
Content-Type: application/json
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
**Request:**
|
| 212 |
+
```bash
|
| 213 |
+
curl -X POST "http://localhost:8000/api/ml/match" \
|
| 214 |
+
-H "Content-Type: application/json" \
|
| 215 |
+
-d '{
|
| 216 |
+
"text": "Kothimir post office Asifabad Telangana",
|
| 217 |
+
"top_k": 3,
|
| 218 |
+
"include_digipin": true
|
| 219 |
+
}'
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
**Response:**
|
| 223 |
+
```json
|
| 224 |
+
{
|
| 225 |
+
"query": "Kothimir post office Asifabad Telangana",
|
| 226 |
+
"normalized_query": "kothimir post office asifabad telangana",
|
| 227 |
+
"matches": [
|
| 228 |
+
{
|
| 229 |
+
"rank": 1,
|
| 230 |
+
"officename": "Kothimir B.O",
|
| 231 |
+
"district": "KUMURAM BHEEM ASIFABAD",
|
| 232 |
+
"state": "TELANGANA",
|
| 233 |
+
"pincode": "504273",
|
| 234 |
+
"digipin": "4A7-B2C-9D5E1F",
|
| 235 |
+
"latitude": 19.3638689,
|
| 236 |
+
"longitude": 79.5376658,
|
| 237 |
+
"similarity": 0.9234,
|
| 238 |
+
"confidence": 0.9534,
|
| 239 |
+
"officetype": "BO",
|
| 240 |
+
"matched_tokens": ["kothimir", "asifabad", "telangana"]
|
| 241 |
+
}
|
| 242 |
+
],
|
| 243 |
+
"processing_time_ms": 145.23
|
| 244 |
+
}
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
### 5. Combined OCR + Matching
|
| 248 |
+
```bash
|
| 249 |
+
POST /api/ml/ocr_match
|
| 250 |
+
Content-Type: multipart/form-data
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
**Request:**
|
| 254 |
+
```bash
|
| 255 |
+
curl -X POST "http://localhost:8000/api/ml/ocr_match" \
|
| 256 |
+
-F "file=@parcel_image.jpg" \
|
| 257 |
+
-F "top_k=3"
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
**Response:**
|
| 261 |
+
```json
|
| 262 |
+
{
|
| 263 |
+
"ocr": {
|
| 264 |
+
"raw_text": "Kothimir Post Office...",
|
| 265 |
+
"clean_text": "kothimir post office asifabad telangana",
|
| 266 |
+
"confidence": 0.85
|
| 267 |
+
},
|
| 268 |
+
"matching": {
|
| 269 |
+
"query": "kothimir post office asifabad telangana",
|
| 270 |
+
"matches": [...]
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
## Docker Usage
|
| 276 |
+
|
| 277 |
+
### Build Image
|
| 278 |
+
```bash
|
| 279 |
+
docker build -t ml-service:latest .
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
### Run Container
|
| 283 |
+
```bash
|
| 284 |
+
docker run -d \
|
| 285 |
+
-p 8000:8000 \
|
| 286 |
+
-v $(pwd)/../post:/data:ro \
|
| 287 |
+
-e CSV_PATH=/data/all_india_pincode_directory_2025.csv \
|
| 288 |
+
--name ml-service \
|
| 289 |
+
ml-service:latest
|
| 290 |
+
```
|
| 291 |
+
|
| 292 |
+
### Using Docker Compose (Recommended)
|
| 293 |
+
```bash
|
| 294 |
+
# From project root
|
| 295 |
+
docker-compose up ml-service
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
## Configuration
|
| 299 |
+
|
| 300 |
+
### Environment Variables
|
| 301 |
+
|
| 302 |
+
| Variable | Description | Default |
|
| 303 |
+
|----------|-------------|---------|
|
| 304 |
+
| `CSV_PATH` | Path to PIN code dataset | `../post/all_india_pincode_directory_2025.csv` |
|
| 305 |
+
| `ML_PORT` | Service port | `8000` |
|
| 306 |
+
| `ML_HOST` | Service host | `0.0.0.0` |
|
| 307 |
+
| `DIGIPIN_API_URL` | DIGIPIN API URL | `http://localhost:5000` |
|
| 308 |
+
| `MODEL_NAME` | Sentence transformer model | `sentence-transformers/all-MiniLM-L6-v2` |
|
| 309 |
+
| `TESSERACT_PATH` | Tesseract executable path | System default |
|
| 310 |
+
|
| 311 |
+
## How It Works
|
| 312 |
+
|
| 313 |
+
### 1. Initialization
|
| 314 |
+
- Loads PIN code dataset (165K+ records)
|
| 315 |
+
- Loads sentence transformer model
|
| 316 |
+
- Checks for cached FAISS index and metadata
|
| 317 |
+
- **If cache exists**: Loads from disk (~5-10s startup)
|
| 318 |
+
- **If no cache**: Builds from scratch (~30-60s), then saves to cache
|
| 319 |
+
- Ready to serve requests
|
| 320 |
+
|
| 321 |
+
### 2. Model Persistence (Fast Startup)
|
| 322 |
+
The service automatically caches the FAISS index and metadata after the first run:
|
| 323 |
+
- **Cache location**: `./cache/` directory
|
| 324 |
+
- **Files created**:
|
| 325 |
+
- `faiss.index` - FAISS similarity search index
|
| 326 |
+
- `metadata.pkl` - Post office metadata
|
| 327 |
+
- **Benefits**:
|
| 328 |
+
- First run: ~30-60s (builds and saves cache)
|
| 329 |
+
- Subsequent runs: ~5-10s (loads from cache)
|
| 330 |
+
- **Cache invalidation**: Delete `./cache/` to rebuild
|
| 331 |
+
|
| 332 |
+
### 3. Address Matching Pipeline
|
| 333 |
+
```python
|
| 334 |
+
Query: "Kothimir PO Asifabad TG"
|
| 335 |
+
↓
|
| 336 |
+
1. Normalize: "kothimir po asifabad tg"
|
| 337 |
+
↓
|
| 338 |
+
2. Clean: "kothimir post office asifabad telangana"
|
| 339 |
+
↓
|
| 340 |
+
3. Encode: Generate 384-dim embedding
|
| 341 |
+
↓
|
| 342 |
+
4. FAISS Search: Find top 15 similar embeddings
|
| 343 |
+
↓
|
| 344 |
+
5. Re-rank: Apply confidence boosting
|
| 345 |
+
- PIN code match: +0.2
|
| 346 |
+
- Office name match: +0.15
|
| 347 |
+
- District match: +0.1
|
| 348 |
+
- State match: +0.05
|
| 349 |
+
↓
|
| 350 |
+
### 3. Address Matching Pipeline
|
| 351 |
+
```python
|
| 352 |
+
Query: "Kothimir PO Asifabad TG"
|
| 353 |
+
↓
|
| 354 |
+
1. Normalize: "kothimir po asifabad tg"
|
| 355 |
+
↓
|
| 356 |
+
2. Clean: "kothimir post office asifabad telangana"
|
| 357 |
+
↓
|
| 358 |
+
3. Encode: Generate 384-dim embedding
|
| 359 |
+
↓
|
| 360 |
+
4. FAISS Search: Find top 15 similar embeddings
|
| 361 |
+
↓
|
| 362 |
+
5. Re-rank: Apply confidence boosting
|
| 363 |
+
- PIN code match: +0.2
|
| 364 |
+
- Office name match: +0.15
|
| 365 |
+
- District match: +0.1
|
| 366 |
+
- State match: +0.05
|
| 367 |
+
↓
|
| 368 |
+
6. Return: Top 5 matches with confidence scores
|
| 369 |
+
```
|
| 370 |
+
|
| 371 |
+
### 4. OCR Pipeline
|
| 372 |
+
```python
|
| 373 |
+
Image Upload
|
| 374 |
+
↓
|
| 375 |
+
1. Preprocess: Grayscale, contrast, sharpen
|
| 376 |
+
↓
|
| 377 |
+
2. Tesseract OCR: Extract text with confidence
|
| 378 |
+
↓
|
| 379 |
+
3. Clean: Remove personal info, normalize
|
| 380 |
+
↓
|
| 381 |
+
4. Return: Cleaned text ready for matching
|
| 382 |
+
```
|
| 383 |
+
|
| 384 |
+
## Performance
|
| 385 |
+
|
| 386 |
+
- **Single address matching**: < 200ms
|
| 387 |
+
- **OCR extraction**: < 2s
|
| 388 |
+
- **Batch processing**: ~100 addresses/minute
|
| 389 |
+
- **Index size**: ~250MB in memory
|
| 390 |
+
- **Startup time**:
|
| 391 |
+
- First run: 30-60s (builds cache)
|
| 392 |
+
- Subsequent runs: 5-10s (loads from cache)
|
| 393 |
+
|
| 394 |
+
## Accuracy Metrics
|
| 395 |
+
|
| 396 |
+
Based on testing with sample data:
|
| 397 |
+
- **Exact match accuracy**: 92%
|
| 398 |
+
- **Top-3 accuracy**: 97%
|
| 399 |
+
- **Top-5 accuracy**: 99%
|
| 400 |
+
- **OCR accuracy**: 85% (depends on image quality)
|
| 401 |
+
|
| 402 |
+
## Security Considerations
|
| 403 |
+
|
| 404 |
+
✅ **No credentials exposed in Docker images**
|
| 405 |
+
✅ **Environment variables for sensitive config**
|
| 406 |
+
✅ **Non-root user in Docker container**
|
| 407 |
+
✅ **Read-only data mounts**
|
| 408 |
+
✅ **No API keys hardcoded**
|
| 409 |
+
|
| 410 |
+
## Troubleshooting
|
| 411 |
+
|
| 412 |
+
### Cache Management
|
| 413 |
+
|
| 414 |
+
**Clear cache to rebuild index:**
|
| 415 |
+
```bash
|
| 416 |
+
rm -rf ml/cache/
|
| 417 |
+
```
|
| 418 |
+
|
| 419 |
+
**Check cache status:**
|
| 420 |
+
```bash
|
| 421 |
+
ls -lh ml/cache/
|
| 422 |
+
# Should show: faiss.index, metadata.pkl
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
**Cache location in Docker:**
|
| 426 |
+
```bash
|
| 427 |
+
# Add volume to persist cache across container restarts
|
| 428 |
+
docker run -v ./cache:/app/cache ml-service:latest
|
| 429 |
+
```
|
| 430 |
+
|
| 431 |
+
### Tesseract not found
|
| 432 |
+
```bash
|
| 433 |
+
# Set explicit path in .env
|
| 434 |
+
TESSERACT_PATH=/usr/local/bin/tesseract
|
| 435 |
+
```
|
| 436 |
+
|
| 437 |
+
### Model download fails
|
| 438 |
+
```bash
|
| 439 |
+
# Use proxy or cache models
|
| 440 |
+
export HF_HOME=/path/to/cache
|
| 441 |
+
export TRANSFORMERS_CACHE=/path/to/cache
|
| 442 |
+
```
|
| 443 |
+
|
| 444 |
+
### Out of memory
|
| 445 |
+
```bash
|
| 446 |
+
# Reduce batch size in matcher.py
|
| 447 |
+
embeddings = model.encode(texts, batch_size=64) # Reduce to 32
|
| 448 |
+
```
|
| 449 |
+
|
| 450 |
+
### Slow startup
|
| 451 |
+
- **First run**: Downloads 90MB model and builds index (~30-60s)
|
| 452 |
+
- **Subsequent runs**: Loads from cache (~5-10s)
|
| 453 |
+
- **Docker**: Use volumes to persist cache across container restarts:
|
| 454 |
+
```bash
|
| 455 |
+
docker run -v ./cache:/app/cache ml-service:latest
|
| 456 |
+
```
|
| 457 |
+
|
| 458 |
+
## Development
|
| 459 |
+
|
| 460 |
+
### Run tests
|
| 461 |
+
```bash
|
| 462 |
+
pytest tests/
|
| 463 |
+
```
|
| 464 |
+
|
| 465 |
+
### Format code
|
| 466 |
+
```bash
|
| 467 |
+
black .
|
| 468 |
+
flake8 .
|
| 469 |
+
```
|
| 470 |
+
|
| 471 |
+
### Type checking
|
| 472 |
+
```bash
|
| 473 |
+
mypy .
|
| 474 |
+
```
|
| 475 |
+
|
| 476 |
+
## API Documentation
|
| 477 |
+
|
| 478 |
+
Interactive API docs available at:
|
| 479 |
+
- Swagger UI: http://localhost:8000/docs
|
| 480 |
+
- ReDoc: http://localhost:8000/redoc
|
| 481 |
+
|
| 482 |
+
## Contributing
|
| 483 |
+
|
| 484 |
+
1. Follow Challenge 1 requirements from context.md
|
| 485 |
+
2. Maintain security best practices
|
| 486 |
+
3. No credentials in code or Docker images
|
| 487 |
+
4. Test all endpoints before committing
|
| 488 |
+
|
| 489 |
+
## License
|
| 490 |
+
|
| 491 |
+
Part of the AI-Powered Delivery Post Office Identification System hackathon project.
|
| 492 |
+
|
| 493 |
+
## Support
|
| 494 |
+
|
| 495 |
+
For issues or questions:
|
| 496 |
+
1. Check context.md for requirements
|
| 497 |
+
2. Review API documentation
|
| 498 |
+
3. Test with sample data in /post directory
|
| 499 |
+
>>>>>>> 88546bc (feat: Implement model persistence and caching for address matching service)
|
app.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file was renamed from main.py for Hugging Face Spaces compatibility
|
| 2 |
+
import os
|
| 3 |
+
import certifi
|
| 4 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
import uvicorn
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
|
| 12 |
+
# Import custom modules
|
| 13 |
+
from utils.text_processor import normalize_text, clean_address
|
| 14 |
+
from utils.ocr import extract_text_from_image
|
| 15 |
+
from models.matcher import AddressMatcher
|
| 16 |
+
|
| 17 |
+
# Load environment variables
|
| 18 |
+
load_dotenv()
|
| 19 |
+
|
| 20 |
+
# Set SSL certificate path
|
| 21 |
+
os.environ['REQUESTS_CA_BUNDLE'] = certifi.where()
|
| 22 |
+
os.environ['SSL_CERT_FILE'] = certifi.where()
|
| 23 |
+
|
| 24 |
+
# Configuration
|
| 25 |
+
CSV_PATH = os.getenv("CSV_PATH", "../post/all_india_pincode_directory_2025.csv")
|
| 26 |
+
PORT = int(os.getenv("ML_PORT", 8000))
|
| 27 |
+
HOST = os.getenv("ML_HOST", "0.0.0.0")
|
| 28 |
+
|
| 29 |
+
# Initialize FastAPI app
|
| 30 |
+
app = FastAPI(
|
| 31 |
+
title="AI Delivery Post Office Identification - ML Service",
|
| 32 |
+
description="ML microservice for address matching and OCR extraction (Challenge 1)",
|
| 33 |
+
version="1.0.0"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# CORS configuration
|
| 37 |
+
app.add_middleware(
|
| 38 |
+
CORSMiddleware,
|
| 39 |
+
allow_origins=["*"], # Configure appropriately for production
|
| 40 |
+
allow_credentials=True,
|
| 41 |
+
allow_methods=["*"],
|
| 42 |
+
allow_headers=["*"],
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Initialize matcher
|
| 46 |
+
matcher = None
|
| 47 |
+
|
| 48 |
+
from contextlib import asynccontextmanager
|
| 49 |
+
|
| 50 |
+
@asynccontextmanager
|
| 51 |
+
async def lifespan(app: FastAPI):
|
| 52 |
+
"""Lifespan context manager for FastAPI application"""
|
| 53 |
+
# Startup
|
| 54 |
+
global matcher
|
| 55 |
+
print("🚀 Starting ML Microservice...")
|
| 56 |
+
print(f"📊 Loading dataset from: {CSV_PATH}")
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
matcher = AddressMatcher(csv_path=CSV_PATH)
|
| 60 |
+
await matcher.initialize()
|
| 61 |
+
print(f"✅ ML Service ready with {matcher.total_records} post office records")
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"❌ Failed to initialize matcher: {e}")
|
| 64 |
+
raise
|
| 65 |
+
|
| 66 |
+
yield # Application running
|
| 67 |
+
|
| 68 |
+
# Shutdown
|
| 69 |
+
if matcher:
|
| 70 |
+
print("📝 Cleaning up resources...")
|
| 71 |
+
# Add any cleanup if needed
|
| 72 |
+
|
| 73 |
+
# Update FastAPI app initialization with lifespan
|
| 74 |
+
app = FastAPI(
|
| 75 |
+
title="AI Delivery Post Office Identification - ML Service",
|
| 76 |
+
description="ML microservice for address matching and OCR extraction (Challenge 1)",
|
| 77 |
+
version="1.0.0",
|
| 78 |
+
lifespan=lifespan
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Pydantic models
|
| 82 |
+
class NormalizeRequest(BaseModel):
|
| 83 |
+
text: str
|
| 84 |
+
|
| 85 |
+
class MatchRequest(BaseModel):
|
| 86 |
+
text: str
|
| 87 |
+
top_k: int = 5
|
| 88 |
+
include_digipin: bool = True
|
| 89 |
+
|
| 90 |
+
class MatchResponse(BaseModel):
|
| 91 |
+
query: str
|
| 92 |
+
normalized_query: str
|
| 93 |
+
matches: List[dict]
|
| 94 |
+
processing_time_ms: float
|
| 95 |
+
|
| 96 |
+
class OCRResponse(BaseModel):
|
| 97 |
+
raw_text: str
|
| 98 |
+
clean_text: str
|
| 99 |
+
confidence: Optional[float] = None
|
| 100 |
+
|
| 101 |
+
# API Endpoints
|
| 102 |
+
|
| 103 |
+
@app.get("/")
|
| 104 |
+
async def root():
|
| 105 |
+
return {
|
| 106 |
+
"status": "ok",
|
| 107 |
+
"service": "ML Microservice",
|
| 108 |
+
"version": "1.0.0",
|
| 109 |
+
"records_loaded": matcher.total_records if matcher else 0,
|
| 110 |
+
"endpoints": {
|
| 111 |
+
"ocr": "POST /api/ml/ocr",
|
| 112 |
+
"normalize": "POST /api/ml/normalize",
|
| 113 |
+
"match": "POST /api/ml/match"
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
@app.get("/health")
|
| 118 |
+
async def health_check():
|
| 119 |
+
"""Detailed health check"""
|
| 120 |
+
if not matcher or not matcher.is_ready:
|
| 121 |
+
raise HTTPException(status_code=503, detail="Service not ready")
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"status": "healthy",
|
| 125 |
+
"model_loaded": matcher.model is not None,
|
| 126 |
+
"index_loaded": matcher.index is not None,
|
| 127 |
+
"total_records": matcher.total_records
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
@app.post("/api/ml/ocr", response_model=OCRResponse)
|
| 131 |
+
async def ocr_extract(file: UploadFile = File(...)):
|
| 132 |
+
try:
|
| 133 |
+
# Read image bytes
|
| 134 |
+
image_bytes = await file.read()
|
| 135 |
+
|
| 136 |
+
# Extract text using OCR
|
| 137 |
+
raw_text, confidence = extract_text_from_image(image_bytes)
|
| 138 |
+
|
| 139 |
+
# Clean the extracted text
|
| 140 |
+
clean_text = clean_address(raw_text)
|
| 141 |
+
|
| 142 |
+
return OCRResponse(
|
| 143 |
+
raw_text=raw_text,
|
| 144 |
+
clean_text=clean_text,
|
| 145 |
+
confidence=confidence
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
except Exception as e:
|
| 149 |
+
raise HTTPException(status_code=500, detail=f"OCR extraction failed: {str(e)}")
|
| 150 |
+
|
| 151 |
+
@app.post("/api/ml/normalize", response_model=dict)
|
| 152 |
+
async def normalize_address(request: NormalizeRequest):
|
| 153 |
+
try:
|
| 154 |
+
normalized = normalize_text(request.text)
|
| 155 |
+
cleaned = clean_address(request.text)
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
"original": request.text,
|
| 159 |
+
"normalized": normalized,
|
| 160 |
+
"cleaned": cleaned
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
raise HTTPException(status_code=500, detail=f"Normalization failed: {str(e)}")
|
| 165 |
+
|
| 166 |
+
@app.post("/api/ml/match", response_model=MatchResponse)
|
| 167 |
+
async def match_address(request: MatchRequest):
|
| 168 |
+
if not matcher or not matcher.is_ready:
|
| 169 |
+
raise HTTPException(status_code=503, detail="Matcher not initialized")
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
# Perform matching
|
| 173 |
+
results = await matcher.match(
|
| 174 |
+
query_text=request.text,
|
| 175 |
+
top_k=request.top_k,
|
| 176 |
+
include_digipin=request.include_digipin
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
return results
|
| 180 |
+
|
| 181 |
+
except Exception as e:
|
| 182 |
+
raise HTTPException(status_code=500, detail=f"Matching failed: {str(e)}")
|
| 183 |
+
|
| 184 |
+
@app.post("/api/ml/ocr_match")
|
| 185 |
+
async def ocr_and_match(file: UploadFile = File(...), top_k: int = 5):
|
| 186 |
+
"""
|
| 187 |
+
Combined endpoint: Extract text from image and match to post offices
|
| 188 |
+
|
| 189 |
+
- **file**: Image file containing address
|
| 190 |
+
- **top_k**: Number of matches to return
|
| 191 |
+
- Returns OCR results and matching post offices
|
| 192 |
+
"""
|
| 193 |
+
if not matcher or not matcher.is_ready:
|
| 194 |
+
raise HTTPException(status_code=503, detail="Matcher not initialized")
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
# Extract text from image
|
| 198 |
+
image_bytes = await file.read()
|
| 199 |
+
raw_text, ocr_confidence = extract_text_from_image(image_bytes)
|
| 200 |
+
clean_text = clean_address(raw_text)
|
| 201 |
+
|
| 202 |
+
# Match the extracted text
|
| 203 |
+
results = await matcher.match(
|
| 204 |
+
query_text=clean_text,
|
| 205 |
+
top_k=top_k,
|
| 206 |
+
include_digipin=True
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
# Combine OCR and matching results
|
| 210 |
+
return {
|
| 211 |
+
"ocr": {
|
| 212 |
+
"raw_text": raw_text,
|
| 213 |
+
"clean_text": clean_text,
|
| 214 |
+
"confidence": ocr_confidence
|
| 215 |
+
},
|
| 216 |
+
"matching": results
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
except Exception as e:
|
| 220 |
+
raise HTTPException(status_code=500, detail=f"OCR+Match failed: {str(e)}")
|
| 221 |
+
|
| 222 |
+
if __name__ == "__main__":
|
| 223 |
+
print(f"🌐 Starting server on {HOST}:{PORT}")
|
| 224 |
+
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
|
data_service.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#data_service.py
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import sqlite3
|
| 4 |
+
import hashlib
|
| 5 |
+
from fastapi import FastAPI, HTTPException
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
import uvicorn
|
| 8 |
+
import re
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
DATA_FILE = "Hackathon-UMU/post/all_india_pincode_directory_2025.csv"
|
| 12 |
+
DB_FILE = "pincodes.db"
|
| 13 |
+
|
| 14 |
+
app = FastAPI(title="Data Service")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def detect_columns(df: pd.DataFrame):
|
| 18 |
+
cols = [c.strip().lower() for c in df.columns]
|
| 19 |
+
mapping = {}
|
| 20 |
+
def find(keys):
|
| 21 |
+
for key in keys:
|
| 22 |
+
for c in cols:
|
| 23 |
+
if key in c:
|
| 24 |
+
return c
|
| 25 |
+
return None
|
| 26 |
+
mapping["officename"] = find(["officename","office_name","po_name","branch"])
|
| 27 |
+
mapping["pincode"] = find(["pincode","postalcode","pin"])
|
| 28 |
+
mapping["division"] = find(["division"])
|
| 29 |
+
mapping["district"] = find(["district"])
|
| 30 |
+
mapping["state"] = find(["state"])
|
| 31 |
+
mapping["lat"] = find(["lat","latitude"])
|
| 32 |
+
mapping["lon"] = find(["lon","lng","longitude"])
|
| 33 |
+
print(" Column mapping detected:", mapping)
|
| 34 |
+
return mapping
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_data_to_sqlite():
|
| 38 |
+
if os.path.exists(DB_FILE):
|
| 39 |
+
print(f" SQLite DB already exists: {DB_FILE}")
|
| 40 |
+
return
|
| 41 |
+
print(f"📂 Loading dataset: {DATA_FILE}")
|
| 42 |
+
df = pd.read_csv(DATA_FILE)
|
| 43 |
+
df.columns = [c.strip().lower() for c in df.columns]
|
| 44 |
+
colmap = detect_columns(df)
|
| 45 |
+
|
| 46 |
+
# Select relevant columns and clean
|
| 47 |
+
cols = [v for v in colmap.values() if v]
|
| 48 |
+
df = df[cols].dropna(subset=[colmap["officename"], colmap["pincode"]])
|
| 49 |
+
df = df.rename(columns={
|
| 50 |
+
colmap["officename"]: "officename",
|
| 51 |
+
colmap["pincode"]: "pincode",
|
| 52 |
+
colmap.get("division",""): "division",
|
| 53 |
+
colmap.get("district",""): "district",
|
| 54 |
+
colmap.get("state",""): "state",
|
| 55 |
+
colmap.get("lat",""): "latitude",
|
| 56 |
+
colmap.get("lon",""): "longitude"
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
# compute DIGIPIN (8-char hash)
|
| 60 |
+
def make_digipin(row):
|
| 61 |
+
base = f"{row.get('pincode','')}-{row.get('latitude','')}-{row.get('longitude','')}"
|
| 62 |
+
return hashlib.sha1(base.encode()).hexdigest()[:8].upper()
|
| 63 |
+
df["digipin"] = df.apply(make_digipin, axis=1)
|
| 64 |
+
|
| 65 |
+
# store in SQLite
|
| 66 |
+
conn = sqlite3.connect(DB_FILE)
|
| 67 |
+
df.to_sql("pincodes", conn, if_exists="replace", index=False)
|
| 68 |
+
conn.close()
|
| 69 |
+
print(f" Saved {len(df)} records to {DB_FILE}")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def query_db(query, params=()):
|
| 73 |
+
conn = sqlite3.connect(DB_FILE)
|
| 74 |
+
conn.row_factory = sqlite3.Row
|
| 75 |
+
rows = conn.execute(query, params).fetchall()
|
| 76 |
+
conn.close()
|
| 77 |
+
return [dict(r) for r in rows]
|
| 78 |
+
|
| 79 |
+
@app.on_event("startup")
|
| 80 |
+
def startup_event():
|
| 81 |
+
load_data_to_sqlite()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@app.get("/by_pin/{pincode}")
|
| 85 |
+
def get_by_pin(pincode: str):
|
| 86 |
+
results = query_db("SELECT * FROM pincodes WHERE pincode = ?", (pincode,))
|
| 87 |
+
if not results:
|
| 88 |
+
raise HTTPException(404, f"No record found for PIN {pincode}")
|
| 89 |
+
return {"count": len(results), "results": results}
|
| 90 |
+
|
| 91 |
+
@app.get("/by_office")
|
| 92 |
+
def get_by_office(name: str):
|
| 93 |
+
name_pattern = f"%{name.lower()}%"
|
| 94 |
+
results = query_db("SELECT * FROM pincodes WHERE LOWER(officename) LIKE ?", (name_pattern,))
|
| 95 |
+
return {"count": len(results), "results": results[:50]}
|
| 96 |
+
|
| 97 |
+
@app.get("/by_district")
|
| 98 |
+
def get_by_district(district: str):
|
| 99 |
+
district_pattern = f"%{district.lower()}%"
|
| 100 |
+
results = query_db("SELECT * FROM pincodes WHERE LOWER(district) LIKE ?", (district_pattern,))
|
| 101 |
+
return {"count": len(results), "results": results[:100]}
|
| 102 |
+
|
| 103 |
+
@app.get("/random")
|
| 104 |
+
def get_random(limit: int = 5):
|
| 105 |
+
results = query_db("SELECT * FROM pincodes ORDER BY RANDOM() LIMIT ?", (limit,))
|
| 106 |
+
return {"count": len(results), "results": results}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
uvicorn.run(app, host="0.0.0.0", port=8001)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
#curl "http://127.0.0.1:8001/by_pin/110070"
|
| 114 |
+
#curl "http://127.0.0.1:8001/by_office?name=Gurgaon"
|
| 115 |
+
#curl "http://127.0.0.1:8001/by_district?district=Delhi"
|
| 116 |
+
#curl http://127.0.0.1:8001/random
|
| 117 |
+
#sqlite3 pincodes.db
|
| 118 |
+
#sqlite> .tables
|
| 119 |
+
#sqlite> SELECT * FROM pincodes LIMIT 5;
|
manage_cache.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Cache Management Utility
|
| 4 |
+
Clear or check the status of the ML service cache
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import shutil
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
CACHE_DIR = Path(__file__).parent / "cache"
|
| 13 |
+
|
| 14 |
+
def get_cache_size(path: Path) -> int:
|
| 15 |
+
"""Get size of cache directory in bytes"""
|
| 16 |
+
total = 0
|
| 17 |
+
if path.exists():
|
| 18 |
+
for file in path.rglob('*'):
|
| 19 |
+
if file.is_file():
|
| 20 |
+
total += file.stat().st_size
|
| 21 |
+
return total
|
| 22 |
+
|
| 23 |
+
def format_size(bytes: int) -> str:
|
| 24 |
+
"""Format bytes to human readable string"""
|
| 25 |
+
for unit in ['B', 'KB', 'MB', 'GB']:
|
| 26 |
+
if bytes < 1024:
|
| 27 |
+
return f"{bytes:.2f} {unit}"
|
| 28 |
+
bytes /= 1024
|
| 29 |
+
return f"{bytes:.2f} TB"
|
| 30 |
+
|
| 31 |
+
def check_cache():
|
| 32 |
+
"""Check cache status"""
|
| 33 |
+
print(f"Cache directory: {CACHE_DIR}")
|
| 34 |
+
|
| 35 |
+
if not CACHE_DIR.exists():
|
| 36 |
+
print("❌ Cache directory does not exist")
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
faiss_index = CACHE_DIR / "faiss.index"
|
| 40 |
+
metadata = CACHE_DIR / "metadata.pkl"
|
| 41 |
+
embeddings = CACHE_DIR / "embeddings.npy"
|
| 42 |
+
|
| 43 |
+
print("\n📊 Cache Status:")
|
| 44 |
+
print(f" Directory exists: ✅")
|
| 45 |
+
|
| 46 |
+
files_found = []
|
| 47 |
+
total_size = 0
|
| 48 |
+
|
| 49 |
+
for file, name in [(faiss_index, "FAISS index"),
|
| 50 |
+
(metadata, "Metadata"),
|
| 51 |
+
(embeddings, "Embeddings")]:
|
| 52 |
+
if file.exists():
|
| 53 |
+
size = file.stat().st_size
|
| 54 |
+
total_size += size
|
| 55 |
+
files_found.append(name)
|
| 56 |
+
print(f" {name}: ✅ ({format_size(size)})")
|
| 57 |
+
else:
|
| 58 |
+
print(f" {name}: ❌ (not found)")
|
| 59 |
+
|
| 60 |
+
if files_found:
|
| 61 |
+
print(f"\n📦 Total cache size: {format_size(total_size)}")
|
| 62 |
+
print(f"✅ Cache is ready ({len(files_found)} file(s) found)")
|
| 63 |
+
else:
|
| 64 |
+
print("\n⚠️ Cache is empty - will be built on first run")
|
| 65 |
+
|
| 66 |
+
def clear_cache():
|
| 67 |
+
"""Clear cache directory"""
|
| 68 |
+
if not CACHE_DIR.exists():
|
| 69 |
+
print("✅ Cache directory does not exist - nothing to clear")
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
print(f"🗑️ Clearing cache directory: {CACHE_DIR}")
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
shutil.rmtree(CACHE_DIR)
|
| 76 |
+
print("✅ Cache cleared successfully")
|
| 77 |
+
print("ℹ️ Cache will be rebuilt on next service startup")
|
| 78 |
+
except Exception as e:
|
| 79 |
+
print(f"❌ Failed to clear cache: {e}")
|
| 80 |
+
sys.exit(1)
|
| 81 |
+
|
| 82 |
+
def main():
|
| 83 |
+
if len(sys.argv) < 2:
|
| 84 |
+
print("Usage:")
|
| 85 |
+
print(" python manage_cache.py check - Check cache status")
|
| 86 |
+
print(" python manage_cache.py clear - Clear cache")
|
| 87 |
+
sys.exit(1)
|
| 88 |
+
|
| 89 |
+
command = sys.argv[1].lower()
|
| 90 |
+
|
| 91 |
+
if command == "check":
|
| 92 |
+
check_cache()
|
| 93 |
+
elif command == "clear":
|
| 94 |
+
confirm = input("⚠️ Are you sure you want to clear the cache? [y/N]: ")
|
| 95 |
+
if confirm.lower() in ['y', 'yes']:
|
| 96 |
+
clear_cache()
|
| 97 |
+
else:
|
| 98 |
+
print("❌ Cancelled")
|
| 99 |
+
else:
|
| 100 |
+
print(f"❌ Unknown command: {command}")
|
| 101 |
+
print("Available commands: check, clear")
|
| 102 |
+
sys.exit(1)
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
main()
|
ml_ocr_service.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#ml_ocr_service.py
|
| 2 |
+
import os
|
| 3 |
+
import certifi
|
| 4 |
+
import faiss
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import sqlite3
|
| 8 |
+
|
| 9 |
+
os.environ['REQUESTS_CA_BUNDLE'] = certifi.where()
|
| 10 |
+
os.environ['SSL_CERT_FILE'] = certifi.where()
|
| 11 |
+
from fastapi import FastAPI, File, UploadFile
|
| 12 |
+
from fastapi.responses import JSONResponse
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from sentence_transformers import SentenceTransformer
|
| 15 |
+
from PIL import Image
|
| 16 |
+
import pytesseract
|
| 17 |
+
import io
|
| 18 |
+
import re, string
|
| 19 |
+
import uvicorn
|
| 20 |
+
# Configure Tesseract path for Windows
|
| 21 |
+
if os.name == "nt":
|
| 22 |
+
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
| 23 |
+
|
| 24 |
+
DB_FILE = "pincodes.db"
|
| 25 |
+
MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
|
| 26 |
+
FAISS_FILE = "pincode_faiss.index"
|
| 27 |
+
|
| 28 |
+
app = FastAPI(title="AI Delivery Mapper - OCR + Matching", version="3.0")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def normalize_text(s: str) -> str:
|
| 32 |
+
s = s.lower()
|
| 33 |
+
s = re.sub(r"[\W_]+", " ", s)
|
| 34 |
+
return re.sub(r"\s+", " ", s).strip()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
print(" Loading dataset and FAISS index...")
|
| 38 |
+
|
| 39 |
+
conn = sqlite3.connect(DB_FILE)
|
| 40 |
+
df = pd.read_sql("SELECT officename, district, state, pincode FROM pincodes", conn)
|
| 41 |
+
conn.close()
|
| 42 |
+
df["text"] = df[["officename", "district", "state", "pincode"]].astype(str).agg(" ".join, axis=1)
|
| 43 |
+
df["text_norm"] = df["text"].apply(normalize_text)
|
| 44 |
+
|
| 45 |
+
model = SentenceTransformer(MODEL_NAME)
|
| 46 |
+
|
| 47 |
+
index = faiss.read_index(FAISS_FILE)
|
| 48 |
+
meta = np.load(FAISS_FILE + ".meta.npy", allow_pickle=True)
|
| 49 |
+
|
| 50 |
+
print(f" Model and FAISS index loaded ({len(meta)} records).")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.post("/ocr")
|
| 54 |
+
async def ocr_image(file: UploadFile = File(...)):
|
| 55 |
+
try:
|
| 56 |
+
image_bytes = await file.read()
|
| 57 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 58 |
+
text = pytesseract.image_to_string(image, lang="eng")
|
| 59 |
+
clean_text = normalize_text(text)
|
| 60 |
+
return {"raw_text": text, "clean_text": clean_text}
|
| 61 |
+
except Exception as e:
|
| 62 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 63 |
+
|
| 64 |
+
class MatchRequest(BaseModel):
|
| 65 |
+
text: str
|
| 66 |
+
top_k: int = 5
|
| 67 |
+
|
| 68 |
+
@app.post("/match")
|
| 69 |
+
def match_address(req: MatchRequest):
|
| 70 |
+
query = normalize_text(req.text)
|
| 71 |
+
emb = model.encode([query], convert_to_numpy=True)
|
| 72 |
+
faiss.normalize_L2(emb)
|
| 73 |
+
D, I = index.search(emb, req.top_k)
|
| 74 |
+
results = []
|
| 75 |
+
for idx, score in zip(I[0], D[0]):
|
| 76 |
+
if idx == -1: continue
|
| 77 |
+
office, district, state, pin = meta[idx]
|
| 78 |
+
results.append({
|
| 79 |
+
"officename": str(office),
|
| 80 |
+
"district": str(district),
|
| 81 |
+
"state": str(state),
|
| 82 |
+
"pincode": str(pin),
|
| 83 |
+
"confidence": round(float(score), 4)
|
| 84 |
+
})
|
| 85 |
+
return {"query": req.text, "normalized": query, "matches": results}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@app.post("/ocr_match")
|
| 89 |
+
async def ocr_then_match(file: UploadFile = File(...)):
|
| 90 |
+
"""Runs OCR first, then matches extracted text."""
|
| 91 |
+
image_bytes = await file.read()
|
| 92 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 93 |
+
text = pytesseract.image_to_string(image, lang="eng")
|
| 94 |
+
clean_text = normalize_text(text)
|
| 95 |
+
emb = model.encode([clean_text], convert_to_numpy=True)
|
| 96 |
+
faiss.normalize_L2(emb)
|
| 97 |
+
D, I = index.search(emb, 5)
|
| 98 |
+
results = []
|
| 99 |
+
for idx, score in zip(I[0], D[0]):
|
| 100 |
+
if idx == -1: continue
|
| 101 |
+
office, district, state, pin = meta[idx]
|
| 102 |
+
results.append({
|
| 103 |
+
"officename": str(office),
|
| 104 |
+
"district": str(district),
|
| 105 |
+
"state": str(state),
|
| 106 |
+
"pincode": str(pin),
|
| 107 |
+
"confidence": round(float(score), 4)
|
| 108 |
+
})
|
| 109 |
+
return {"raw_text": text, "normalized": clean_text, "matches": results}
|
| 110 |
+
|
| 111 |
+
@app.get("/")
|
| 112 |
+
def root():
|
| 113 |
+
return {"status": "ok", "records": len(meta)}
|
| 114 |
+
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
uvicorn.run(app, host="0.0.0.0", port=8003)
|
| 117 |
+
|
| 118 |
+
#curl -X POST "http://127.0.0.1:8003/ocr_match" \
|
| 119 |
+
# -H "accept: application/json" \
|
| 120 |
+
# -H "Content-Type: multipart/form-data" \
|
| 121 |
+
# -F "file=@sample_parcel.jpg"
|
ml_service.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#ml_service.py
|
| 2 |
+
import os
|
| 3 |
+
import certifi
|
| 4 |
+
import faiss
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import sqlite3
|
| 8 |
+
from fastapi import FastAPI
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
from sentence_transformers import SentenceTransformer
|
| 11 |
+
import uvicorn
|
| 12 |
+
import re, string
|
| 13 |
+
|
| 14 |
+
# Set SSL certificate path
|
| 15 |
+
os.environ['REQUESTS_CA_BUNDLE'] = certifi.where()
|
| 16 |
+
os.environ['SSL_CERT_FILE'] = certifi.where()
|
| 17 |
+
|
| 18 |
+
DB_FILE = "pincodes.db"
|
| 19 |
+
MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
|
| 20 |
+
FAISS_FILE = "pincode_faiss.index"
|
| 21 |
+
BATCH_SIZE = 2048
|
| 22 |
+
|
| 23 |
+
app = FastAPI(title="AI Delivery Mapper - ML Microservice", version="2.0")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def normalize_text(s: str) -> str:
|
| 27 |
+
"""Cleans and normalizes address text."""
|
| 28 |
+
s = s.lower()
|
| 29 |
+
s = re.sub(r"[\W_]+", " ", s) # remove punctuation
|
| 30 |
+
s = re.sub(r"\s+", " ", s)
|
| 31 |
+
s = s.strip()
|
| 32 |
+
return s
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
print(" Loading dataset and model...")
|
| 36 |
+
|
| 37 |
+
conn = sqlite3.connect(DB_FILE)
|
| 38 |
+
df = pd.read_sql("SELECT officename, district, state, pincode FROM pincodes", conn)
|
| 39 |
+
conn.close()
|
| 40 |
+
df["text"] = df[["officename", "district", "state", "pincode"]].astype(str).agg(" ".join, axis=1)
|
| 41 |
+
df["text_norm"] = df["text"].apply(normalize_text)
|
| 42 |
+
|
| 43 |
+
model = SentenceTransformer(MODEL_NAME)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def build_index():
|
| 47 |
+
print(f" Building FAISS index for {len(df)} records...")
|
| 48 |
+
embeddings = model.encode(df["text_norm"].tolist(), batch_size=64, show_progress_bar=True, convert_to_numpy=True)
|
| 49 |
+
# Normalize for cosine similarity
|
| 50 |
+
faiss.normalize_L2(embeddings)
|
| 51 |
+
dim = embeddings.shape[1]
|
| 52 |
+
index = faiss.IndexFlatIP(dim) # inner product = cosine similarity
|
| 53 |
+
index.add(embeddings)
|
| 54 |
+
faiss.write_index(index, FAISS_FILE)
|
| 55 |
+
np.save(FAISS_FILE + ".meta.npy", df[["officename", "district", "state", "pincode"]].to_numpy())
|
| 56 |
+
print(f" FAISS index built and saved to {FAISS_FILE}")
|
| 57 |
+
return index
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
index = faiss.read_index(FAISS_FILE)
|
| 61 |
+
meta = np.load(FAISS_FILE + ".meta.npy", allow_pickle=True)
|
| 62 |
+
print(" Loaded existing FAISS index.")
|
| 63 |
+
except:
|
| 64 |
+
index = build_index()
|
| 65 |
+
meta = np.load(FAISS_FILE + ".meta.npy", allow_pickle=True)
|
| 66 |
+
|
| 67 |
+
class MatchRequest(BaseModel):
|
| 68 |
+
text: str
|
| 69 |
+
top_k: int = 5
|
| 70 |
+
|
| 71 |
+
@app.post("/match")
|
| 72 |
+
def match_address(req: MatchRequest):
|
| 73 |
+
query = normalize_text(req.text)
|
| 74 |
+
query_vec = model.encode([query], convert_to_numpy=True)
|
| 75 |
+
faiss.normalize_L2(query_vec)
|
| 76 |
+
|
| 77 |
+
D, I = index.search(query_vec, req.top_k)
|
| 78 |
+
results = []
|
| 79 |
+
for idx, score in zip(I[0], D[0]):
|
| 80 |
+
if idx == -1:
|
| 81 |
+
continue
|
| 82 |
+
office, district, state, pin = meta[idx]
|
| 83 |
+
results.append({
|
| 84 |
+
"officename": str(office),
|
| 85 |
+
"district": str(district),
|
| 86 |
+
"state": str(state),
|
| 87 |
+
"pincode": str(pin),
|
| 88 |
+
"confidence": round(float(score), 4)
|
| 89 |
+
})
|
| 90 |
+
return {"query": req.text, "normalized": query, "matches": results}
|
| 91 |
+
|
| 92 |
+
@app.get("/")
|
| 93 |
+
def root():
|
| 94 |
+
return {"status": "ok", "records": len(df)}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
uvicorn.run(app, host="0.0.0.0", port=8002)
|
| 99 |
+
|
| 100 |
+
#curl -X POST "http://127.0.0.1:8002/match" \
|
| 101 |
+
# -H "Content-Type: application/json" \
|
| 102 |
+
# -d '{"text": "koramangala bangalore 560034"}'
|
models/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ML models for address matching
|
| 3 |
+
"""
|
models/matcher.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import hashlib
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import faiss
|
| 7 |
+
from sentence_transformers import SentenceTransformer
|
| 8 |
+
from typing import List, Dict, Optional, Tuple
|
| 9 |
+
import requests
|
| 10 |
+
|
| 11 |
+
from utils.text_processor import (
|
| 12 |
+
normalize_text,
|
| 13 |
+
clean_address,
|
| 14 |
+
extract_pincode,
|
| 15 |
+
highlight_matching_tokens
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AddressMatcher:
|
| 20 |
+
def __init__(self, csv_path: str, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", cache_dir: str = "./cache"):
|
| 21 |
+
self.csv_path = csv_path
|
| 22 |
+
self.model_name = model_name
|
| 23 |
+
self.cache_dir = cache_dir
|
| 24 |
+
self.model = None
|
| 25 |
+
self.index = None
|
| 26 |
+
self.df = None
|
| 27 |
+
self.metadata = None
|
| 28 |
+
self.total_records = 0
|
| 29 |
+
self.is_ready = False
|
| 30 |
+
|
| 31 |
+
# DIGIPIN API configuration
|
| 32 |
+
self.digipin_api = os.getenv("DIGIPIN_API_URL", "http://localhost:5002")
|
| 33 |
+
|
| 34 |
+
# Cache file paths
|
| 35 |
+
os.makedirs(self.cache_dir, exist_ok=True)
|
| 36 |
+
self.embeddings_path = os.path.join(self.cache_dir, "embeddings.npy")
|
| 37 |
+
self.index_path = os.path.join(self.cache_dir, "faiss.index")
|
| 38 |
+
self.metadata_path = os.path.join(self.cache_dir, "metadata.pkl")
|
| 39 |
+
|
| 40 |
+
async def initialize(self):
|
| 41 |
+
"""Initialize matcher: load model and build/load index"""
|
| 42 |
+
print("📊 Loading dataset...")
|
| 43 |
+
await self._load_dataset()
|
| 44 |
+
|
| 45 |
+
print("🤖 Loading sentence transformer model...")
|
| 46 |
+
await self._load_model()
|
| 47 |
+
|
| 48 |
+
# Check if cached index exists
|
| 49 |
+
if self._cache_exists():
|
| 50 |
+
print("📦 Loading cached FAISS index and metadata...")
|
| 51 |
+
await self._load_from_cache()
|
| 52 |
+
else:
|
| 53 |
+
print("🔍 Building FAISS index from scratch...")
|
| 54 |
+
await self._build_index()
|
| 55 |
+
print("💾 Saving index to cache...")
|
| 56 |
+
await self._save_to_cache()
|
| 57 |
+
|
| 58 |
+
self.is_ready = True
|
| 59 |
+
print(f"✅ Matcher initialized with {self.total_records} records")
|
| 60 |
+
|
| 61 |
+
async def _load_dataset(self):
|
| 62 |
+
"""Load and preprocess the PIN code dataset"""
|
| 63 |
+
try:
|
| 64 |
+
# Load CSV
|
| 65 |
+
df = pd.read_csv(self.csv_path)
|
| 66 |
+
|
| 67 |
+
# Normalize column names
|
| 68 |
+
df.columns = [col.strip().lower().replace(' ', '_') for col in df.columns]
|
| 69 |
+
|
| 70 |
+
# Detect columns
|
| 71 |
+
col_map = self._detect_columns(df)
|
| 72 |
+
|
| 73 |
+
# Select and rename relevant columns
|
| 74 |
+
required_cols = ['officename', 'pincode', 'district', 'state']
|
| 75 |
+
optional_cols = ['latitude', 'longitude', 'officetype', 'delivery']
|
| 76 |
+
|
| 77 |
+
# Filter valid records
|
| 78 |
+
df = df.dropna(subset=[col_map['officename'], col_map['pincode']])
|
| 79 |
+
|
| 80 |
+
# Create standardized dataframe
|
| 81 |
+
data_dict = {
|
| 82 |
+
'officename': df[col_map['officename']],
|
| 83 |
+
'pincode': df[col_map['pincode']].astype(str),
|
| 84 |
+
'district': df[col_map.get('district', col_map['officename'])],
|
| 85 |
+
'state': df[col_map.get('state', col_map['officename'])]
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
# Add optional columns if available
|
| 89 |
+
if col_map.get('latitude'):
|
| 90 |
+
data_dict['latitude'] = pd.to_numeric(df[col_map['latitude']], errors='coerce')
|
| 91 |
+
if col_map.get('longitude'):
|
| 92 |
+
data_dict['longitude'] = pd.to_numeric(df[col_map['longitude']], errors='coerce')
|
| 93 |
+
if col_map.get('officetype'):
|
| 94 |
+
data_dict['officetype'] = df[col_map['officetype']]
|
| 95 |
+
if col_map.get('delivery'):
|
| 96 |
+
data_dict['delivery'] = df[col_map['delivery']]
|
| 97 |
+
|
| 98 |
+
self.df = pd.DataFrame(data_dict)
|
| 99 |
+
|
| 100 |
+
# Filter delivery offices only
|
| 101 |
+
if 'delivery' in self.df.columns:
|
| 102 |
+
self.df = self.df[self.df['delivery'].str.lower().str.contains('delivery', na=False)]
|
| 103 |
+
|
| 104 |
+
# Create searchable text combining all fields
|
| 105 |
+
self.df['search_text'] = (
|
| 106 |
+
self.df['officename'].astype(str) + ' ' +
|
| 107 |
+
self.df['district'].astype(str) + ' ' +
|
| 108 |
+
self.df['state'].astype(str) + ' ' +
|
| 109 |
+
self.df['pincode'].astype(str)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Normalize search text
|
| 113 |
+
self.df['search_text_norm'] = self.df['search_text'].apply(normalize_text)
|
| 114 |
+
|
| 115 |
+
# Generate DIGIPIN (will be done lazily when needed, not during init)
|
| 116 |
+
# Set to N/A initially to avoid startup delays
|
| 117 |
+
self.df['digipin'] = 'N/A'
|
| 118 |
+
|
| 119 |
+
self.total_records = len(self.df)
|
| 120 |
+
print(f"✅ Loaded {self.total_records} post office records")
|
| 121 |
+
|
| 122 |
+
except Exception as e:
|
| 123 |
+
raise Exception(f"Failed to load dataset: {str(e)}")
|
| 124 |
+
|
| 125 |
+
def _detect_columns(self, df: pd.DataFrame) -> Dict[str, str]:
|
| 126 |
+
"""Auto-detect column names from dataset"""
|
| 127 |
+
cols = [c.strip().lower() for c in df.columns]
|
| 128 |
+
mapping = {}
|
| 129 |
+
|
| 130 |
+
def find(keywords):
|
| 131 |
+
for keyword in keywords:
|
| 132 |
+
for col in cols:
|
| 133 |
+
if keyword in col:
|
| 134 |
+
return col
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
mapping['officename'] = find(['officename', 'office_name', 'po_name', 'name'])
|
| 138 |
+
mapping['pincode'] = find(['pincode', 'postalcode', 'pin', 'postal_code'])
|
| 139 |
+
mapping['district'] = find(['district'])
|
| 140 |
+
mapping['state'] = find(['state', 'statename'])
|
| 141 |
+
mapping['latitude'] = find(['lat', 'latitude'])
|
| 142 |
+
mapping['longitude'] = find(['lon', 'lng', 'longitude'])
|
| 143 |
+
mapping['officetype'] = find(['officetype', 'office_type', 'type'])
|
| 144 |
+
mapping['delivery'] = find(['delivery'])
|
| 145 |
+
|
| 146 |
+
return mapping
|
| 147 |
+
|
| 148 |
+
def _generate_digipin(self, row) -> str:
|
| 149 |
+
"""Generate DIGIPIN using the real DIGIPIN API"""
|
| 150 |
+
try:
|
| 151 |
+
# Use lat/long if available to call real DIGIPIN API
|
| 152 |
+
if pd.notna(row.get('latitude')) and pd.notna(row.get('longitude')):
|
| 153 |
+
try:
|
| 154 |
+
# Call real DIGIPIN encode API
|
| 155 |
+
response = requests.post(
|
| 156 |
+
f"{self.digipin_api}/api/digipin/encode",
|
| 157 |
+
json={
|
| 158 |
+
"latitude": float(row['latitude']),
|
| 159 |
+
"longitude": float(row['longitude'])
|
| 160 |
+
},
|
| 161 |
+
timeout=5
|
| 162 |
+
)
|
| 163 |
+
if response.status_code == 200:
|
| 164 |
+
return response.json().get('digipin', 'N/A')
|
| 165 |
+
except Exception as e:
|
| 166 |
+
print(f"⚠️ DIGIPIN API call failed: {e}")
|
| 167 |
+
|
| 168 |
+
# Fallback: If DIGIPIN API is unavailable or no lat/long, return N/A
|
| 169 |
+
return "N/A"
|
| 170 |
+
except:
|
| 171 |
+
return "N/A"
|
| 172 |
+
|
| 173 |
+
def _generate_digipin_for_coords(self, latitude: float, longitude: float) -> str:
|
| 174 |
+
"""Generate DIGIPIN for specific coordinates using real DIGIPIN API"""
|
| 175 |
+
try:
|
| 176 |
+
response = requests.post(
|
| 177 |
+
f"{self.digipin_api}/api/digipin/encode",
|
| 178 |
+
json={
|
| 179 |
+
"latitude": latitude,
|
| 180 |
+
"longitude": longitude
|
| 181 |
+
},
|
| 182 |
+
timeout=2 # Short timeout to avoid hanging
|
| 183 |
+
)
|
| 184 |
+
if response.status_code == 200:
|
| 185 |
+
return response.json().get('digipin', 'N/A')
|
| 186 |
+
except Exception as e:
|
| 187 |
+
print(f"⚠️ DIGIPIN API call failed: {e}")
|
| 188 |
+
return "N/A"
|
| 189 |
+
|
| 190 |
+
async def _load_model(self):
|
| 191 |
+
"""Load sentence transformer model"""
|
| 192 |
+
try:
|
| 193 |
+
self.model = SentenceTransformer(self.model_name)
|
| 194 |
+
print(f"✅ Model loaded: {self.model_name}")
|
| 195 |
+
except Exception as e:
|
| 196 |
+
raise Exception(f"Failed to load model: {str(e)}")
|
| 197 |
+
|
| 198 |
+
async def _build_index(self):
|
| 199 |
+
"""Build FAISS index from embeddings"""
|
| 200 |
+
try:
|
| 201 |
+
# Generate embeddings for all records
|
| 202 |
+
print(f"Encoding {len(self.df)} records...")
|
| 203 |
+
texts = self.df['search_text_norm'].tolist()
|
| 204 |
+
|
| 205 |
+
embeddings = self.model.encode(
|
| 206 |
+
texts,
|
| 207 |
+
batch_size=128,
|
| 208 |
+
show_progress_bar=True,
|
| 209 |
+
convert_to_numpy=True
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
# Normalize embeddings for cosine similarity
|
| 213 |
+
faiss.normalize_L2(embeddings)
|
| 214 |
+
|
| 215 |
+
# Create FAISS index
|
| 216 |
+
dimension = embeddings.shape[1]
|
| 217 |
+
self.index = faiss.IndexFlatIP(dimension) # Inner product = cosine similarity
|
| 218 |
+
self.index.add(embeddings.astype('float32'))
|
| 219 |
+
|
| 220 |
+
# Store metadata separately
|
| 221 |
+
self.metadata = self.df[[
|
| 222 |
+
'officename', 'district', 'state', 'pincode',
|
| 223 |
+
'digipin', 'search_text'
|
| 224 |
+
]].copy()
|
| 225 |
+
|
| 226 |
+
# Add lat/long if available
|
| 227 |
+
if 'latitude' in self.df.columns:
|
| 228 |
+
self.metadata['latitude'] = self.df['latitude']
|
| 229 |
+
if 'longitude' in self.df.columns:
|
| 230 |
+
self.metadata['longitude'] = self.df['longitude']
|
| 231 |
+
if 'officetype' in self.df.columns:
|
| 232 |
+
self.metadata['officetype'] = self.df['officetype']
|
| 233 |
+
|
| 234 |
+
print(f"✅ FAISS index built with dimension {dimension}")
|
| 235 |
+
|
| 236 |
+
except Exception as e:
|
| 237 |
+
raise Exception(f"Failed to build index: {str(e)}")
|
| 238 |
+
|
| 239 |
+
def _cache_exists(self) -> bool:
|
| 240 |
+
"""Check if cache files exist"""
|
| 241 |
+
return (
|
| 242 |
+
os.path.exists(self.embeddings_path) and
|
| 243 |
+
os.path.exists(self.index_path) and
|
| 244 |
+
os.path.exists(self.metadata_path)
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
async def _save_to_cache(self):
|
| 248 |
+
"""Save embeddings, index, and metadata to disk"""
|
| 249 |
+
try:
|
| 250 |
+
# Save FAISS index
|
| 251 |
+
faiss.write_index(self.index, self.index_path)
|
| 252 |
+
|
| 253 |
+
# Save metadata
|
| 254 |
+
self.metadata.to_pickle(self.metadata_path)
|
| 255 |
+
|
| 256 |
+
print(f"✅ Cache saved to {self.cache_dir}")
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"⚠️ Warning: Failed to save cache: {str(e)}")
|
| 260 |
+
|
| 261 |
+
async def _load_from_cache(self):
|
| 262 |
+
"""Load embeddings, index, and metadata from disk"""
|
| 263 |
+
try:
|
| 264 |
+
# Load FAISS index
|
| 265 |
+
self.index = faiss.read_index(self.index_path)
|
| 266 |
+
|
| 267 |
+
# Load metadata
|
| 268 |
+
self.metadata = pd.read_pickle(self.metadata_path)
|
| 269 |
+
|
| 270 |
+
print(f"✅ Cache loaded from {self.cache_dir}")
|
| 271 |
+
|
| 272 |
+
except Exception as e:
|
| 273 |
+
raise Exception(f"Failed to load cache: {str(e)}")
|
| 274 |
+
|
| 275 |
+
def clear_cache(self):
|
| 276 |
+
"""Clear cached files"""
|
| 277 |
+
try:
|
| 278 |
+
if os.path.exists(self.embeddings_path):
|
| 279 |
+
os.remove(self.embeddings_path)
|
| 280 |
+
if os.path.exists(self.index_path):
|
| 281 |
+
os.remove(self.index_path)
|
| 282 |
+
if os.path.exists(self.metadata_path):
|
| 283 |
+
os.remove(self.metadata_path)
|
| 284 |
+
print(f"✅ Cache cleared from {self.cache_dir}")
|
| 285 |
+
except Exception as e:
|
| 286 |
+
print(f"⚠️ Warning: Failed to clear cache: {str(e)}")
|
| 287 |
+
|
| 288 |
+
async def match(
|
| 289 |
+
self,
|
| 290 |
+
query_text: str,
|
| 291 |
+
top_k: int = 5,
|
| 292 |
+
include_digipin: bool = True
|
| 293 |
+
) -> Dict:
|
| 294 |
+
"""
|
| 295 |
+
Match query address to post offices
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
query_text: Address text to match
|
| 299 |
+
top_k: Number of top matches to return
|
| 300 |
+
include_digipin: Whether to include DIGIPIN codes
|
| 301 |
+
|
| 302 |
+
Returns:
|
| 303 |
+
Dictionary with matches and metadata
|
| 304 |
+
"""
|
| 305 |
+
start_time = time.time()
|
| 306 |
+
|
| 307 |
+
# Clean and normalize query
|
| 308 |
+
normalized_query = normalize_text(query_text)
|
| 309 |
+
cleaned_query = clean_address(query_text)
|
| 310 |
+
|
| 311 |
+
# Extract PIN code from query if present
|
| 312 |
+
query_pincode = extract_pincode(query_text)
|
| 313 |
+
|
| 314 |
+
# Generate query embedding
|
| 315 |
+
query_embedding = self.model.encode(
|
| 316 |
+
[cleaned_query],
|
| 317 |
+
convert_to_numpy=True
|
| 318 |
+
)
|
| 319 |
+
faiss.normalize_L2(query_embedding)
|
| 320 |
+
|
| 321 |
+
# Search FAISS index
|
| 322 |
+
similarities, indices = self.index.search(
|
| 323 |
+
query_embedding.astype('float32'),
|
| 324 |
+
top_k * 3 # Get more candidates for re-ranking
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Build candidate list
|
| 328 |
+
candidates = []
|
| 329 |
+
for sim, idx in zip(similarities[0], indices[0]):
|
| 330 |
+
if idx == -1:
|
| 331 |
+
continue
|
| 332 |
+
|
| 333 |
+
record = self.metadata.iloc[idx]
|
| 334 |
+
|
| 335 |
+
# Calculate confidence score
|
| 336 |
+
confidence = self._calculate_confidence(
|
| 337 |
+
similarity=float(sim),
|
| 338 |
+
record=record,
|
| 339 |
+
query=cleaned_query,
|
| 340 |
+
query_pincode=query_pincode
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# Build match result
|
| 344 |
+
match = {
|
| 345 |
+
'officename': str(record['officename']),
|
| 346 |
+
'district': str(record['district']),
|
| 347 |
+
'state': str(record['state']),
|
| 348 |
+
'pincode': str(record['pincode']),
|
| 349 |
+
'similarity': round(float(sim), 4),
|
| 350 |
+
'confidence': round(confidence, 4)
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
# Add optional fields
|
| 354 |
+
if include_digipin:
|
| 355 |
+
# Generate DIGIPIN on-demand only when requested
|
| 356 |
+
if 'latitude' in record and pd.notna(record['latitude']) and 'longitude' in record and pd.notna(record['longitude']):
|
| 357 |
+
digipin = self._generate_digipin_for_coords(float(record['latitude']), float(record['longitude']))
|
| 358 |
+
match['digipin'] = digipin
|
| 359 |
+
else:
|
| 360 |
+
match['digipin'] = 'N/A'
|
| 361 |
+
|
| 362 |
+
if 'latitude' in record and pd.notna(record['latitude']):
|
| 363 |
+
match['latitude'] = float(record['latitude'])
|
| 364 |
+
if 'longitude' in record and pd.notna(record['longitude']):
|
| 365 |
+
match['longitude'] = float(record['longitude'])
|
| 366 |
+
if 'officetype' in record:
|
| 367 |
+
match['officetype'] = str(record['officetype'])
|
| 368 |
+
|
| 369 |
+
# Add matched tokens for explainability
|
| 370 |
+
match['matched_tokens'] = highlight_matching_tokens(
|
| 371 |
+
cleaned_query,
|
| 372 |
+
record['search_text']
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
candidates.append(match)
|
| 376 |
+
|
| 377 |
+
# Sort by confidence
|
| 378 |
+
candidates.sort(key=lambda x: x['confidence'], reverse=True)
|
| 379 |
+
|
| 380 |
+
# Take top K
|
| 381 |
+
final_matches = candidates[:top_k]
|
| 382 |
+
|
| 383 |
+
# Add rank
|
| 384 |
+
for i, match in enumerate(final_matches, 1):
|
| 385 |
+
match['rank'] = i
|
| 386 |
+
|
| 387 |
+
processing_time = (time.time() - start_time) * 1000 # Convert to ms
|
| 388 |
+
|
| 389 |
+
return {
|
| 390 |
+
'query': query_text,
|
| 391 |
+
'normalized_query': normalized_query,
|
| 392 |
+
'matches': final_matches,
|
| 393 |
+
'processing_time_ms': round(processing_time, 2)
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
def _calculate_confidence(
|
| 397 |
+
self,
|
| 398 |
+
similarity: float,
|
| 399 |
+
record: pd.Series,
|
| 400 |
+
query: str,
|
| 401 |
+
query_pincode: str
|
| 402 |
+
) -> float:
|
| 403 |
+
"""
|
| 404 |
+
Calculate confidence score considering multiple factors
|
| 405 |
+
|
| 406 |
+
Args:
|
| 407 |
+
similarity: Embedding similarity score
|
| 408 |
+
record: Post office record
|
| 409 |
+
query: Normalized query text
|
| 410 |
+
query_pincode: Extracted PIN code from query
|
| 411 |
+
|
| 412 |
+
Returns:
|
| 413 |
+
Confidence score between 0 and 1
|
| 414 |
+
"""
|
| 415 |
+
confidence = similarity # Base confidence from embedding
|
| 416 |
+
|
| 417 |
+
# Boost if PIN code matches
|
| 418 |
+
if query_pincode and query_pincode == str(record['pincode']):
|
| 419 |
+
confidence = min(1.0, confidence + 0.2)
|
| 420 |
+
|
| 421 |
+
# Boost if office name appears in query
|
| 422 |
+
office_name = normalize_text(str(record['officename']))
|
| 423 |
+
if office_name in query:
|
| 424 |
+
confidence = min(1.0, confidence + 0.15)
|
| 425 |
+
|
| 426 |
+
# Boost if district appears in query
|
| 427 |
+
district_name = normalize_text(str(record['district']))
|
| 428 |
+
if district_name in query:
|
| 429 |
+
confidence = min(1.0, confidence + 0.1)
|
| 430 |
+
|
| 431 |
+
# Boost if state appears in query
|
| 432 |
+
state_name = normalize_text(str(record['state']))
|
| 433 |
+
if state_name in query:
|
| 434 |
+
confidence = min(1.0, confidence + 0.05)
|
| 435 |
+
|
| 436 |
+
return min(1.0, confidence)
|
requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core Dependencies
|
| 2 |
+
fastapi==0.115.4
|
| 3 |
+
uvicorn[standard]==0.32.0
|
| 4 |
+
python-multipart==0.0.17
|
| 5 |
+
pydantic==2.9.2
|
| 6 |
+
|
| 7 |
+
# ML & AI
|
| 8 |
+
sentence-transformers==3.2.1
|
| 9 |
+
faiss-cpu==1.12.0
|
| 10 |
+
numpy==1.26.4
|
| 11 |
+
pandas==2.2.3
|
| 12 |
+
|
| 13 |
+
# OCR
|
| 14 |
+
pytesseract==0.3.13
|
| 15 |
+
Pillow==11.0.0
|
| 16 |
+
|
| 17 |
+
# Utilities
|
| 18 |
+
certifi==2024.8.30
|
| 19 |
+
python-dotenv==1.0.1
|
| 20 |
+
|
| 21 |
+
# Database
|
| 22 |
+
# sqlite3 is a built-in Python module, no need to install it
|
test_service.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import time
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
BASE_URL = "http://localhost:8000"
|
| 7 |
+
TIMEOUT = 30
|
| 8 |
+
|
| 9 |
+
def colored(text, color):
|
| 10 |
+
colors = {
|
| 11 |
+
'green': '\033[92m',
|
| 12 |
+
'red': '\033[91m',
|
| 13 |
+
'yellow': '\033[93m',
|
| 14 |
+
'blue': '\033[94m',
|
| 15 |
+
'reset': '\033[0m'
|
| 16 |
+
}
|
| 17 |
+
return f"{colors.get(color, '')}{text}{colors['reset']}"
|
| 18 |
+
|
| 19 |
+
def test_health():
|
| 20 |
+
"""Test health endpoint"""
|
| 21 |
+
print("\n" + "="*60)
|
| 22 |
+
print(colored("TEST 1: Health Check", 'blue'))
|
| 23 |
+
print("="*60)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
response = requests.get(f"{BASE_URL}/health", timeout=TIMEOUT)
|
| 27 |
+
if response.status_code == 200:
|
| 28 |
+
data = response.json()
|
| 29 |
+
print(colored("✓ Service is healthy", 'green'))
|
| 30 |
+
print(f" Model loaded: {data.get('model_loaded')}")
|
| 31 |
+
print(f" Index loaded: {data.get('index_loaded')}")
|
| 32 |
+
print(f" Total records: {data.get('total_records')}")
|
| 33 |
+
return True
|
| 34 |
+
else:
|
| 35 |
+
print(colored("✗ Health check failed", 'red'))
|
| 36 |
+
return False
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(colored(f"✗ Error: {e}", 'red'))
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
def test_normalize():
|
| 42 |
+
"""Test normalization endpoint"""
|
| 43 |
+
print("\n" + "="*60)
|
| 44 |
+
print(colored("TEST 2: Address Normalization", 'blue'))
|
| 45 |
+
print("="*60)
|
| 46 |
+
|
| 47 |
+
test_cases = [
|
| 48 |
+
"Kothimir PO, Asifabad Dist, TG-504273",
|
| 49 |
+
"koramangala bangalore 560034",
|
| 50 |
+
"Mr. John S/O Ram, Gurgaon Near DLF Phase 2, HR"
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
passed = 0
|
| 54 |
+
for address in test_cases:
|
| 55 |
+
try:
|
| 56 |
+
response = requests.post(
|
| 57 |
+
f"{BASE_URL}/api/ml/normalize",
|
| 58 |
+
json={"text": address},
|
| 59 |
+
timeout=TIMEOUT
|
| 60 |
+
)
|
| 61 |
+
if response.status_code == 200:
|
| 62 |
+
data = response.json()
|
| 63 |
+
print(colored(f"\n✓ Test passed", 'green'))
|
| 64 |
+
print(f" Original: {data['original']}")
|
| 65 |
+
print(f" Cleaned: {data['cleaned']}")
|
| 66 |
+
passed += 1
|
| 67 |
+
else:
|
| 68 |
+
print(colored(f"✗ Failed for: {address}", 'red'))
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(colored(f"✗ Error: {e}", 'red'))
|
| 71 |
+
|
| 72 |
+
print(f"\nPassed: {passed}/{len(test_cases)}")
|
| 73 |
+
return passed == len(test_cases)
|
| 74 |
+
|
| 75 |
+
def test_match():
|
| 76 |
+
"""Test address matching endpoint"""
|
| 77 |
+
print("\n" + "="*60)
|
| 78 |
+
print(colored("TEST 3: Address Matching", 'blue'))
|
| 79 |
+
print("="*60)
|
| 80 |
+
|
| 81 |
+
test_cases = [
|
| 82 |
+
{
|
| 83 |
+
"text": "Kothimir post office Asifabad Telangana",
|
| 84 |
+
"expected_pincode": "504273"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"text": "koramangala bangalore 560034",
|
| 88 |
+
"expected_pincode": "560034"
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"text": "Connaught Place New Delhi",
|
| 92 |
+
"expected_pincode": "110001"
|
| 93 |
+
}
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
passed = 0
|
| 97 |
+
for test in test_cases:
|
| 98 |
+
try:
|
| 99 |
+
response = requests.post(
|
| 100 |
+
f"{BASE_URL}/api/ml/match",
|
| 101 |
+
json={"text": test["text"], "top_k": 3},
|
| 102 |
+
timeout=TIMEOUT
|
| 103 |
+
)
|
| 104 |
+
if response.status_code == 200:
|
| 105 |
+
data = response.json()
|
| 106 |
+
matches = data.get('matches', [])
|
| 107 |
+
|
| 108 |
+
if matches:
|
| 109 |
+
top_match = matches[0]
|
| 110 |
+
print(colored(f"\n✓ Match found", 'green'))
|
| 111 |
+
print(f" Query: {test['text']}")
|
| 112 |
+
print(f" Top Result:")
|
| 113 |
+
print(f" Office: {top_match['officename']}")
|
| 114 |
+
print(f" PIN: {top_match['pincode']}")
|
| 115 |
+
print(f" District: {top_match['district']}")
|
| 116 |
+
print(f" State: {top_match['state']}")
|
| 117 |
+
print(f" Confidence: {top_match['confidence']:.2%}")
|
| 118 |
+
print(f" Processing time: {data.get('processing_time_ms', 0):.2f}ms")
|
| 119 |
+
|
| 120 |
+
# Check if expected PIN is in top 3
|
| 121 |
+
found_pins = [m['pincode'] for m in matches]
|
| 122 |
+
if test['expected_pincode'] in found_pins:
|
| 123 |
+
print(colored(f" ✓ Expected PIN found in top {found_pins.index(test['expected_pincode'])+1}", 'green'))
|
| 124 |
+
passed += 1
|
| 125 |
+
else:
|
| 126 |
+
print(colored(f" ⚠ Expected PIN {test['expected_pincode']} not in top 3", 'yellow'))
|
| 127 |
+
print(f" Got PINs: {found_pins}")
|
| 128 |
+
else:
|
| 129 |
+
print(colored(f"✗ No matches found for: {test['text']}", 'red'))
|
| 130 |
+
else:
|
| 131 |
+
print(colored(f"✗ Request failed: {response.status_code}", 'red'))
|
| 132 |
+
except Exception as e:
|
| 133 |
+
print(colored(f"✗ Error: {e}", 'red'))
|
| 134 |
+
|
| 135 |
+
print(f"\nPassed: {passed}/{len(test_cases)}")
|
| 136 |
+
return passed >= len(test_cases) - 1 # Allow 1 failure
|
| 137 |
+
|
| 138 |
+
def test_performance():
|
| 139 |
+
"""Test performance metrics"""
|
| 140 |
+
print("\n" + "="*60)
|
| 141 |
+
print(colored("TEST 4: Performance Metrics", 'blue'))
|
| 142 |
+
print("="*60)
|
| 143 |
+
|
| 144 |
+
test_text = "Koramangala Bangalore Karnataka 560034"
|
| 145 |
+
times = []
|
| 146 |
+
|
| 147 |
+
print("Running 10 requests...")
|
| 148 |
+
for i in range(10):
|
| 149 |
+
try:
|
| 150 |
+
start = time.time()
|
| 151 |
+
response = requests.post(
|
| 152 |
+
f"{BASE_URL}/api/ml/match",
|
| 153 |
+
json={"text": test_text, "top_k": 5},
|
| 154 |
+
timeout=TIMEOUT
|
| 155 |
+
)
|
| 156 |
+
elapsed = (time.time() - start) * 1000
|
| 157 |
+
times.append(elapsed)
|
| 158 |
+
print(f" Request {i+1}: {elapsed:.2f}ms")
|
| 159 |
+
except Exception as e:
|
| 160 |
+
print(colored(f" Request {i+1} failed: {e}", 'red'))
|
| 161 |
+
|
| 162 |
+
if times:
|
| 163 |
+
avg_time = sum(times) / len(times)
|
| 164 |
+
min_time = min(times)
|
| 165 |
+
max_time = max(times)
|
| 166 |
+
|
| 167 |
+
print(f"\nPerformance Summary:")
|
| 168 |
+
print(f" Average: {avg_time:.2f}ms")
|
| 169 |
+
print(f" Min: {min_time:.2f}ms")
|
| 170 |
+
print(f" Max: {max_time:.2f}ms")
|
| 171 |
+
|
| 172 |
+
if avg_time < 500:
|
| 173 |
+
print(colored("✓ Performance target met (< 500ms)", 'green'))
|
| 174 |
+
return True
|
| 175 |
+
else:
|
| 176 |
+
print(colored("⚠ Performance below target", 'yellow'))
|
| 177 |
+
return False
|
| 178 |
+
else:
|
| 179 |
+
print(colored("✗ No successful requests", 'red'))
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
def wait_for_service(max_wait=120):
|
| 183 |
+
"""Wait for service to be ready"""
|
| 184 |
+
print(colored("\nWaiting for ML service to start...", 'yellow'))
|
| 185 |
+
print("(This may take 30-90s on first run while downloading models)")
|
| 186 |
+
|
| 187 |
+
start_time = time.time()
|
| 188 |
+
while time.time() - start_time < max_wait:
|
| 189 |
+
try:
|
| 190 |
+
response = requests.get(f"{BASE_URL}/health", timeout=5)
|
| 191 |
+
if response.status_code == 200:
|
| 192 |
+
data = response.json()
|
| 193 |
+
if data.get('status') == 'healthy':
|
| 194 |
+
print(colored("✓ Service is ready!", 'green'))
|
| 195 |
+
return True
|
| 196 |
+
except:
|
| 197 |
+
pass
|
| 198 |
+
|
| 199 |
+
elapsed = int(time.time() - start_time)
|
| 200 |
+
print(f" Waiting... ({elapsed}s)", end='\r')
|
| 201 |
+
time.sleep(2)
|
| 202 |
+
|
| 203 |
+
print(colored("\n✗ Service did not start in time", 'red'))
|
| 204 |
+
return False
|
| 205 |
+
|
| 206 |
+
def main():
|
| 207 |
+
print(colored("\n" + "="*60, 'blue'))
|
| 208 |
+
print(colored(" ML Service Test Suite - Challenge 1", 'blue'))
|
| 209 |
+
print(colored("="*60 + "\n", 'blue'))
|
| 210 |
+
|
| 211 |
+
# Wait for service
|
| 212 |
+
if not wait_for_service():
|
| 213 |
+
print(colored("\n✗ Service not available. Please start with:", 'red'))
|
| 214 |
+
print(" python main.py")
|
| 215 |
+
print(" or")
|
| 216 |
+
print(" docker-compose up ml-service")
|
| 217 |
+
sys.exit(1)
|
| 218 |
+
|
| 219 |
+
# Run tests
|
| 220 |
+
results = []
|
| 221 |
+
results.append(("Health Check", test_health()))
|
| 222 |
+
results.append(("Normalization", test_normalize()))
|
| 223 |
+
results.append(("Address Matching", test_match()))
|
| 224 |
+
results.append(("Performance", test_performance()))
|
| 225 |
+
|
| 226 |
+
# Summary
|
| 227 |
+
print("\n" + "="*60)
|
| 228 |
+
print(colored("TEST SUMMARY", 'blue'))
|
| 229 |
+
print("="*60)
|
| 230 |
+
|
| 231 |
+
passed = sum(1 for _, result in results if result)
|
| 232 |
+
total = len(results)
|
| 233 |
+
|
| 234 |
+
for test_name, result in results:
|
| 235 |
+
status = colored("✓ PASS", 'green') if result else colored("✗ FAIL", 'red')
|
| 236 |
+
print(f"{test_name:20s} {status}")
|
| 237 |
+
|
| 238 |
+
print(f"\nTotal: {passed}/{total} tests passed")
|
| 239 |
+
|
| 240 |
+
if passed == total:
|
| 241 |
+
print(colored("\n🎉 All tests passed! Challenge 1 implementation is working!", 'green'))
|
| 242 |
+
sys.exit(0)
|
| 243 |
+
else:
|
| 244 |
+
print(colored(f"\n⚠ {total - passed} test(s) failed", 'yellow'))
|
| 245 |
+
sys.exit(1)
|
| 246 |
+
|
| 247 |
+
if __name__ == "__main__":
|
| 248 |
+
main()
|
utils/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility modules for ML service
|
| 3 |
+
"""
|
utils/ocr.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OCR utilities for extracting text from images
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import io
|
| 6 |
+
from typing import Tuple
|
| 7 |
+
from PIL import Image, ImageEnhance, ImageFilter
|
| 8 |
+
import pytesseract
|
| 9 |
+
|
| 10 |
+
# Configure Tesseract path for different OS
|
| 11 |
+
if os.name == "nt": # Windows
|
| 12 |
+
tesseract_path = os.getenv("TESSERACT_PATH", r"C:\Program Files\Tesseract-OCR\tesseract.exe")
|
| 13 |
+
if os.path.exists(tesseract_path):
|
| 14 |
+
pytesseract.pytesseract.tesseract_cmd = tesseract_path
|
| 15 |
+
elif os.name == "posix": # Linux/Mac
|
| 16 |
+
# Tesseract should be in PATH or at common locations
|
| 17 |
+
common_paths = [
|
| 18 |
+
"/usr/bin/tesseract",
|
| 19 |
+
"/usr/local/bin/tesseract",
|
| 20 |
+
"/opt/homebrew/bin/tesseract"
|
| 21 |
+
]
|
| 22 |
+
for path in common_paths:
|
| 23 |
+
if os.path.exists(path):
|
| 24 |
+
pytesseract.pytesseract.tesseract_cmd = path
|
| 25 |
+
break
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def preprocess_image(image: Image.Image) -> Image.Image:
|
| 29 |
+
"""
|
| 30 |
+
Preprocess image for better OCR results
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
image: PIL Image object
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
Preprocessed PIL Image
|
| 37 |
+
"""
|
| 38 |
+
# Convert to grayscale
|
| 39 |
+
image = image.convert('L')
|
| 40 |
+
|
| 41 |
+
# Increase contrast
|
| 42 |
+
enhancer = ImageEnhance.Contrast(image)
|
| 43 |
+
image = enhancer.enhance(2.0)
|
| 44 |
+
|
| 45 |
+
# Increase sharpness
|
| 46 |
+
enhancer = ImageEnhance.Sharpness(image)
|
| 47 |
+
image = enhancer.enhance(1.5)
|
| 48 |
+
|
| 49 |
+
# Apply slight blur to reduce noise
|
| 50 |
+
image = image.filter(ImageFilter.MedianFilter(size=3))
|
| 51 |
+
|
| 52 |
+
# Resize if too small (OCR works better on larger images)
|
| 53 |
+
width, height = image.size
|
| 54 |
+
if width < 1000 or height < 1000:
|
| 55 |
+
scale_factor = max(1000 / width, 1000 / height)
|
| 56 |
+
new_size = (int(width * scale_factor), int(height * scale_factor))
|
| 57 |
+
image = image.resize(new_size, Image.Resampling.LANCZOS)
|
| 58 |
+
|
| 59 |
+
return image
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def extract_text_from_image(image_bytes: bytes) -> Tuple[str, float]:
|
| 63 |
+
"""
|
| 64 |
+
Extract text from image using OCR
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
image_bytes: Image file bytes
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Tuple of (extracted_text, confidence_score)
|
| 71 |
+
"""
|
| 72 |
+
try:
|
| 73 |
+
# Load image
|
| 74 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 75 |
+
|
| 76 |
+
# Preprocess image
|
| 77 |
+
processed_image = preprocess_image(image)
|
| 78 |
+
|
| 79 |
+
# Perform OCR with detailed output
|
| 80 |
+
ocr_data = pytesseract.image_to_data(
|
| 81 |
+
processed_image,
|
| 82 |
+
lang='eng',
|
| 83 |
+
output_type=pytesseract.Output.DICT
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Extract text and calculate confidence
|
| 87 |
+
text_parts = []
|
| 88 |
+
confidences = []
|
| 89 |
+
|
| 90 |
+
for i, conf in enumerate(ocr_data['conf']):
|
| 91 |
+
if int(conf) > 0: # Valid confidence
|
| 92 |
+
text = ocr_data['text'][i].strip()
|
| 93 |
+
if text:
|
| 94 |
+
text_parts.append(text)
|
| 95 |
+
confidences.append(int(conf))
|
| 96 |
+
|
| 97 |
+
# Combine text
|
| 98 |
+
extracted_text = ' '.join(text_parts)
|
| 99 |
+
|
| 100 |
+
# Calculate average confidence
|
| 101 |
+
avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
| 102 |
+
normalized_confidence = avg_confidence / 100.0 # Convert to 0-1 scale
|
| 103 |
+
|
| 104 |
+
return extracted_text, normalized_confidence
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
print(f"OCR extraction error: {e}")
|
| 108 |
+
# Fallback to simple OCR
|
| 109 |
+
try:
|
| 110 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 111 |
+
text = pytesseract.image_to_string(image, lang='eng')
|
| 112 |
+
return text.strip(), 0.5 # Default confidence
|
| 113 |
+
except Exception as fallback_error:
|
| 114 |
+
print(f"Fallback OCR also failed: {fallback_error}")
|
| 115 |
+
raise Exception(f"OCR failed: {str(e)}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def extract_text_simple(image_bytes: bytes) -> str:
|
| 119 |
+
"""
|
| 120 |
+
Simple text extraction without preprocessing
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
image_bytes: Image file bytes
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Extracted text string
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 130 |
+
text = pytesseract.image_to_string(image, lang='eng')
|
| 131 |
+
return text.strip()
|
| 132 |
+
except Exception as e:
|
| 133 |
+
raise Exception(f"Simple OCR failed: {str(e)}")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def is_tesseract_available() -> bool:
|
| 137 |
+
"""
|
| 138 |
+
Check if Tesseract OCR is available
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
True if Tesseract is available, False otherwise
|
| 142 |
+
"""
|
| 143 |
+
try:
|
| 144 |
+
pytesseract.get_tesseract_version()
|
| 145 |
+
return True
|
| 146 |
+
except:
|
| 147 |
+
return False
|
utils/text_processor.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Dict, List
|
| 3 |
+
|
| 4 |
+
# Common abbreviations in Indian addresses
|
| 5 |
+
ABBREVIATIONS = {
|
| 6 |
+
'po': 'post office',
|
| 7 |
+
'bo': 'branch office',
|
| 8 |
+
'so': 'sub office',
|
| 9 |
+
'ho': 'head office',
|
| 10 |
+
'dist': 'district',
|
| 11 |
+
'nr': 'near',
|
| 12 |
+
'rd': 'road',
|
| 13 |
+
'st': 'street',
|
| 14 |
+
'ave': 'avenue',
|
| 15 |
+
'blvd': 'boulevard',
|
| 16 |
+
'apt': 'apartment',
|
| 17 |
+
'bldg': 'building',
|
| 18 |
+
'flr': 'floor',
|
| 19 |
+
'dept': 'department',
|
| 20 |
+
'no': 'number',
|
| 21 |
+
'nagar': 'nagar',
|
| 22 |
+
'gali': 'gali',
|
| 23 |
+
'marg': 'marg',
|
| 24 |
+
'colony': 'colony',
|
| 25 |
+
'sector': 'sector',
|
| 26 |
+
'phase': 'phase',
|
| 27 |
+
'block': 'block',
|
| 28 |
+
'h/o': 'house of',
|
| 29 |
+
's/o': 'son of',
|
| 30 |
+
'd/o': 'daughter of',
|
| 31 |
+
'w/o': 'wife of',
|
| 32 |
+
'c/o': 'care of',
|
| 33 |
+
'tel': 'telangana',
|
| 34 |
+
'tg': 'telangana',
|
| 35 |
+
'ap': 'andhra pradesh',
|
| 36 |
+
'up': 'uttar pradesh',
|
| 37 |
+
'hp': 'himachal pradesh',
|
| 38 |
+
'mp': 'madhya pradesh',
|
| 39 |
+
'tn': 'tamil nadu',
|
| 40 |
+
'wb': 'west bengal',
|
| 41 |
+
'ka': 'karnataka',
|
| 42 |
+
'mh': 'maharashtra',
|
| 43 |
+
'dl': 'delhi',
|
| 44 |
+
'rj': 'rajasthan',
|
| 45 |
+
'pb': 'punjab',
|
| 46 |
+
'hr': 'haryana',
|
| 47 |
+
'jk': 'jammu kashmir',
|
| 48 |
+
'gj': 'gujarat',
|
| 49 |
+
'or': 'odisha',
|
| 50 |
+
'br': 'bihar',
|
| 51 |
+
'jh': 'jharkhand',
|
| 52 |
+
'as': 'assam',
|
| 53 |
+
'uk': 'uttarakhand',
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def normalize_text(text: str) -> str:
|
| 58 |
+
if not text:
|
| 59 |
+
return ""
|
| 60 |
+
|
| 61 |
+
# Convert to lowercase
|
| 62 |
+
text = text.lower()
|
| 63 |
+
|
| 64 |
+
# Remove special characters but keep spaces and alphanumeric
|
| 65 |
+
text = re.sub(r'[^a-z0-9\s]', ' ', text)
|
| 66 |
+
|
| 67 |
+
# Remove extra whitespace
|
| 68 |
+
text = re.sub(r'\s+', ' ', text)
|
| 69 |
+
|
| 70 |
+
# Strip leading/trailing whitespace
|
| 71 |
+
text = text.strip()
|
| 72 |
+
|
| 73 |
+
return text
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def expand_abbreviations(text: str) -> str:
|
| 77 |
+
words = text.split()
|
| 78 |
+
expanded_words = []
|
| 79 |
+
|
| 80 |
+
for word in words:
|
| 81 |
+
# Check if word is an abbreviation
|
| 82 |
+
if word.lower() in ABBREVIATIONS:
|
| 83 |
+
expanded_words.append(ABBREVIATIONS[word.lower()])
|
| 84 |
+
else:
|
| 85 |
+
expanded_words.append(word)
|
| 86 |
+
|
| 87 |
+
return ' '.join(expanded_words)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def extract_pincode(text: str) -> str:
|
| 91 |
+
# Look for 6-digit numbers
|
| 92 |
+
match = re.search(r'\b\d{6}\b', text)
|
| 93 |
+
if match:
|
| 94 |
+
return match.group(0)
|
| 95 |
+
return ""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def remove_personal_info(text: str) -> str:
|
| 99 |
+
# Remove mobile numbers (10 digits)
|
| 100 |
+
text = re.sub(r'\b\d{10}\b', '', text)
|
| 101 |
+
|
| 102 |
+
# Remove email addresses
|
| 103 |
+
text = re.sub(r'\S+@\S+', '', text)
|
| 104 |
+
|
| 105 |
+
# Remove patterns like "S/O", "D/O", "W/O" followed by names
|
| 106 |
+
text = re.sub(r'\b[sdw]/o\s+\w+\s+\w+', '', text, flags=re.IGNORECASE)
|
| 107 |
+
|
| 108 |
+
# Remove "Mr.", "Mrs.", "Ms.", "Dr." titles
|
| 109 |
+
text = re.sub(r'\b(mr|mrs|ms|dr|shri|smt)\.?\s+', '', text, flags=re.IGNORECASE)
|
| 110 |
+
|
| 111 |
+
return text
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def clean_address(text: str) -> str:
|
| 115 |
+
if not text:
|
| 116 |
+
return ""
|
| 117 |
+
|
| 118 |
+
# Step 1: Remove personal info
|
| 119 |
+
text = remove_personal_info(text)
|
| 120 |
+
|
| 121 |
+
# Step 2: Normalize
|
| 122 |
+
text = normalize_text(text)
|
| 123 |
+
|
| 124 |
+
# Step 3: Expand abbreviations
|
| 125 |
+
text = expand_abbreviations(text)
|
| 126 |
+
|
| 127 |
+
# Step 4: Final normalization
|
| 128 |
+
text = normalize_text(text)
|
| 129 |
+
|
| 130 |
+
return text
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def extract_address_components(text: str) -> Dict[str, str]:
|
| 134 |
+
components = {
|
| 135 |
+
'pincode': '',
|
| 136 |
+
'city': '',
|
| 137 |
+
'state': '',
|
| 138 |
+
'district': '',
|
| 139 |
+
'raw_text': text
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
# Extract PIN code
|
| 143 |
+
components['pincode'] = extract_pincode(text)
|
| 144 |
+
|
| 145 |
+
# Extract state (if matches known abbreviations)
|
| 146 |
+
text_lower = text.lower()
|
| 147 |
+
for abbr, full_name in ABBREVIATIONS.items():
|
| 148 |
+
if len(abbr) == 2 and abbr in text_lower.split():
|
| 149 |
+
components['state'] = full_name
|
| 150 |
+
break
|
| 151 |
+
|
| 152 |
+
return components
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def calculate_text_similarity(text1: str, text2: str) -> float:
|
| 156 |
+
# Normalize both texts
|
| 157 |
+
text1 = normalize_text(text1)
|
| 158 |
+
text2 = normalize_text(text2)
|
| 159 |
+
|
| 160 |
+
# Get word sets
|
| 161 |
+
set1 = set(text1.split())
|
| 162 |
+
set2 = set(text2.split())
|
| 163 |
+
|
| 164 |
+
# Calculate Jaccard similarity
|
| 165 |
+
if not set1 or not set2:
|
| 166 |
+
return 0.0
|
| 167 |
+
|
| 168 |
+
intersection = len(set1 & set2)
|
| 169 |
+
union = len(set1 | set2)
|
| 170 |
+
|
| 171 |
+
return intersection / union if union > 0 else 0.0
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def highlight_matching_tokens(query: str, target: str) -> List[str]:
|
| 175 |
+
query_tokens = set(normalize_text(query).split())
|
| 176 |
+
target_tokens = set(normalize_text(target).split())
|
| 177 |
+
|
| 178 |
+
return list(query_tokens & target_tokens)
|