imkrish commited on
Commit
98689fd
·
verified ·
1 Parent(s): 3fa80af

Upload folder using huggingface_hub

Browse files
Files changed (17) hide show
  1. .dockerignore +54 -0
  2. .env.example +19 -0
  3. .gitignore +72 -0
  4. Dockerfile +57 -0
  5. README.md +499 -3
  6. app.py +224 -0
  7. data_service.py +119 -0
  8. manage_cache.py +105 -0
  9. ml_ocr_service.py +121 -0
  10. ml_service.py +102 -0
  11. models/__init__.py +3 -0
  12. models/matcher.py +436 -0
  13. requirements.txt +22 -0
  14. test_service.py +248 -0
  15. utils/__init__.py +3 -0
  16. utils/ocr.py +147 -0
  17. 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
- license: mit
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)