Ranjit Behera commited on
Commit
9101d7e
·
1 Parent(s): 3a80a66

feat: Add comprehensive data pipeline and fine-tuning

Browse files

Data Pipeline (scripts/data_pipeline/):
- step1_unify.py: Unifies MBOX, JSON, CSV, XML sources
- step2_filter.py: Removes OTPs, spam, marketing
- step3_baseline.py: Tests regex extractor accuracy
- step4_label.py: Creates labeled training data

Synthetic Data Generator:
- generate_synthetic.py: Production-grade grammar-based generator
- 100K+ realistic Indian bank transactions
- All major banks (HDFC, ICICI, SBI, Axis, Kotak, etc.)
- Brokerages (Zerodha, Groww, Upstox, etc.)
- E-commerce, food, travel, utilities, entertainment
- generate_advanced.py: Advanced features
- Markov Chain for realistic message flow
- Real data calibration
- Multilingual (Hindi, Tamil, Telugu, Bengali, Kannada)
- Data augmentation and edge case oversampling

Fine-tuning Pipeline (scripts/finetune.py):
- Supports MLX (Apple Silicon) and PyTorch
- LoRA fine-tuning with automatic data prep
- Model fusion and evaluation

Training Results:
- 152K training records
- Val loss: 2.42 -> 0.46 (81% reduction)
- 100% JSON parsing on test cases
- Multilingual support working

scripts/data_pipeline/README.md ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data Pipeline
2
+
3
+ 3-step pipeline to prepare training data for FinEE model fine-tuning.
4
+
5
+ ## Overview
6
+
7
+ ```
8
+ Raw Data (XML/JSON/CSV/MBOX)
9
+
10
+
11
+ ┌────────────────────────┐
12
+ │ Step 1: Unify │ → step1_unified.csv
13
+ │ Standardize formats │ (all records, single schema)
14
+ └────────────────────────┘
15
+
16
+
17
+ ┌────────────────────────┐
18
+ │ Step 2: Filter │ → step2_training_ready.csv
19
+ │ Remove garbage │ (clean transactions only)
20
+ └────────────────────────┘
21
+
22
+
23
+ ┌────────────────────────┐
24
+ │ Step 3: Baseline │ → step3_baseline_results.csv
25
+ │ Test current accuracy │ (extracted fields + metrics)
26
+ └────────────────────────┘
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ### 1. Copy your data to the workspace
32
+
33
+ ```bash
34
+ # Example: Google Takeout export
35
+ cp -r ~/Downloads/Takeout /Users/ranjit/llm-mail-trainer/data/raw/
36
+
37
+ # Or SMS backup
38
+ cp sms_backup.xml /Users/ranjit/llm-mail-trainer/data/raw/
39
+ ```
40
+
41
+ ### 2. Run the pipeline
42
+
43
+ ```bash
44
+ cd /Users/ranjit/llm-mail-trainer
45
+
46
+ # Step 1: Unify all data formats
47
+ python scripts/data_pipeline/step1_unify.py --input data/raw/ --output data/pipeline/step1_unified.csv
48
+
49
+ # Step 2: Filter garbage (OTPs, spam, etc.)
50
+ python scripts/data_pipeline/step2_filter.py --input data/pipeline/step1_unified.csv
51
+
52
+ # Step 3: Test baseline accuracy
53
+ python scripts/data_pipeline/step3_baseline.py
54
+ ```
55
+
56
+ ## Supported Input Formats
57
+
58
+ | Format | Source | Example |
59
+ |--------|--------|---------|
60
+ | `.mbox` | Gmail export | Mail.mbox |
61
+ | `.json` | Google Takeout | transactions.json |
62
+ | `.csv` | Bank exports | statements.csv |
63
+ | `.xml` | SMS Backup apps | sms_backup.xml |
64
+
65
+ ## Output Schema
66
+
67
+ All data is standardized to:
68
+
69
+ | Column | Type | Description |
70
+ |--------|------|-------------|
71
+ | `timestamp` | string | When message was received |
72
+ | `sender` | string | Bank/sender name |
73
+ | `body` | string | Message content |
74
+ | `source` | string | Original file source |
75
+
76
+ ## Step 2 Filters
77
+
78
+ ### Messages REMOVED (Garbage):
79
+ - OTPs / Verification codes
80
+ - Login alerts
81
+ - Marketing spam (% off, offers)
82
+ - Bill reminders (not transactions)
83
+ - Account statements
84
+ - Delivery notifications
85
+
86
+ ### Messages KEPT (Transactions):
87
+ - Debit/Credit notifications
88
+ - UPI payments
89
+ - NEFT/IMPS transfers
90
+ - Amount + account references
91
+
92
+ ## Step 3 Metrics
93
+
94
+ The baseline test measures:
95
+
96
+ | Metric | Description |
97
+ |--------|-------------|
98
+ | Extraction Success Rate | % of messages where amount + type extracted |
99
+ | Field Coverage | % for each field (amount, merchant, etc.) |
100
+ | Confidence Distribution | LOW / MEDIUM / HIGH breakdown |
101
+ | Top Merchants | Most common extracted merchants |
102
+ | Processing Speed | Messages per second |
103
+
104
+ ## Directory Structure
105
+
106
+ ```
107
+ data/
108
+ ├── raw/ # Put your raw data here
109
+ │ └── Takeout/
110
+ │ └── ...
111
+ ├── pipeline/ # Pipeline outputs
112
+ │ ├── step1_unified.csv
113
+ │ ├── step2_training_ready.csv
114
+ │ ├── step2_garbage.csv (optional)
115
+ │ ├── step2_uncertain.csv (optional)
116
+ │ ├── step3_baseline_results.csv
117
+ │ └── step3_baseline_analysis.json
118
+ └── training/ # Final training data (after labeling)
119
+ ```
120
+
121
+ ## Next Steps
122
+
123
+ After Step 3:
124
+
125
+ 1. Review low-confidence extractions
126
+ 2. Add ground truth labels for training
127
+ 3. Identify patterns that need new regex
128
+ 4. Fine-tune the LLM on labeled data
scripts/data_pipeline/generate_advanced.py ADDED
@@ -0,0 +1,1149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Advanced Synthetic Data Generator v4.0
4
+ ======================================
5
+
6
+ New Features:
7
+ 1. Markov Chain for realistic message flow
8
+ 2. Real data calibration from actual samples
9
+ 3. Multilingual support (Hindi, Tamil, Telugu, Bengali, Kannada)
10
+ 4. PDF/Image generation for document training
11
+ 5. Statistical augmentation for rare edge cases
12
+
13
+ Author: Ranjit Behera
14
+ """
15
+
16
+ from __future__ import annotations
17
+ import json
18
+ import random
19
+ import hashlib
20
+ import argparse
21
+ import math
22
+ import pickle
23
+ from abc import ABC, abstractmethod
24
+ from dataclasses import dataclass, field, asdict
25
+ from datetime import datetime, timedelta, date
26
+ from decimal import Decimal, ROUND_HALF_UP
27
+ from enum import Enum, auto
28
+ from pathlib import Path
29
+ from typing import List, Dict, Optional, Tuple, Set, Any, Iterator
30
+ from collections import defaultdict, Counter
31
+ import re
32
+
33
+
34
+ # ============================================================================
35
+ # MARKOV CHAIN MESSAGE GENERATOR
36
+ # ============================================================================
37
+
38
+ class MarkovChain:
39
+ """
40
+ Markov Chain for realistic message structure.
41
+
42
+ Learns transition probabilities from real data to generate
43
+ messages that follow actual patterns.
44
+ """
45
+
46
+ def __init__(self, order: int = 2):
47
+ """
48
+ Args:
49
+ order: n-gram order (1 = unigram, 2 = bigram, etc.)
50
+ """
51
+ self.order = order
52
+ self.transitions: Dict[Tuple, Counter] = defaultdict(Counter)
53
+ self.start_states: Counter = Counter()
54
+
55
+ def train(self, messages: List[str]):
56
+ """Train on real messages."""
57
+ for message in messages:
58
+ tokens = self._tokenize(message)
59
+ if len(tokens) <= self.order:
60
+ continue
61
+
62
+ # Start state
63
+ start = tuple(tokens[:self.order])
64
+ self.start_states[start] += 1
65
+
66
+ # Build transitions
67
+ for i in range(len(tokens) - self.order):
68
+ state = tuple(tokens[i:i + self.order])
69
+ next_token = tokens[i + self.order]
70
+ self.transitions[state][next_token] += 1
71
+
72
+ def _tokenize(self, text: str) -> List[str]:
73
+ """Tokenize preserving structure."""
74
+ # Split on whitespace but keep special tokens
75
+ tokens = []
76
+ for word in text.split():
77
+ # Keep amounts as single tokens
78
+ if re.match(r'^Rs\.?\d', word) or re.match(r'^₹\d', word):
79
+ tokens.append(word)
80
+ # Keep VPAs together
81
+ elif '@' in word:
82
+ tokens.append(word)
83
+ else:
84
+ tokens.append(word)
85
+ return tokens
86
+
87
+ def generate(self, rng: random.Random, max_length: int = 50) -> str:
88
+ """Generate a message using learned transitions."""
89
+ if not self.start_states:
90
+ return ""
91
+
92
+ # Sample start state
93
+ states = list(self.start_states.keys())
94
+ weights = [self.start_states[s] for s in states]
95
+ current = list(rng.choices(states, weights=weights)[0])
96
+
97
+ result = list(current)
98
+
99
+ for _ in range(max_length - len(current)):
100
+ state = tuple(current[-self.order:])
101
+
102
+ if state not in self.transitions:
103
+ break
104
+
105
+ # Sample next token
106
+ next_tokens = list(self.transitions[state].keys())
107
+ weights = [self.transitions[state][t] for t in next_tokens]
108
+ next_token = rng.choices(next_tokens, weights=weights)[0]
109
+
110
+ result.append(next_token)
111
+ current.append(next_token)
112
+
113
+ return ' '.join(result)
114
+
115
+ def save(self, path: Path):
116
+ """Save trained model."""
117
+ with open(path, 'wb') as f:
118
+ pickle.dump({
119
+ 'order': self.order,
120
+ 'transitions': dict(self.transitions),
121
+ 'start_states': dict(self.start_states),
122
+ }, f)
123
+
124
+ @classmethod
125
+ def load(cls, path: Path) -> 'MarkovChain':
126
+ """Load trained model."""
127
+ with open(path, 'rb') as f:
128
+ data = pickle.load(f)
129
+
130
+ chain = cls(order=data['order'])
131
+ chain.transitions = defaultdict(Counter, {
132
+ k: Counter(v) for k, v in data['transitions'].items()
133
+ })
134
+ chain.start_states = Counter(data['start_states'])
135
+ return chain
136
+
137
+
138
+ class HybridGenerator:
139
+ """
140
+ Combines Markov Chain with template-based generation.
141
+
142
+ Uses Markov for structure, templates for entity placement.
143
+ """
144
+
145
+ def __init__(self, markov: MarkovChain):
146
+ self.markov = markov
147
+ self.entity_patterns = {
148
+ 'AMOUNT': r'Rs\.?\s*[\d,]+(?:\.\d{2})?',
149
+ 'ACCOUNT': r'XX\d{4}',
150
+ 'DATE': r'\d{1,2}[-/]\d{1,2}[-/]\d{2,4}',
151
+ 'REF': r'\d{12,16}',
152
+ 'VPA': r'[a-z0-9]+@[a-z]+',
153
+ }
154
+
155
+ def generate(self, entities: Dict[str, str], rng: random.Random) -> str:
156
+ """
157
+ Generate message with specific entities.
158
+
159
+ 1. Generate base structure from Markov
160
+ 2. Replace placeholders with actual entities
161
+ """
162
+ base = self.markov.generate(rng)
163
+
164
+ # Replace detected patterns with actual entities
165
+ for entity_type, pattern in self.entity_patterns.items():
166
+ if entity_type in entities:
167
+ base = re.sub(pattern, entities[entity_type], base, count=1)
168
+
169
+ return base
170
+
171
+
172
+ # ============================================================================
173
+ # REAL DATA CALIBRATION
174
+ # ============================================================================
175
+
176
+ @dataclass
177
+ class DistributionFit:
178
+ """Fitted statistical distribution."""
179
+ name: str
180
+ params: Dict[str, float]
181
+
182
+ def sample(self, rng: random.Random) -> float:
183
+ """Sample from fitted distribution."""
184
+ if self.name == 'normal':
185
+ value = rng.gauss(self.params['mean'], self.params['std'])
186
+ return max(self.params.get('min', 0),
187
+ min(self.params.get('max', float('inf')), value))
188
+
189
+ elif self.name == 'lognormal':
190
+ # Log-normal is better for amounts
191
+ log_value = rng.gauss(self.params['mu'], self.params['sigma'])
192
+ return math.exp(log_value)
193
+
194
+ elif self.name == 'exponential':
195
+ return rng.expovariate(1 / self.params['lambda'])
196
+
197
+ elif self.name == 'uniform':
198
+ return rng.uniform(self.params['min'], self.params['max'])
199
+
200
+ elif self.name == 'categorical':
201
+ items = list(self.params['categories'].keys())
202
+ weights = list(self.params['categories'].values())
203
+ return rng.choices(items, weights=weights)[0]
204
+
205
+ return 0
206
+
207
+
208
+ class DataCalibrator:
209
+ """
210
+ Calibrate synthetic distributions to match real data.
211
+
212
+ Fits statistical distributions to actual transaction data.
213
+ """
214
+
215
+ def __init__(self):
216
+ self.amount_dist: Optional[DistributionFit] = None
217
+ self.category_dist: Optional[DistributionFit] = None
218
+ self.bank_dist: Optional[DistributionFit] = None
219
+ self.hour_dist: Optional[DistributionFit] = None
220
+
221
+ # Amount by category
222
+ self.amount_by_category: Dict[str, DistributionFit] = {}
223
+
224
+ def fit_from_data(self, data: List[Dict]):
225
+ """Fit distributions from real data."""
226
+ if not data:
227
+ return
228
+
229
+ # Extract amounts
230
+ amounts = [r['amount'] for r in data if r.get('amount')]
231
+ if amounts:
232
+ mean_amt = sum(amounts) / len(amounts)
233
+ var_amt = sum((x - mean_amt) ** 2 for x in amounts) / len(amounts)
234
+ std_amt = math.sqrt(var_amt)
235
+
236
+ # Use log-normal for amounts (always positive, right-skewed)
237
+ log_amounts = [math.log(max(1, a)) for a in amounts]
238
+ mu = sum(log_amounts) / len(log_amounts)
239
+ sigma_sq = sum((x - mu) ** 2 for x in log_amounts) / len(log_amounts)
240
+
241
+ self.amount_dist = DistributionFit(
242
+ name='lognormal',
243
+ params={'mu': mu, 'sigma': math.sqrt(sigma_sq)}
244
+ )
245
+
246
+ # Fit categories
247
+ categories = Counter(r.get('category') for r in data if r.get('category'))
248
+ if categories:
249
+ total = sum(categories.values())
250
+ self.category_dist = DistributionFit(
251
+ name='categorical',
252
+ params={'categories': {k: v/total for k, v in categories.items()}}
253
+ )
254
+
255
+ # Fit banks
256
+ banks = Counter(r.get('bank') for r in data if r.get('bank'))
257
+ if banks:
258
+ total = sum(banks.values())
259
+ self.bank_dist = DistributionFit(
260
+ name='categorical',
261
+ params={'categories': {k: v/total for k, v in banks.items()}}
262
+ )
263
+
264
+ # Fit amounts by category
265
+ by_category = defaultdict(list)
266
+ for r in data:
267
+ if r.get('amount') and r.get('category'):
268
+ by_category[r['category']].append(r['amount'])
269
+
270
+ for cat, amounts in by_category.items():
271
+ if len(amounts) >= 10:
272
+ log_amounts = [math.log(max(1, a)) for a in amounts]
273
+ mu = sum(log_amounts) / len(log_amounts)
274
+ sigma_sq = sum((x - mu) ** 2 for x in log_amounts) / len(log_amounts)
275
+
276
+ self.amount_by_category[cat] = DistributionFit(
277
+ name='lognormal',
278
+ params={'mu': mu, 'sigma': max(0.1, math.sqrt(sigma_sq))}
279
+ )
280
+
281
+ def sample_amount(self, category: Optional[str], rng: random.Random) -> float:
282
+ """Sample amount, optionally by category."""
283
+ if category and category in self.amount_by_category:
284
+ return self.amount_by_category[category].sample(rng)
285
+ elif self.amount_dist:
286
+ return self.amount_dist.sample(rng)
287
+ else:
288
+ return rng.uniform(100, 10000)
289
+
290
+ def sample_category(self, rng: random.Random) -> str:
291
+ """Sample category from fitted distribution."""
292
+ if self.category_dist:
293
+ return self.category_dist.sample(rng)
294
+ return 'shopping'
295
+
296
+ def sample_bank(self, rng: random.Random) -> str:
297
+ """Sample bank from fitted distribution."""
298
+ if self.bank_dist:
299
+ return self.bank_dist.sample(rng)
300
+ return 'HDFC'
301
+
302
+ def save(self, path: Path):
303
+ """Save calibration."""
304
+ with open(path, 'wb') as f:
305
+ pickle.dump({
306
+ 'amount_dist': self.amount_dist,
307
+ 'category_dist': self.category_dist,
308
+ 'bank_dist': self.bank_dist,
309
+ 'amount_by_category': self.amount_by_category,
310
+ }, f)
311
+
312
+ @classmethod
313
+ def load(cls, path: Path) -> 'DataCalibrator':
314
+ """Load calibration."""
315
+ with open(path, 'rb') as f:
316
+ data = pickle.load(f)
317
+
318
+ calibrator = cls()
319
+ calibrator.amount_dist = data.get('amount_dist')
320
+ calibrator.category_dist = data.get('category_dist')
321
+ calibrator.bank_dist = data.get('bank_dist')
322
+ calibrator.amount_by_category = data.get('amount_by_category', {})
323
+ return calibrator
324
+
325
+
326
+ # ============================================================================
327
+ # MULTILINGUAL SUPPORT
328
+ # ============================================================================
329
+
330
+ class Language(Enum):
331
+ ENGLISH = "en"
332
+ HINDI = "hi"
333
+ TAMIL = "ta"
334
+ TELUGU = "te"
335
+ BENGALI = "bn"
336
+ KANNADA = "kn"
337
+ MARATHI = "mr"
338
+ GUJARATI = "gu"
339
+
340
+
341
+ @dataclass
342
+ class MultilingualTemplate:
343
+ """Template with translations."""
344
+ english: str
345
+ translations: Dict[Language, str]
346
+
347
+ def get(self, lang: Language) -> str:
348
+ """Get template in specified language."""
349
+ if lang == Language.ENGLISH:
350
+ return self.english
351
+ return self.translations.get(lang, self.english)
352
+
353
+
354
+ class MultilingualBank:
355
+ """
356
+ Bank SMS templates in multiple Indian languages.
357
+
358
+ Based on actual bank SMS formats in different languages.
359
+ """
360
+
361
+ TEMPLATES = {
362
+ 'debit': MultilingualTemplate(
363
+ english="{bank}: Rs.{amount} debited from A/c XX{account} on {date}. {vpa}. Ref: {ref}",
364
+ translations={
365
+ Language.HINDI: "{bank}: आपके खाते XX{account} से Rs.{amount} डेबिट हुआ। दिनांक {date}। {vpa}। संदर्भ: {ref}",
366
+ Language.TAMIL: "{bank}: உங்கள் கணக்கு XX{account} இல் இருந்து Rs.{amount} டெபிட் செய்யப்பட்டது. தேதி {date}. Ref: {ref}",
367
+ Language.TELUGU: "{bank}: మీ ఖాతా XX{account} నుండి Rs.{amount} డెబిట్ చేయబడింది. తేదీ {date}. {vpa}. Ref: {ref}",
368
+ Language.BENGALI: "{bank}: আপনার অ্যাকাউন্ট XX{account} থেকে Rs.{amount} ডেবিট হয়েছে। তারিখ {date}। Ref: {ref}",
369
+ Language.KANNADA: "{bank}: ನಿಮ್ಮ ಖಾತೆ XX{account} ನಿಂದ Rs.{amount} ಡೆಬಿಟ್ ಆಗಿದೆ. ದಿನಾಂಕ {date}. Ref: {ref}",
370
+ Language.MARATHI: "{bank}: तुमच्या खात्यातून XX{account} Rs.{amount} डेबिट झाले. तारीख {date}. Ref: {ref}",
371
+ Language.GUJARATI: "{bank}: તમારા ખાતા XX{account} માંથી Rs.{amount} ડેબિટ થયું. તારીખ {date}. Ref: {ref}",
372
+ }
373
+ ),
374
+ 'credit': MultilingualTemplate(
375
+ english="{bank}: Rs.{amount} credited to A/c XX{account} on {date}. {sender}. Ref: {ref}",
376
+ translations={
377
+ Language.HINDI: "{bank}: आपके खाते XX{account} में Rs.{amount} क्रेडिट हुआ। दिनांक {date}। {sender}। संदर्भ: {ref}",
378
+ Language.TAMIL: "{bank}: உங்கள் கணக்கு XX{account} க்கு Rs.{amount} கிரெடிட் செய்யப்பட்டது. தேதி {date}. Ref: {ref}",
379
+ Language.TELUGU: "{bank}: మీ ఖాతా XX{account} కు Rs.{amount} క్రెడిట్ చేయబడింది. తేదీ {date}. Ref: {ref}",
380
+ Language.BENGALI: "{bank}: আপনার অ্যাকাউন্ট XX{account} এ Rs.{amount} ক্রেডিট হয়েছে। তারিখ {date}। Ref: {ref}",
381
+ Language.KANNADA: "{bank}: ನಿಮ್ಮ ಖಾತೆ XX{account} ಗೆ Rs.{amount} ಕ್ರೆಡಿಟ್ ಆಗಿದೆ. ದಿನಾಂಕ {date}. Ref: {ref}",
382
+ Language.MARATHI: "{bank}: तुमच्या खात्यात XX{account} Rs.{amount} क्रेडिट झाले. तारीख {date}. Ref: {ref}",
383
+ Language.GUJARATI: "{bank}: તમારા ખાતા XX{account} માં Rs.{amount} ક્રેડિટ થયું. તારીખ {date}. Ref: {ref}",
384
+ }
385
+ ),
386
+ 'otp': MultilingualTemplate(
387
+ english="{bank}: Your OTP is {otp}. Valid for 10 mins. Do not share with anyone.",
388
+ translations={
389
+ Language.HINDI: "{bank}: आपका OTP {otp} है। 10 मिनट के लिए मान्य। किसी के साथ साझा न करें।",
390
+ Language.TAMIL: "{bank}: உங்கள் OTP {otp}. 10 நிமிடங்களுக்கு செல்லுபடியாகும். யாருடனும் பகிர வேண்டாம்.",
391
+ Language.TELUGU: "{bank}: మీ OTP {otp}. 10 నిమిషాలు చెల్లుబాటు. ఎవరితోనూ షేర్ చేయకండి.",
392
+ Language.BENGALI: "{bank}: আপনার OTP হল {otp}। 10 মিনিটের জন্য বৈধ। কারো সাথে শেয়ার করবেন না।",
393
+ }
394
+ ),
395
+ 'balance': MultilingualTemplate(
396
+ english="{bank}: Your A/c XX{account} balance is Rs.{balance}.",
397
+ translations={
398
+ Language.HINDI: "{bank}: आपके खाते XX{account} में शेष राशि Rs.{balance} है।",
399
+ Language.TAMIL: "{bank}: உங்கள் கணக்கு XX{account} இருப்பு Rs.{balance}.",
400
+ Language.TELUGU: "{bank}: మీ ఖాతా XX{account} బ్యాలెన్స్ Rs.{balance}.",
401
+ Language.BENGALI: "{bank}: আপনার অ্যাকাউন্ট XX{account} ব্যালেন্স Rs.{balance}।",
402
+ }
403
+ ),
404
+ }
405
+
406
+ # Numbers in Indian languages
407
+ NUMBERS = {
408
+ Language.HINDI: {
409
+ '0': '०', '1': '१', '2': '२', '3': '३', '4': '४',
410
+ '5': '५', '6': '६', '7': '७', '8': '८', '9': '९',
411
+ },
412
+ Language.BENGALI: {
413
+ '0': '০', '1': '১', '2': '২', '3': '৩', '4': '৪',
414
+ '5': '৫', '6': '৬', '7': '৭', '8': '৮', '9': '৯',
415
+ },
416
+ Language.TAMIL: {
417
+ '0': '௦', '1': '௧', '2': '௨', '3': '௩', '4': '௪',
418
+ '5': '௫', '6': '௬', '7': '௭', '8': '௮', '9': '௯',
419
+ },
420
+ Language.KANNADA: {
421
+ '0': '೦', '1': '೧', '2': '೨', '3': '೩', '4': '೪',
422
+ '5': '೫', '6': '೬', '7': '೭', '8': '೮', '9': '೯',
423
+ },
424
+ }
425
+
426
+ @classmethod
427
+ def generate(
428
+ cls,
429
+ template_type: str,
430
+ language: Language,
431
+ params: Dict[str, str],
432
+ use_native_numbers: bool = False,
433
+ rng: random.Random = None
434
+ ) -> str:
435
+ """Generate message in specified language."""
436
+ template = cls.TEMPLATES.get(template_type)
437
+ if not template:
438
+ return ""
439
+
440
+ text = template.get(language)
441
+ message = text.format(**params)
442
+
443
+ # Optionally convert numbers to native script
444
+ if use_native_numbers and language in cls.NUMBERS:
445
+ for eng, native in cls.NUMBERS[language].items():
446
+ message = message.replace(eng, native)
447
+
448
+ return message
449
+
450
+
451
+ class MultilingualNameGenerator:
452
+ """Generate names in multiple Indian languages."""
453
+
454
+ NAMES = {
455
+ Language.HINDI: [
456
+ "राहुल शर्मा", "प्रिया सिंह", "अमित कुमार", "नेहा गुप्ता",
457
+ "विजय पटेल", "दीपक वर्मा", "अंजलि मेहता", "राजेश नायर",
458
+ "सुनीता अय्यर", "अरुण जोशी", "पूजा रेड्डी", "संजय मिश्रा",
459
+ ],
460
+ Language.TAMIL: [
461
+ "முருகன் செல்வம்", "லக்ஷ்மி நாராயணன்", "கார்த்திக் சுப்பிரமணியம்",
462
+ "மீனா குமார்", "அருண் பிரகாஷ்", "சரிதா வேணுகோபால்",
463
+ ],
464
+ Language.TELUGU: [
465
+ "రవి కుమార్", "లక్ష్మీ దేవి", "సురేష్ రెడ్డి", "వెంకట రావు",
466
+ "ప్రసాద్ నాయుడు", "కమల శర్మ", "రాజేష్ గుప్తా",
467
+ ],
468
+ Language.BENGALI: [
469
+ "রাহুল ব্যানার্জী", "���্রিয়া দাস", "অমিত চক্রবর্তী",
470
+ "সুমিতা সেন", "রাজেশ মুখার্জী", "কবিতা বসু",
471
+ ],
472
+ Language.KANNADA: [
473
+ "ರಾಜೇಶ್ ಗೌಡ", "ಲಕ್ಷ್ಮೀ ನಾರಾಯಣ", "ಸುರೇಶ್ ಕುಮಾರ್",
474
+ "ಮೀನಾ ಹೆಗ್ಡೆ", "ಪ್ರಕಾಶ್ ರಾವ್", "ನೇತ್ರಾ ಶೆಟ್ಟಿ",
475
+ ],
476
+ }
477
+
478
+ @classmethod
479
+ def get_name(cls, language: Language, rng: random.Random) -> str:
480
+ """Get a random name in specified language."""
481
+ names = cls.NAMES.get(language, cls.NAMES[Language.HINDI])
482
+ return rng.choice(names)
483
+
484
+
485
+ # ============================================================================
486
+ # DATA AUGMENTATION
487
+ # ============================================================================
488
+
489
+ class DataAugmenter:
490
+ """
491
+ Advanced data augmentation techniques.
492
+
493
+ Techniques:
494
+ 1. Back-translation (via templates)
495
+ 2. Synonym replacement
496
+ 3. Random insertion/deletion
497
+ 4. Noise injection
498
+ 5. Entity swapping
499
+ """
500
+
501
+ SYNONYMS = {
502
+ 'debited': ['withdrawn', 'deducted', 'paid', 'transferred', 'sent'],
503
+ 'credited': ['received', 'deposited', 'added', 'transferred'],
504
+ 'transaction': ['payment', 'transfer', 'txn'],
505
+ 'account': ['A/c', 'Acc', 'Acct', 'a/c'],
506
+ 'reference': ['Ref', 'UTR', 'Txn ID'],
507
+ 'available': ['Avl', 'remaining', 'left'],
508
+ 'balance': ['Bal', 'amt'],
509
+ }
510
+
511
+ def __init__(self, seed: int = 42):
512
+ self.rng = random.Random(seed)
513
+
514
+ def augment(
515
+ self,
516
+ text: str,
517
+ ground_truth: Dict,
518
+ techniques: List[str] = None
519
+ ) -> List[Tuple[str, Dict]]:
520
+ """
521
+ Generate augmented versions of a sample.
522
+
523
+ Returns list of (augmented_text, ground_truth) tuples.
524
+ """
525
+ if techniques is None:
526
+ techniques = ['synonym', 'noise', 'case']
527
+
528
+ augmented = []
529
+
530
+ if 'synonym' in techniques:
531
+ aug = self._synonym_replace(text)
532
+ augmented.append((aug, ground_truth))
533
+
534
+ if 'noise' in techniques:
535
+ aug = self._add_noise(text)
536
+ augmented.append((aug, ground_truth))
537
+
538
+ if 'case' in techniques:
539
+ aug = self._vary_case(text)
540
+ augmented.append((aug, ground_truth))
541
+
542
+ if 'truncate' in techniques:
543
+ aug = self._truncate(text)
544
+ augmented.append((aug, ground_truth))
545
+
546
+ if 'reorder' in techniques:
547
+ aug = self._reorder_phrases(text)
548
+ augmented.append((aug, ground_truth))
549
+
550
+ return augmented
551
+
552
+ def _synonym_replace(self, text: str) -> str:
553
+ """Replace words with synonyms."""
554
+ words = text.split()
555
+ for i, word in enumerate(words):
556
+ word_lower = word.lower().strip('.,;:')
557
+ if word_lower in self.SYNONYMS and self.rng.random() < 0.3:
558
+ synonym = self.rng.choice(self.SYNONYMS[word_lower])
559
+ # Preserve case
560
+ if word[0].isupper():
561
+ synonym = synonym.capitalize()
562
+ words[i] = synonym
563
+ return ' '.join(words)
564
+
565
+ def _add_noise(self, text: str) -> str:
566
+ """Add realistic noise."""
567
+ # Random spacing
568
+ if self.rng.random() < 0.3:
569
+ text = text.replace('. ', '.')
570
+ if self.rng.random() < 0.3:
571
+ text = text.replace(': ', ':')
572
+
573
+ # Abbreviations
574
+ text = text.replace('Reference', 'Ref' if self.rng.random() < 0.5 else 'Reference')
575
+ text = text.replace('Account', 'A/c' if self.rng.random() < 0.5 else 'Account')
576
+
577
+ return text
578
+
579
+ def _vary_case(self, text: str) -> str:
580
+ """Vary text case."""
581
+ r = self.rng.random()
582
+ if r < 0.2:
583
+ return text.upper()
584
+ elif r < 0.4:
585
+ return text.lower()
586
+ return text
587
+
588
+ def _truncate(self, text: str) -> str:
589
+ """Truncate to SMS limit."""
590
+ if len(text) > 160:
591
+ return text[:157] + '...'
592
+ return text
593
+
594
+ def _reorder_phrases(self, text: str) -> str:
595
+ """Reorder independent phrases."""
596
+ # Split by common delimiters
597
+ phrases = re.split(r'[.;]', text)
598
+ phrases = [p.strip() for p in phrases if p.strip()]
599
+
600
+ if len(phrases) <= 2:
601
+ return text
602
+
603
+ # Keep first phrase, shuffle middle, keep last
604
+ first = phrases[0]
605
+ last = phrases[-1]
606
+ middle = phrases[1:-1]
607
+ self.rng.shuffle(middle)
608
+
609
+ return '. '.join([first] + middle + [last])
610
+
611
+ def augment_batch(
612
+ self,
613
+ data: List[Dict],
614
+ augmentation_factor: int = 3
615
+ ) -> List[Dict]:
616
+ """Augment entire dataset."""
617
+ augmented_data = []
618
+
619
+ for record in data:
620
+ text = record.get('text') or record.get('input', '')
621
+ gt = record.get('ground_truth', record.get('output', {}))
622
+
623
+ if isinstance(gt, str):
624
+ gt = json.loads(gt)
625
+
626
+ # Original
627
+ augmented_data.append(record)
628
+
629
+ # Augmented versions
630
+ for aug_text, aug_gt in self.augment(text, gt)[:augmentation_factor-1]:
631
+ augmented_data.append({
632
+ 'text': aug_text,
633
+ 'ground_truth': aug_gt,
634
+ 'augmented': True,
635
+ })
636
+
637
+ return augmented_data
638
+
639
+
640
+ # ============================================================================
641
+ # RARE EDGE CASE OVERSAMPLING
642
+ # ============================================================================
643
+
644
+ class RareEdgeCaseSampler:
645
+ """
646
+ Oversample rare edge cases to improve model robustness.
647
+
648
+ Uses importance sampling to increase representation of:
649
+ - Failed transactions
650
+ - Large amounts
651
+ - Unusual formats
652
+ - Rare banks
653
+ - Unicode text
654
+ """
655
+
656
+ def __init__(self, seed: int = 42):
657
+ self.rng = random.Random(seed)
658
+
659
+ # Define edge case conditions
660
+ self.edge_cases = {
661
+ 'failed_txn': lambda r: r.get('status') == 'failed',
662
+ 'pending_txn': lambda r: r.get('status') == 'pending',
663
+ 'large_amount': lambda r: (r.get('amount') or 0) > 100000,
664
+ 'small_amount': lambda r: (r.get('amount') or float('inf')) < 10,
665
+ 'unicode': lambda r: any(ord(c) > 127 for c in str(r.get('text', ''))),
666
+ 'credit': lambda r: r.get('type') == 'credit',
667
+ }
668
+
669
+ # Oversampling weights (higher = more samples)
670
+ self.oversample_weights = {
671
+ 'failed_txn': 5.0,
672
+ 'pending_txn': 3.0,
673
+ 'large_amount': 2.0,
674
+ 'small_amount': 2.0,
675
+ 'unicode': 4.0,
676
+ 'credit': 1.5,
677
+ }
678
+
679
+ def identify_edge_cases(self, record: Dict) -> List[str]:
680
+ """Identify which edge cases a record matches."""
681
+ return [
682
+ name for name, condition in self.edge_cases.items()
683
+ if condition(record)
684
+ ]
685
+
686
+ def calculate_sample_weight(self, record: Dict) -> float:
687
+ """Calculate importance weight for a record."""
688
+ weight = 1.0
689
+ for edge_case in self.identify_edge_cases(record):
690
+ weight *= self.oversample_weights.get(edge_case, 1.0)
691
+ return weight
692
+
693
+ def oversample(
694
+ self,
695
+ data: List[Dict],
696
+ target_size: Optional[int] = None
697
+ ) -> List[Dict]:
698
+ """
699
+ Oversample data with edge case weighting.
700
+
701
+ Returns dataset with increased representation of rare cases.
702
+ """
703
+ if target_size is None:
704
+ target_size = len(data)
705
+
706
+ # Calculate weights
707
+ weights = [self.calculate_sample_weight(r) for r in data]
708
+ total_weight = sum(weights)
709
+ probs = [w / total_weight for w in weights]
710
+
711
+ # Sample with replacement
712
+ indices = self.rng.choices(range(len(data)), weights=probs, k=target_size)
713
+
714
+ oversampled = []
715
+ for i in indices:
716
+ record = data[i].copy()
717
+ record['oversampled'] = True
718
+ oversampled.append(record)
719
+
720
+ return oversampled
721
+
722
+ def generate_targeted_edge_cases(
723
+ self,
724
+ generator,
725
+ edge_case_type: str,
726
+ count: int
727
+ ) -> List[Dict]:
728
+ """Generate specific edge case samples."""
729
+ samples = []
730
+
731
+ if edge_case_type == 'failed_txn':
732
+ from scripts.data_pipeline.generate_synthetic import TransactionStatus
733
+ for _ in range(count):
734
+ sample = generator.generate_transaction(
735
+ status=TransactionStatus.FAILED
736
+ )
737
+ samples.append(sample)
738
+
739
+ elif edge_case_type == 'large_amount':
740
+ for _ in range(count):
741
+ sample = generator.generate_transaction()
742
+ # Force large amount
743
+ sample['ground_truth']['amount'] = self.rng.uniform(100000, 1000000)
744
+ samples.append(sample)
745
+
746
+ elif edge_case_type == 'unicode':
747
+ for _ in range(count):
748
+ sample = generator.generate_transaction()
749
+ # Use Hindi name
750
+ sample['ground_truth']['beneficiary'] = self.rng.choice([
751
+ "राहुल शर्मा", "प्रिया सिंह", "అమిత్ కుమార్"
752
+ ])
753
+ samples.append(sample)
754
+
755
+ return samples
756
+
757
+
758
+ # ============================================================================
759
+ # DOCUMENT/PDF GENERATION (Placeholder - needs external libs)
760
+ # ============================================================================
761
+
762
+ class DocumentGenerator:
763
+ """
764
+ Generate synthetic bank statements and documents.
765
+
766
+ Note: Full implementation requires:
767
+ - reportlab for PDF generation
768
+ - PIL for image processing
769
+ - wkhtmltopdf for HTML to PDF
770
+ """
771
+
772
+ STATEMENT_TEMPLATE = """
773
+ ============================================
774
+ {bank} BANK
775
+ ACCOUNT STATEMENT
776
+ ============================================
777
+
778
+ Account Holder: {name}
779
+ Account Number: XXXXXXXX{account}
780
+ Statement Period: {start_date} to {end_date}
781
+
782
+ Opening Balance: Rs. {opening_balance}
783
+
784
+ --------------------------------------------
785
+ Date Description Debit Credit Balance
786
+ --------------------------------------------
787
+ {transactions}
788
+ --------------------------------------------
789
+
790
+ Closing Balance: Rs. {closing_balance}
791
+
792
+ This is a computer-generated statement.
793
+ """
794
+
795
+ @classmethod
796
+ def generate_text_statement(
797
+ cls,
798
+ transactions: List[Dict],
799
+ bank: str,
800
+ account: str,
801
+ name: str,
802
+ rng: random.Random
803
+ ) -> str:
804
+ """Generate a text-based bank statement."""
805
+ if not transactions:
806
+ return ""
807
+
808
+ # Sort by date
809
+ sorted_txns = sorted(
810
+ transactions,
811
+ key=lambda x: x.get('date', '2025-01-01')
812
+ )
813
+
814
+ # Calculate running balance
815
+ opening = rng.randint(10000, 100000)
816
+ balance = opening
817
+ lines = []
818
+
819
+ for txn in sorted_txns:
820
+ amount = txn.get('amount', 0)
821
+ txn_type = txn.get('type', 'debit')
822
+
823
+ if txn_type == 'debit':
824
+ balance -= amount
825
+ debit = f"{amount:,.2f}"
826
+ credit = ""
827
+ else:
828
+ balance += amount
829
+ debit = ""
830
+ credit = f"{amount:,.2f}"
831
+
832
+ desc = txn.get('merchant') or txn.get('beneficiary') or 'Transaction'
833
+ date_str = txn.get('date', '2025-01-01')
834
+
835
+ line = f"{date_str} {desc[:20]:<20} {debit:>10} {credit:>10} {balance:>12,.2f}"
836
+ lines.append(line)
837
+
838
+ start_date = sorted_txns[0].get('date', '2025-01-01')
839
+ end_date = sorted_txns[-1].get('date', '2025-01-31')
840
+
841
+ return cls.STATEMENT_TEMPLATE.format(
842
+ bank=bank,
843
+ name=name,
844
+ account=account[-4:],
845
+ start_date=start_date,
846
+ end_date=end_date,
847
+ opening_balance=f"{opening:,.2f}",
848
+ closing_balance=f"{balance:,.2f}",
849
+ transactions='\n '.join(lines)
850
+ )
851
+
852
+ @classmethod
853
+ def generate_statement_image_data(
854
+ cls,
855
+ transactions: List[Dict],
856
+ bank: str,
857
+ rng: random.Random
858
+ ) -> Dict:
859
+ """
860
+ Generate data for statement image (actual rendering needs PIL).
861
+
862
+ Returns structured data that can be used with image generation.
863
+ """
864
+ return {
865
+ 'type': 'bank_statement',
866
+ 'bank': bank,
867
+ 'transactions': transactions,
868
+ 'format': 'image_data',
869
+ 'note': 'Use PIL/reportlab to render actual image'
870
+ }
871
+
872
+
873
+ # ============================================================================
874
+ # UNIFIED ADVANCED GENERATOR
875
+ # ============================================================================
876
+
877
+ class AdvancedSyntheticGenerator:
878
+ """
879
+ Unified generator combining all advanced features.
880
+
881
+ Features:
882
+ 1. Markov chain learning from real data
883
+ 2. Statistical calibration
884
+ 3. Multilingual support
885
+ 4. Data augmentation
886
+ 5. Edge case oversampling
887
+ """
888
+
889
+ def __init__(self, seed: int = 42):
890
+ self.seed = seed
891
+ self.rng = random.Random(seed)
892
+
893
+ # Components
894
+ self.markov: Optional[MarkovChain] = None
895
+ self.calibrator: Optional[DataCalibrator] = None
896
+ self.augmenter = DataAugmenter(seed)
897
+ self.edge_sampler = RareEdgeCaseSampler(seed)
898
+
899
+ def train_on_real_data(self, real_data: List[Dict]):
900
+ """Train/calibrate on real data."""
901
+ print("Training on real data...")
902
+
903
+ # Train Markov chain
904
+ texts = [r.get('text') or r.get('input', '') for r in real_data]
905
+ self.markov = MarkovChain(order=2)
906
+ self.markov.train(texts)
907
+ print(f" Markov chain trained on {len(texts)} samples")
908
+
909
+ # Calibrate distributions
910
+ self.calibrator = DataCalibrator()
911
+ parsed_data = []
912
+ for r in real_data:
913
+ gt = r.get('ground_truth') or r.get('output', {})
914
+ if isinstance(gt, str):
915
+ gt = json.loads(gt)
916
+ parsed_data.append(gt)
917
+
918
+ self.calibrator.fit_from_data(parsed_data)
919
+ print(" Distributions calibrated")
920
+
921
+ def generate(
922
+ self,
923
+ count: int,
924
+ languages: List[Language] = None,
925
+ include_documents: bool = False,
926
+ augmentation_factor: int = 1,
927
+ edge_case_ratio: float = 0.1,
928
+ ) -> List[Dict]:
929
+ """
930
+ Generate synthetic data with all advanced features.
931
+
932
+ Args:
933
+ count: Number of records
934
+ languages: Languages to include (None = English only)
935
+ include_documents: Include bank statement format
936
+ augmentation_factor: How many augmented versions per sample
937
+ edge_case_ratio: Proportion of edge cases to include
938
+ """
939
+ if languages is None:
940
+ languages = [Language.ENGLISH]
941
+
942
+ records = []
943
+ base_count = int(count / augmentation_factor)
944
+ edge_count = int(base_count * edge_case_ratio)
945
+ normal_count = base_count - edge_count
946
+
947
+ print(f"Generating {count:,} records...")
948
+ print(f" Base: {base_count:,}, Edges: {edge_count:,}, Augmented: {count - base_count:,}")
949
+
950
+ # Generate normal transactions
951
+ for i in range(normal_count):
952
+ lang = self.rng.choice(languages)
953
+
954
+ # Sample from calibrated distributions if available
955
+ if self.calibrator:
956
+ category = self.calibrator.sample_category(self.rng)
957
+ bank = self.calibrator.sample_bank(self.rng)
958
+ amount = self.calibrator.sample_amount(category, self.rng)
959
+ else:
960
+ category = self.rng.choice(['shopping', 'food', 'transfer', 'bills'])
961
+ bank = self.rng.choice(['HDFC', 'ICICI', 'SBI', 'Axis'])
962
+ amount = self.rng.uniform(100, 10000)
963
+
964
+ # Generate message
965
+ is_debit = self.rng.random() < 0.7
966
+ template_type = 'debit' if is_debit else 'credit'
967
+
968
+ params = {
969
+ 'bank': bank,
970
+ 'amount': f"{amount:,.2f}",
971
+ 'account': str(self.rng.randint(1000, 9999)),
972
+ 'date': (date.today() - timedelta(days=self.rng.randint(0, 365))).strftime('%d-%m-%Y'),
973
+ 'vpa': f"{self.rng.choice(['swiggy', 'amazon', 'paytm'])}@ybl",
974
+ 'sender': 'PhonePe',
975
+ 'ref': ''.join(self.rng.choices('0123456789', k=12)),
976
+ }
977
+
978
+ text = MultilingualBank.generate(template_type, lang, params)
979
+
980
+ records.append({
981
+ 'text': text,
982
+ 'ground_truth': {
983
+ 'amount': round(amount, 2),
984
+ 'type': 'debit' if is_debit else 'credit',
985
+ 'bank': bank,
986
+ 'category': category,
987
+ 'language': lang.value,
988
+ },
989
+ 'language': lang.value,
990
+ })
991
+
992
+ if (i + 1) % 5000 == 0:
993
+ print(f" Generated {i+1:,}/{base_count:,}")
994
+
995
+ # Generate edge cases
996
+ for i in range(edge_count):
997
+ lang = self.rng.choice(languages)
998
+ edge_type = self.rng.choice(['unicode', 'large_amount', 'small_amount'])
999
+
1000
+ if edge_type == 'unicode' and lang == Language.ENGLISH:
1001
+ lang = Language.HINDI
1002
+
1003
+ amount = (
1004
+ self.rng.uniform(100000, 1000000) if edge_type == 'large_amount'
1005
+ else self.rng.uniform(0.5, 10) if edge_type == 'small_amount'
1006
+ else self.rng.uniform(100, 10000)
1007
+ )
1008
+
1009
+ params = {
1010
+ 'bank': self.rng.choice(['HDFC', 'ICICI', 'SBI']),
1011
+ 'amount': f"{amount:,.2f}",
1012
+ 'account': str(self.rng.randint(1000, 9999)),
1013
+ 'date': date.today().strftime('%d-%m-%Y'),
1014
+ 'vpa': 'merchant@ybl',
1015
+ 'sender': MultilingualNameGenerator.get_name(lang, self.rng) if edge_type == 'unicode' else 'User',
1016
+ 'ref': ''.join(self.rng.choices('0123456789', k=12)),
1017
+ }
1018
+
1019
+ text = MultilingualBank.generate('debit', lang, params, use_native_numbers=(edge_type == 'unicode'))
1020
+
1021
+ records.append({
1022
+ 'text': text,
1023
+ 'ground_truth': {
1024
+ 'amount': round(amount, 2),
1025
+ 'type': 'debit',
1026
+ 'language': lang.value,
1027
+ },
1028
+ 'edge_case': edge_type,
1029
+ 'language': lang.value,
1030
+ })
1031
+
1032
+ # Augment if factor > 1
1033
+ if augmentation_factor > 1:
1034
+ print(f" Augmenting {len(records):,} records...")
1035
+ records = self.augmenter.augment_batch(records, augmentation_factor)
1036
+
1037
+ # Add statements if requested
1038
+ if include_documents:
1039
+ print(" Generating document samples...")
1040
+ for _ in range(min(100, count // 100)):
1041
+ bank = self.rng.choice(['HDFC', 'ICICI', 'SBI'])
1042
+ account = str(self.rng.randint(10000000, 99999999))
1043
+ name = self.rng.choice(['Rahul Sharma', 'Priya Singh', 'Amit Kumar'])
1044
+
1045
+ # Use recent records for statement
1046
+ txns = [r['ground_truth'] for r in self.rng.sample(records, min(10, len(records)))]
1047
+ statement = DocumentGenerator.generate_text_statement(
1048
+ txns, bank, account, name, self.rng
1049
+ )
1050
+
1051
+ records.append({
1052
+ 'text': statement,
1053
+ 'ground_truth': {'document_type': 'bank_statement', 'bank': bank},
1054
+ 'document': True,
1055
+ })
1056
+
1057
+ self.rng.shuffle(records)
1058
+
1059
+ # Add IDs
1060
+ for i, r in enumerate(records):
1061
+ r['id'] = i + 1
1062
+
1063
+ print(f"✅ Generated {len(records):,} total records")
1064
+ return records
1065
+
1066
+ def save_training_data(self, records: List[Dict], output_path: Path):
1067
+ """Save in training format."""
1068
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1069
+
1070
+ with open(output_path, 'w', encoding='utf-8') as f:
1071
+ for r in records:
1072
+ line = {
1073
+ 'input': r['text'],
1074
+ 'output': json.dumps(r['ground_truth'], ensure_ascii=False),
1075
+ 'id': r.get('id'),
1076
+ 'language': r.get('language', 'en'),
1077
+ }
1078
+ if r.get('edge_case'):
1079
+ line['edge_case'] = r['edge_case']
1080
+ f.write(json.dumps(line, ensure_ascii=False) + '\n')
1081
+
1082
+ print(f"✅ Saved to: {output_path}")
1083
+
1084
+
1085
+ # ============================================================================
1086
+ # CLI
1087
+ # ============================================================================
1088
+
1089
+ def main():
1090
+ parser = argparse.ArgumentParser(description="Advanced Synthetic Data Generator v4.0")
1091
+ parser.add_argument("-n", "--count", type=int, default=10000, help="Number of records")
1092
+ parser.add_argument("-o", "--output", default="data/synthetic/advanced_synthetic.jsonl")
1093
+ parser.add_argument("--seed", type=int, default=42, help="Random seed")
1094
+ parser.add_argument("--languages", nargs='+', default=['en'],
1095
+ help="Languages: en, hi, ta, te, bn, kn, mr, gu")
1096
+ parser.add_argument("--augment", type=int, default=1, help="Augmentation factor")
1097
+ parser.add_argument("--edge-ratio", type=float, default=0.1, help="Edge case ratio")
1098
+ parser.add_argument("--real-data", help="Path to real data for calibration")
1099
+ parser.add_argument("--documents", action="store_true", help="Include document samples")
1100
+
1101
+ args = parser.parse_args()
1102
+
1103
+ # Parse languages
1104
+ lang_map = {
1105
+ 'en': Language.ENGLISH, 'hi': Language.HINDI, 'ta': Language.TAMIL,
1106
+ 'te': Language.TELUGU, 'bn': Language.BENGALI, 'kn': Language.KANNADA,
1107
+ 'mr': Language.MARATHI, 'gu': Language.GUJARATI,
1108
+ }
1109
+ languages = [lang_map.get(l, Language.ENGLISH) for l in args.languages]
1110
+
1111
+ # Initialize generator
1112
+ generator = AdvancedSyntheticGenerator(seed=args.seed)
1113
+
1114
+ # Train on real data if provided
1115
+ if args.real_data:
1116
+ real_path = Path(args.real_data)
1117
+ if real_path.exists():
1118
+ with open(real_path) as f:
1119
+ real_data = [json.loads(line) for line in f]
1120
+ generator.train_on_real_data(real_data)
1121
+
1122
+ # Generate
1123
+ records = generator.generate(
1124
+ count=args.count,
1125
+ languages=languages,
1126
+ include_documents=args.documents,
1127
+ augmentation_factor=args.augment,
1128
+ edge_case_ratio=args.edge_ratio,
1129
+ )
1130
+
1131
+ # Save
1132
+ output_path = Path(args.output)
1133
+ generator.save_training_data(records, output_path)
1134
+
1135
+ # Summary
1136
+ print("\n📊 Summary:")
1137
+ lang_counts = Counter(r.get('language', 'en') for r in records)
1138
+ for lang, count in lang_counts.most_common():
1139
+ print(f" {lang}: {count:,}")
1140
+
1141
+ edge_counts = Counter(r.get('edge_case') for r in records if r.get('edge_case'))
1142
+ if edge_counts:
1143
+ print("\n📋 Edge Cases:")
1144
+ for edge, count in edge_counts.most_common():
1145
+ print(f" {edge}: {count:,}")
1146
+
1147
+
1148
+ if __name__ == "__main__":
1149
+ main()
scripts/data_pipeline/generate_synthetic.py ADDED
@@ -0,0 +1,1726 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Production-Grade Synthetic Data Generator for Indian Banking Transactions
4
+ ==========================================================================
5
+
6
+ Engineering Principles:
7
+ 1. Grammar-based message generation (not hardcoded templates)
8
+ 2. Combinatorial coverage with configurable sampling
9
+ 3. Type-safe generation with validation
10
+ 4. Systematic edge case enumeration
11
+ 5. Statistical distribution control
12
+ 6. Reproducible with proper seeding
13
+ 7. Property-based validation
14
+
15
+ Author: Ranjit Behera
16
+ Version: 3.0 (Engineering Grade)
17
+ """
18
+
19
+ from __future__ import annotations
20
+ import json
21
+ import random
22
+ import hashlib
23
+ import argparse
24
+ import math
25
+ import itertools
26
+ from abc import ABC, abstractmethod
27
+ from dataclasses import dataclass, field, asdict
28
+ from datetime import datetime, timedelta, date
29
+ from decimal import Decimal, ROUND_HALF_UP
30
+ from enum import Enum, auto
31
+ from pathlib import Path
32
+ from typing import (
33
+ List, Dict, Optional, Tuple, Set, Generator,
34
+ Callable, TypeVar, Generic, Union, Any, Iterator
35
+ )
36
+ from collections import defaultdict
37
+ import re
38
+
39
+
40
+ # ============================================================================
41
+ # TYPE SYSTEM - Enums and Value Objects
42
+ # ============================================================================
43
+
44
+ class TransactionType(Enum):
45
+ DEBIT = "debit"
46
+ CREDIT = "credit"
47
+
48
+ class TransactionStatus(Enum):
49
+ SUCCESS = "success"
50
+ FAILED = "failed"
51
+ PENDING = "pending"
52
+ REVERSED = "reversed"
53
+
54
+ class PaymentMethod(Enum):
55
+ UPI = "upi"
56
+ NEFT = "neft"
57
+ RTGS = "rtgs"
58
+ IMPS = "imps"
59
+ CREDIT_CARD = "credit_card"
60
+ DEBIT_CARD = "debit_card"
61
+ ATM = "atm"
62
+ WALLET = "wallet"
63
+ AUTO_DEBIT = "auto_debit"
64
+ CHEQUE = "cheque"
65
+ CASH = "cash"
66
+
67
+ class Category(Enum):
68
+ FOOD = "food"
69
+ GROCERY = "grocery"
70
+ SHOPPING = "shopping"
71
+ TRANSPORT = "transport"
72
+ TRAVEL = "travel"
73
+ FUEL = "fuel"
74
+ BILLS = "bills"
75
+ ENTERTAINMENT = "entertainment"
76
+ HEALTHCARE = "healthcare"
77
+ INVESTMENT = "investment"
78
+ INSURANCE = "insurance"
79
+ EDUCATION = "education"
80
+ TRANSFER = "transfer"
81
+ SALARY = "salary"
82
+ REFUND = "refund"
83
+ CASHBACK = "cashback"
84
+ EMI = "emi"
85
+ ATM_WITHDRAWAL = "atm_withdrawal"
86
+ OTHER = "other"
87
+
88
+ class MessageType(Enum):
89
+ TRANSACTION = "transaction"
90
+ OTP = "otp"
91
+ PROMOTIONAL = "promotional"
92
+ ALERT = "alert"
93
+ STATEMENT = "statement"
94
+
95
+ class AmountFormat(Enum):
96
+ """All possible amount format variations."""
97
+ INTEGER = "integer" # 2500
98
+ DECIMAL_2 = "decimal_2" # 2500.00
99
+ DECIMAL_1 = "decimal_1" # 2500.5
100
+ COMMA_INTERNATIONAL = "comma_intl" # 2,500.00
101
+ COMMA_INDIAN = "comma_indian" # 2,50,000.00
102
+ NO_DECIMAL_COMMA = "no_decimal_comma" # 2,500
103
+ COMPACT = "compact" # 2.5K, 1.2L
104
+ PADDED = "padded" # 002500.00
105
+
106
+ class DateFormat(Enum):
107
+ """All possible date format variations."""
108
+ DD_MM_YYYY_DASH = "dd-mm-yyyy" # 28-12-2025
109
+ DD_MM_YY_DASH = "dd-mm-yy" # 28-12-25
110
+ DD_MM_YYYY_SLASH = "dd/mm/yyyy" # 28/12/2025
111
+ DD_MM_YY_SLASH = "dd/mm/yy" # 28/12/25
112
+ DD_MON_YY = "dd-mon-yy" # 28-Dec-25
113
+ DD_MON_YYYY = "dd-mon-yyyy" # 28-Dec-2025
114
+ DD_MONTH_YYYY = "dd-month-yyyy" # 28 December 2025
115
+ MON_DD_YYYY = "mon-dd-yyyy" # Dec 28, 2025
116
+ YYYY_MM_DD = "yyyy-mm-dd" # 2025-12-28 (ISO)
117
+ COMPACT = "compact" # 28Dec25
118
+ RELATIVE = "relative" # today, yesterday
119
+
120
+ class CurrencySymbol(Enum):
121
+ """Currency symbol variations."""
122
+ RS_DOT = "Rs."
123
+ RS = "Rs"
124
+ INR = "INR"
125
+ RUPEE = "₹"
126
+ RS_SPACE = "Rs "
127
+ INR_SPACE = "INR "
128
+
129
+
130
+ # ============================================================================
131
+ # VALUE OBJECTS - Immutable domain objects
132
+ # ============================================================================
133
+
134
+ @dataclass(frozen=True)
135
+ class Amount:
136
+ """Immutable amount with validation."""
137
+ value: Decimal
138
+
139
+ def __post_init__(self):
140
+ if self.value < 0:
141
+ raise ValueError(f"Amount cannot be negative: {self.value}")
142
+
143
+ @classmethod
144
+ def from_float(cls, value: float) -> 'Amount':
145
+ return cls(Decimal(str(value)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
146
+
147
+ @classmethod
148
+ def from_int(cls, value: int) -> 'Amount':
149
+ return cls(Decimal(value))
150
+
151
+ def format(self, fmt: AmountFormat) -> str:
152
+ """Format amount according to specified format."""
153
+ v = float(self.value)
154
+
155
+ if fmt == AmountFormat.INTEGER:
156
+ return str(int(v))
157
+ elif fmt == AmountFormat.DECIMAL_2:
158
+ return f"{v:.2f}"
159
+ elif fmt == AmountFormat.DECIMAL_1:
160
+ return f"{v:.1f}" if v != int(v) else str(int(v))
161
+ elif fmt == AmountFormat.COMMA_INTERNATIONAL:
162
+ return f"{v:,.2f}"
163
+ elif fmt == AmountFormat.COMMA_INDIAN:
164
+ return self._indian_format(v)
165
+ elif fmt == AmountFormat.NO_DECIMAL_COMMA:
166
+ return f"{int(v):,}"
167
+ elif fmt == AmountFormat.COMPACT:
168
+ return self._compact_format(v)
169
+ elif fmt == AmountFormat.PADDED:
170
+ return f"{v:012.2f}"
171
+ return str(v)
172
+
173
+ def _indian_format(self, v: float) -> str:
174
+ """Format in Indian numbering system (lakhs, crores)."""
175
+ s = f"{v:.2f}"
176
+ integer_part, decimal_part = s.split('.')
177
+
178
+ if len(integer_part) <= 3:
179
+ return s
180
+
181
+ # Last 3 digits
182
+ result = integer_part[-3:]
183
+ integer_part = integer_part[:-3]
184
+
185
+ # Rest in groups of 2
186
+ while integer_part:
187
+ result = integer_part[-2:] + ',' + result
188
+ integer_part = integer_part[:-2]
189
+
190
+ return result + '.' + decimal_part
191
+
192
+ def _compact_format(self, v: float) -> str:
193
+ """Format as compact (2.5K, 1.2L, 3.5Cr)."""
194
+ if v >= 10000000: # Crore
195
+ return f"{v/10000000:.1f}Cr"
196
+ elif v >= 100000: # Lakh
197
+ return f"{v/100000:.1f}L"
198
+ elif v >= 1000: # Thousand
199
+ return f"{v/1000:.1f}K"
200
+ return str(int(v))
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class Account:
205
+ """Bank account representation."""
206
+ last_four: str
207
+
208
+ def __post_init__(self):
209
+ if not self.last_four.isdigit() or len(self.last_four) != 4:
210
+ raise ValueError(f"Invalid account last four: {self.last_four}")
211
+
212
+ def format(self, style: str = "XX") -> str:
213
+ """Format account number."""
214
+ styles = {
215
+ "XX": f"XX{self.last_four}",
216
+ "xx": f"xx{self.last_four}",
217
+ "****": f"****{self.last_four}",
218
+ "...": f"...{self.last_four}",
219
+ "plain": self.last_four,
220
+ "X": f"X{self.last_four}",
221
+ }
222
+ return styles.get(style, f"XX{self.last_four}")
223
+
224
+
225
+ @dataclass(frozen=True)
226
+ class Reference:
227
+ """Transaction reference number."""
228
+ value: str
229
+ ref_type: str # numeric, alphanumeric, utr
230
+
231
+ @classmethod
232
+ def generate(cls, ref_type: str, bank_code: str = "HDFC") -> 'Reference':
233
+ if ref_type == "numeric":
234
+ value = ''.join(random.choices('0123456789', k=12))
235
+ elif ref_type == "alphanumeric":
236
+ value = bank_code + ''.join(random.choices('0123456789', k=11))
237
+ elif ref_type == "utr":
238
+ date_part = datetime.now().strftime('%y%m%d')
239
+ value = f"{bank_code}{date_part}N{''.join(random.choices('0123456789', k=6))}"
240
+ else:
241
+ value = ''.join(random.choices('0123456789', k=12))
242
+
243
+ return cls(value=value, ref_type=ref_type)
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class TransactionDate:
248
+ """Date with multiple format support."""
249
+ date: date
250
+ time: Optional[Tuple[int, int, int]] = None # (hour, minute, second)
251
+
252
+ def format_date(self, fmt: DateFormat) -> str:
253
+ """Format date according to specified format."""
254
+ d = self.date
255
+
256
+ formats = {
257
+ DateFormat.DD_MM_YYYY_DASH: d.strftime("%d-%m-%Y"),
258
+ DateFormat.DD_MM_YY_DASH: d.strftime("%d-%m-%y"),
259
+ DateFormat.DD_MM_YYYY_SLASH: d.strftime("%d/%m/%Y"),
260
+ DateFormat.DD_MM_YY_SLASH: d.strftime("%d/%m/%y"),
261
+ DateFormat.DD_MON_YY: d.strftime("%d-%b-%y"),
262
+ DateFormat.DD_MON_YYYY: d.strftime("%d-%b-%Y"),
263
+ DateFormat.DD_MONTH_YYYY: d.strftime("%d %B %Y"),
264
+ DateFormat.MON_DD_YYYY: d.strftime("%b %d, %Y"),
265
+ DateFormat.YYYY_MM_DD: d.strftime("%Y-%m-%d"),
266
+ DateFormat.COMPACT: d.strftime("%d%b%y"),
267
+ }
268
+
269
+ if fmt == DateFormat.RELATIVE:
270
+ today = date.today()
271
+ diff = (today - d).days
272
+ if diff == 0:
273
+ return "today"
274
+ elif diff == 1:
275
+ return "yesterday"
276
+ else:
277
+ return formats[DateFormat.DD_MM_YY_DASH]
278
+
279
+ return formats.get(fmt, d.strftime("%d-%m-%Y"))
280
+
281
+ def format_time(self) -> Optional[str]:
282
+ """Format time if present."""
283
+ if not self.time:
284
+ return None
285
+ h, m, s = self.time
286
+ formats = [
287
+ f"{h:02d}:{m:02d}:{s:02d}",
288
+ f"{h:02d}:{m:02d}",
289
+ f"{h:02d}:{m:02d} {'AM' if h < 12 else 'PM'}",
290
+ ]
291
+ return random.choice(formats)
292
+
293
+ def normalized(self) -> str:
294
+ """Return ISO format."""
295
+ return self.date.strftime("%Y-%m-%d")
296
+
297
+
298
+ # ============================================================================
299
+ # GRAMMAR-BASED MESSAGE GENERATOR
300
+ # ============================================================================
301
+
302
+ class GrammarRule:
303
+ """Represents a grammar rule with weighted alternatives."""
304
+
305
+ def __init__(self, name: str, alternatives: List[Tuple[str, float]]):
306
+ """
307
+ Args:
308
+ name: Rule name
309
+ alternatives: List of (pattern, weight) tuples
310
+ """
311
+ self.name = name
312
+ self.alternatives = alternatives
313
+ self._normalize_weights()
314
+
315
+ def _normalize_weights(self):
316
+ """Normalize weights to sum to 1."""
317
+ total = sum(w for _, w in self.alternatives)
318
+ self.alternatives = [(p, w/total) for p, w in self.alternatives]
319
+
320
+ def sample(self, rng: random.Random) -> str:
321
+ """Sample one alternative based on weights."""
322
+ r = rng.random()
323
+ cumulative = 0
324
+ for pattern, weight in self.alternatives:
325
+ cumulative += weight
326
+ if r <= cumulative:
327
+ return pattern
328
+ return self.alternatives[-1][0]
329
+
330
+
331
+ class MessageGrammar:
332
+ """
333
+ Grammar-based message generator.
334
+
335
+ Grammar Structure:
336
+ MESSAGE ::= PREFIX BODY SUFFIX
337
+ PREFIX ::= BANK_INTRO
338
+ BODY ::= AMOUNT_PHRASE ACCOUNT_PHRASE DATE_PHRASE PARTY_PHRASE REF_PHRASE
339
+ SUFFIX ::= BALANCE_PHRASE? WARNING_PHRASE?
340
+ """
341
+
342
+ def __init__(self, seed: int = 42):
343
+ self.rng = random.Random(seed)
344
+ self._build_grammar()
345
+
346
+ def _build_grammar(self):
347
+ """Build the grammar rules."""
348
+
349
+ # Bank introduction patterns
350
+ self.bank_intro = GrammarRule("BANK_INTRO", [
351
+ ("{bank}:", 0.3),
352
+ ("{bank} Bank:", 0.25),
353
+ ("{bank} Bk:", 0.1),
354
+ ("Dear Customer, {bank}:", 0.1),
355
+ ("Alert: {bank}", 0.1),
356
+ ("{bank} Bank Alert:", 0.1),
357
+ ("{bank} Bank Acct", 0.05),
358
+ ])
359
+
360
+ # Amount phrase patterns for DEBIT
361
+ self.amount_debit = GrammarRule("AMOUNT_DEBIT", [
362
+ ("{currency}{amount} debited from", 0.25),
363
+ ("{currency}{amount} sent to", 0.15),
364
+ ("{currency}{amount} paid to", 0.1),
365
+ ("{currency}{amount} transferred to", 0.1),
366
+ ("{currency} {amount} debited from", 0.1),
367
+ ("INR {amount} debited from", 0.1),
368
+ ("{currency}{amount} spent at", 0.1),
369
+ ("Amount {currency}{amount} debited", 0.1),
370
+ ])
371
+
372
+ # Amount phrase patterns for CREDIT
373
+ self.amount_credit = GrammarRule("AMOUNT_CREDIT", [
374
+ ("{currency}{amount} credited to", 0.3),
375
+ ("{currency}{amount} received in", 0.2),
376
+ ("{currency}{amount} deposited to", 0.1),
377
+ ("{currency} {amount} credited to", 0.15),
378
+ ("INR {amount} credited to", 0.1),
379
+ ("{currency}{amount} added to", 0.1),
380
+ ("Received {currency}{amount} in", 0.05),
381
+ ])
382
+
383
+ # Account phrase patterns
384
+ self.account_phrase = GrammarRule("ACCOUNT_PHRASE", [
385
+ ("A/c {account}", 0.3),
386
+ ("a/c {account}", 0.15),
387
+ ("Account {account}", 0.15),
388
+ ("Acct {account}", 0.15),
389
+ ("A/c No. {account}", 0.1),
390
+ ("Acc {account}", 0.1),
391
+ ("your A/c {account}", 0.05),
392
+ ])
393
+
394
+ # Date phrase patterns
395
+ self.date_phrase = GrammarRule("DATE_PHRASE", [
396
+ ("on {date}", 0.4),
397
+ ("on {date} {time}", 0.2),
398
+ ("{date}", 0.15),
399
+ ("dated {date}", 0.1),
400
+ ("on {date} at {time}", 0.1),
401
+ ("{date} {time}", 0.05),
402
+ ])
403
+
404
+ # Counterparty patterns (UPI)
405
+ self.party_upi = GrammarRule("PARTY_UPI", [
406
+ ("VPA {vpa}", 0.2),
407
+ ("to {vpa}", 0.2),
408
+ ("VPA: {vpa}", 0.15),
409
+ ("to VPA {vpa}", 0.15),
410
+ ("Info: UPI/{vpa}", 0.1),
411
+ ("{beneficiary} ({vpa})", 0.1),
412
+ ("UPI-{vpa}", 0.1),
413
+ ])
414
+
415
+ # Reference patterns
416
+ self.reference = GrammarRule("REFERENCE", [
417
+ ("Ref: {ref}", 0.25),
418
+ ("Ref {ref}", 0.2),
419
+ ("UPI Ref: {ref}", 0.2),
420
+ ("Reference: {ref}", 0.1),
421
+ ("UTR: {ref}", 0.1),
422
+ ("Txn Ref: {ref}", 0.1),
423
+ ("Ref No. {ref}", 0.05),
424
+ ])
425
+
426
+ # Balance patterns
427
+ self.balance = GrammarRule("BALANCE", [
428
+ ("Avl Bal: {currency}{balance}", 0.25),
429
+ ("Bal: {currency}{balance}", 0.2),
430
+ ("Available Balance: {currency}{balance}", 0.15),
431
+ ("Avl Bal {currency}{balance}", 0.15),
432
+ ("Balance: {currency}{balance}", 0.1),
433
+ ("A/c Bal: {currency}{balance}", 0.1),
434
+ ("", 0.05), # No balance shown
435
+ ])
436
+
437
+ # Warning patterns
438
+ self.warning = GrammarRule("WARNING", [
439
+ ("Not you? Call {helpline}", 0.3),
440
+ ("If not done by you, call {helpline}", 0.2),
441
+ ("Call {helpline} for dispute", 0.2),
442
+ ("", 0.3), # No warning
443
+ ])
444
+
445
+ # Failed transaction patterns
446
+ self.failed = GrammarRule("FAILED", [
447
+ ("Transaction of {currency}{amount} FAILED", 0.3),
448
+ ("{currency}{amount} transaction DECLINED", 0.25),
449
+ ("Payment of {currency}{amount} FAILED", 0.2),
450
+ ("Transaction FAILED: {currency}{amount}", 0.15),
451
+ ("{currency}{amount} to {vpa} DECLINED", 0.1),
452
+ ])
453
+
454
+ # Pending patterns
455
+ self.pending = GrammarRule("PENDING", [
456
+ ("{currency}{amount} debit PENDING", 0.35),
457
+ ("Transaction of {currency}{amount} is PENDING", 0.3),
458
+ ("{currency}{amount} payment is being processed", 0.2),
459
+ ("Processing: {currency}{amount} to {vpa}", 0.15),
460
+ ])
461
+
462
+ def generate_debit_message(self, params: Dict[str, str]) -> str:
463
+ """Generate a debit transaction message."""
464
+ parts = [
465
+ self.bank_intro.sample(self.rng),
466
+ self.amount_debit.sample(self.rng),
467
+ self.account_phrase.sample(self.rng),
468
+ self.date_phrase.sample(self.rng),
469
+ self.party_upi.sample(self.rng) if params.get('vpa') else "",
470
+ self.reference.sample(self.rng),
471
+ self.balance.sample(self.rng),
472
+ self.warning.sample(self.rng),
473
+ ]
474
+
475
+ message = " ".join(p for p in parts if p)
476
+ return self._apply_params(message, params)
477
+
478
+ def generate_credit_message(self, params: Dict[str, str]) -> str:
479
+ """Generate a credit transaction message."""
480
+ parts = [
481
+ self.bank_intro.sample(self.rng),
482
+ self.amount_credit.sample(self.rng),
483
+ self.account_phrase.sample(self.rng),
484
+ self.date_phrase.sample(self.rng),
485
+ f"from {params.get('vpa', params.get('beneficiary', ''))}",
486
+ self.reference.sample(self.rng),
487
+ self.balance.sample(self.rng),
488
+ ]
489
+
490
+ message = " ".join(p for p in parts if p)
491
+ return self._apply_params(message, params)
492
+
493
+ def generate_failed_message(self, params: Dict[str, str]) -> str:
494
+ """Generate a failed transaction message."""
495
+ parts = [
496
+ self.bank_intro.sample(self.rng),
497
+ self.failed.sample(self.rng),
498
+ self.account_phrase.sample(self.rng),
499
+ f"Reason: {params.get('reason', 'Transaction declined')}",
500
+ self.reference.sample(self.rng),
501
+ ]
502
+
503
+ message = " ".join(p for p in parts if p)
504
+ return self._apply_params(message, params)
505
+
506
+ def generate_pending_message(self, params: Dict[str, str]) -> str:
507
+ """Generate a pending transaction message."""
508
+ parts = [
509
+ self.bank_intro.sample(self.rng),
510
+ self.pending.sample(self.rng),
511
+ f"for {params.get('vpa', '')}",
512
+ self.account_phrase.sample(self.rng),
513
+ self.reference.sample(self.rng),
514
+ ]
515
+
516
+ message = " ".join(p for p in parts if p)
517
+ return self._apply_params(message, params)
518
+
519
+ def _apply_params(self, template: str, params: Dict[str, str]) -> str:
520
+ """Apply parameters to template."""
521
+ for key, value in params.items():
522
+ template = template.replace(f"{{{key}}}", str(value) if value else "")
523
+
524
+ # Clean up double spaces and trailing punctuation
525
+ template = re.sub(r'\s+', ' ', template)
526
+ template = re.sub(r'\s+([.,:])', r'\1', template)
527
+ return template.strip()
528
+
529
+
530
+ # ============================================================================
531
+ # COMBINATORIAL GENERATOR
532
+ # ============================================================================
533
+
534
+ @dataclass
535
+ class GenerationSpace:
536
+ """
537
+ Defines the combinatorial space for generation.
538
+
539
+ Total combinations = product of all dimension sizes
540
+ """
541
+ banks: List[str]
542
+ amount_formats: List[AmountFormat]
543
+ date_formats: List[DateFormat]
544
+ currency_symbols: List[CurrencySymbol]
545
+ account_styles: List[str]
546
+ reference_types: List[str]
547
+ transaction_types: List[TransactionType]
548
+ statuses: List[TransactionStatus]
549
+ payment_methods: List[PaymentMethod]
550
+ categories: List[Category]
551
+ noise_levels: List[float]
552
+
553
+ @property
554
+ def total_combinations(self) -> int:
555
+ """Calculate total theoretical combinations."""
556
+ return (
557
+ len(self.banks) *
558
+ len(self.amount_formats) *
559
+ len(self.date_formats) *
560
+ len(self.currency_symbols) *
561
+ len(self.account_styles) *
562
+ len(self.reference_types) *
563
+ len(self.transaction_types) *
564
+ len(self.statuses) *
565
+ len(self.payment_methods) *
566
+ len(self.categories) *
567
+ len(self.noise_levels)
568
+ )
569
+
570
+ def get_dimension_coverage(self, samples: int) -> Dict[str, float]:
571
+ """Calculate coverage for each dimension given sample count."""
572
+ dimensions = {
573
+ 'banks': len(self.banks),
574
+ 'amount_formats': len(self.amount_formats),
575
+ 'date_formats': len(self.date_formats),
576
+ 'currency_symbols': len(self.currency_symbols),
577
+ 'account_styles': len(self.account_styles),
578
+ 'reference_types': len(self.reference_types),
579
+ 'transaction_types': len(self.transaction_types),
580
+ 'statuses': len(self.statuses),
581
+ 'payment_methods': len(self.payment_methods),
582
+ 'categories': len(self.categories),
583
+ 'noise_levels': len(self.noise_levels),
584
+ }
585
+
586
+ # Expected coverage using coupon collector approximation
587
+ coverage = {}
588
+ for dim, size in dimensions.items():
589
+ # E[samples needed to see all] ≈ n * H_n where H_n is harmonic number
590
+ harmonic = sum(1/i for i in range(1, size + 1))
591
+ expected_needed = size * harmonic
592
+ coverage[dim] = min(1.0, samples / expected_needed)
593
+
594
+ return coverage
595
+
596
+
597
+ class EdgeCaseGenerator:
598
+ """
599
+ Systematic edge case generation using boundary value analysis.
600
+
601
+ Categories:
602
+ 1. Amount boundaries
603
+ 2. Date boundaries
604
+ 3. String boundaries
605
+ 4. Format edge cases
606
+ 5. Invalid inputs (for negative testing)
607
+ """
608
+
609
+ # Amount edge cases
610
+ AMOUNT_BOUNDARIES = [
611
+ 0.01, # Minimum meaningful
612
+ 0.50, # Sub-rupee
613
+ 0.99, # Just under 1
614
+ 1.00, # Boundary
615
+ 9.99, # Just under 10
616
+ 10.00, # Boundary
617
+ 99.99, # Just under 100
618
+ 100.00, # Common boundary
619
+ 499.99, # Just under 500
620
+ 500.00, # Common payment
621
+ 999.99, # Just under 1000
622
+ 1000.00, # Thousand boundary
623
+ 9999.99, # Just under 10K
624
+ 10000.00, # 10K boundary
625
+ 99999.99, # Just under 1L
626
+ 100000.00, # Lakh boundary
627
+ 999999.99, # Just under 10L
628
+ 1000000.00, # 10L boundary
629
+ 9999999.99, # Just under 1Cr
630
+ 10000000.00, # Crore boundary
631
+ ]
632
+
633
+ # Round numbers (common in real transactions)
634
+ ROUND_AMOUNTS = [
635
+ 50, 100, 200, 250, 500, 750, 1000, 1500, 2000, 2500,
636
+ 3000, 4000, 5000, 7500, 10000, 15000, 20000, 25000,
637
+ 50000, 75000, 100000, 200000, 500000,
638
+ ]
639
+
640
+ # Psychological pricing
641
+ PSYCHOLOGICAL_AMOUNTS = [
642
+ 49, 99, 149, 199, 249, 299, 399, 499, 599, 699, 799, 899, 999,
643
+ 1299, 1499, 1999, 2499, 2999, 3999, 4999, 9999,
644
+ ]
645
+
646
+ # Date edge cases
647
+ DATE_EDGE_CASES = [
648
+ date(2024, 2, 29), # Leap year
649
+ date(2025, 2, 28), # Non-leap year Feb end
650
+ date(2025, 12, 31), # Year end
651
+ date(2026, 1, 1), # Year start
652
+ date(2025, 3, 31), # Month with 31 days
653
+ date(2025, 4, 30), # Month with 30 days
654
+ ]
655
+
656
+ # Special characters in merchant names
657
+ SPECIAL_CHAR_MERCHANTS = [
658
+ "McDonald's",
659
+ "H&M",
660
+ "AT&T",
661
+ "L'Oreal",
662
+ "7-Eleven",
663
+ "Marks & Spencer",
664
+ "Johnson & Johnson",
665
+ "Dunkin' Donuts",
666
+ "Toys \"R\" Us",
667
+ "Yahoo!",
668
+ ]
669
+
670
+ # Long merchant names
671
+ LONG_MERCHANTS = [
672
+ "Tata Consultancy Services Limited",
673
+ "Hindustan Petroleum Corporation Limited",
674
+ "Life Insurance Corporation of India",
675
+ "Indian Oil Corporation Limited",
676
+ "Bharat Heavy Electricals Limited",
677
+ "Steel Authority of India Limited",
678
+ ]
679
+
680
+ # Unicode names (Hindi/Regional)
681
+ UNICODE_NAMES = [
682
+ "राहुल शर्मा",
683
+ "प्रिया सिंह",
684
+ "अमित कुमार",
685
+ "सुनीता देवी",
686
+ "విజయ్ కుమార్",
687
+ "প্রিয়া দাস",
688
+ "அருண் குமார்",
689
+ ]
690
+
691
+ @classmethod
692
+ def get_amount_edge_cases(cls) -> List[float]:
693
+ """Get all amount edge cases."""
694
+ return cls.AMOUNT_BOUNDARIES + cls.ROUND_AMOUNTS + cls.PSYCHOLOGICAL_AMOUNTS
695
+
696
+ @classmethod
697
+ def get_date_edge_cases(cls) -> List[date]:
698
+ """Get all date edge cases."""
699
+ today = date.today()
700
+ return cls.DATE_EDGE_CASES + [
701
+ today,
702
+ today - timedelta(days=1),
703
+ today - timedelta(days=7),
704
+ today - timedelta(days=30),
705
+ today - timedelta(days=365),
706
+ ]
707
+
708
+ @classmethod
709
+ def generate_edge_case_batch(cls, count: int, rng: random.Random) -> List[Dict]:
710
+ """Generate a batch of edge case configurations."""
711
+ configs = []
712
+
713
+ # Amount edge cases
714
+ for amount in rng.sample(cls.AMOUNT_BOUNDARIES, min(count // 4, len(cls.AMOUNT_BOUNDARIES))):
715
+ configs.append({'edge_type': 'amount_boundary', 'amount': amount})
716
+
717
+ # Special character merchants
718
+ for merchant in cls.SPECIAL_CHAR_MERCHANTS:
719
+ configs.append({'edge_type': 'special_char', 'merchant': merchant})
720
+
721
+ # Long merchants
722
+ for merchant in cls.LONG_MERCHANTS:
723
+ configs.append({'edge_type': 'long_merchant', 'merchant': merchant})
724
+
725
+ # Unicode names
726
+ for name in cls.UNICODE_NAMES:
727
+ configs.append({'edge_type': 'unicode', 'beneficiary': name})
728
+
729
+ return configs[:count]
730
+
731
+
732
+ # ============================================================================
733
+ # STATISTICAL DISTRIBUTION CONTROLLER
734
+ # ============================================================================
735
+
736
+ class DistributionConfig:
737
+ """
738
+ Configurable statistical distributions for realistic data.
739
+
740
+ Uses weighted random selection based on real-world frequencies.
741
+ """
742
+
743
+ # Transaction type distribution
744
+ TRANSACTION_TYPE_WEIGHTS = {
745
+ TransactionType.DEBIT: 0.7,
746
+ TransactionType.CREDIT: 0.3,
747
+ }
748
+
749
+ # Status distribution
750
+ STATUS_WEIGHTS = {
751
+ TransactionStatus.SUCCESS: 0.92,
752
+ TransactionStatus.FAILED: 0.05,
753
+ TransactionStatus.PENDING: 0.02,
754
+ TransactionStatus.REVERSED: 0.01,
755
+ }
756
+
757
+ # Payment method distribution
758
+ PAYMENT_METHOD_WEIGHTS = {
759
+ PaymentMethod.UPI: 0.55,
760
+ PaymentMethod.CREDIT_CARD: 0.15,
761
+ PaymentMethod.DEBIT_CARD: 0.08,
762
+ PaymentMethod.NEFT: 0.08,
763
+ PaymentMethod.IMPS: 0.05,
764
+ PaymentMethod.ATM: 0.04,
765
+ PaymentMethod.AUTO_DEBIT: 0.03,
766
+ PaymentMethod.WALLET: 0.02,
767
+ }
768
+
769
+ # Category distribution (based on typical spending)
770
+ CATEGORY_WEIGHTS = {
771
+ Category.FOOD: 0.18,
772
+ Category.SHOPPING: 0.15,
773
+ Category.GROCERY: 0.12,
774
+ Category.TRANSFER: 0.12,
775
+ Category.BILLS: 0.10,
776
+ Category.TRANSPORT: 0.08,
777
+ Category.ENTERTAINMENT: 0.05,
778
+ Category.FUEL: 0.05,
779
+ Category.INVESTMENT: 0.04,
780
+ Category.HEALTHCARE: 0.03,
781
+ Category.TRAVEL: 0.03,
782
+ Category.SALARY: 0.02,
783
+ Category.EMI: 0.02,
784
+ Category.REFUND: 0.01,
785
+ }
786
+
787
+ # Amount distribution by category (min, max, mean, std_dev)
788
+ AMOUNT_DISTRIBUTIONS = {
789
+ Category.FOOD: (20, 3000, 350, 300),
790
+ Category.GROCERY: (50, 10000, 800, 600),
791
+ Category.SHOPPING: (100, 100000, 2500, 3000),
792
+ Category.TRANSPORT: (20, 5000, 250, 300),
793
+ Category.TRAVEL: (500, 200000, 8000, 15000),
794
+ Category.FUEL: (100, 10000, 1500, 1000),
795
+ Category.BILLS: (100, 20000, 1200, 1500),
796
+ Category.ENTERTAINMENT: (50, 5000, 500, 400),
797
+ Category.HEALTHCARE: (100, 50000, 2000, 3000),
798
+ Category.INVESTMENT: (500, 500000, 25000, 50000),
799
+ Category.TRANSFER: (100, 200000, 5000, 10000),
800
+ Category.SALARY: (15000, 500000, 60000, 40000),
801
+ Category.EMI: (1000, 100000, 15000, 12000),
802
+ Category.REFUND: (50, 50000, 1000, 2000),
803
+ }
804
+
805
+ @classmethod
806
+ def sample_weighted(cls, weights: Dict, rng: random.Random):
807
+ """Sample from weighted distribution."""
808
+ items = list(weights.keys())
809
+ probs = list(weights.values())
810
+ total = sum(probs)
811
+ probs = [p / total for p in probs]
812
+
813
+ r = rng.random()
814
+ cumulative = 0
815
+ for item, prob in zip(items, probs):
816
+ cumulative += prob
817
+ if r <= cumulative:
818
+ return item
819
+ return items[-1]
820
+
821
+ @classmethod
822
+ def sample_amount(cls, category: Category, rng: random.Random) -> float:
823
+ """Sample amount based on category distribution."""
824
+ min_amt, max_amt, mean, std = cls.AMOUNT_DISTRIBUTIONS.get(
825
+ category, (100, 10000, 1000, 1000)
826
+ )
827
+
828
+ # Use truncated normal distribution
829
+ amount = rng.gauss(mean, std)
830
+ amount = max(min_amt, min(max_amt, amount))
831
+
832
+ # Round to realistic precision
833
+ if amount < 100:
834
+ amount = round(amount, 2)
835
+ elif amount < 1000:
836
+ amount = round(amount, 1)
837
+ else:
838
+ amount = round(amount, 0)
839
+
840
+ return amount
841
+
842
+
843
+ # ============================================================================
844
+ # NOISE INJECTION ENGINE
845
+ # ============================================================================
846
+
847
+ class NoiseEngine:
848
+ """
849
+ Systematic noise injection for realistic messages.
850
+
851
+ Noise types:
852
+ 1. Spacing variations
853
+ 2. Abbreviations
854
+ 3. Case changes
855
+ 4. Truncation
856
+ 5. Typos
857
+ 6. Punctuation variations
858
+ """
859
+
860
+ ABBREVIATIONS = {
861
+ 'Account': ['A/c', 'Acc', 'Acct', 'a/c'],
862
+ 'Reference': ['Ref', 'Ref.', 'ref'],
863
+ 'Transaction': ['Txn', 'txn', 'Trans'],
864
+ 'Available': ['Avl', 'Avail', 'avl'],
865
+ 'Balance': ['Bal', 'bal', 'Bal.'],
866
+ 'Credited': ['Cr', 'cr', 'Cr.'],
867
+ 'Debited': ['Dr', 'dr', 'Dr.'],
868
+ 'Number': ['No', 'No.', 'Num'],
869
+ }
870
+
871
+ COMMON_TYPOS = {
872
+ 'debited': ['debitd', 'debite', 'debitted'],
873
+ 'credited': ['creditd', 'credite', 'creditted'],
874
+ 'transaction': ['transction', 'transcation', 'transacton'],
875
+ 'available': ['availble', 'avialable', 'availabel'],
876
+ 'balance': ['balace', 'balnce', 'balanc'],
877
+ }
878
+
879
+ def __init__(self, seed: int = 42):
880
+ self.rng = random.Random(seed)
881
+
882
+ def apply(self, text: str, noise_level: float) -> str:
883
+ """
884
+ Apply noise to text.
885
+
886
+ Args:
887
+ text: Original text
888
+ noise_level: 0.0 (clean) to 1.0 (very noisy)
889
+ """
890
+ if noise_level <= 0:
891
+ return text
892
+
893
+ # Determine which noise types to apply
894
+ noise_budget = noise_level
895
+
896
+ # 1. Abbreviations (always some)
897
+ if self.rng.random() < noise_budget * 0.5:
898
+ text = self._apply_abbreviations(text)
899
+
900
+ # 2. Spacing
901
+ if self.rng.random() < noise_budget * 0.3:
902
+ text = self._vary_spacing(text)
903
+
904
+ # 3. Case changes
905
+ if self.rng.random() < noise_budget * 0.2:
906
+ text = self._vary_case(text)
907
+
908
+ # 4. Truncation (for SMS)
909
+ if self.rng.random() < noise_budget * 0.1:
910
+ text = self._truncate(text)
911
+
912
+ # 5. Typos (rare)
913
+ if self.rng.random() < noise_budget * 0.05:
914
+ text = self._add_typos(text)
915
+
916
+ # 6. Punctuation
917
+ if self.rng.random() < noise_budget * 0.2:
918
+ text = self._vary_punctuation(text)
919
+
920
+ return text
921
+
922
+ def _apply_abbreviations(self, text: str) -> str:
923
+ """Replace words with abbreviations."""
924
+ for word, abbrevs in self.ABBREVIATIONS.items():
925
+ if word in text and self.rng.random() < 0.5:
926
+ text = text.replace(word, self.rng.choice(abbrevs), 1)
927
+ return text
928
+
929
+ def _vary_spacing(self, text: str) -> str:
930
+ """Vary spacing."""
931
+ variations = [
932
+ (". ", "."),
933
+ (": ", ":"),
934
+ ("Rs. ", "Rs."),
935
+ ("Rs ", "Rs"),
936
+ (", ", ","),
937
+ ]
938
+ for old, new in variations:
939
+ if self.rng.random() < 0.3:
940
+ text = text.replace(old, new)
941
+ return text
942
+
943
+ def _vary_case(self, text: str) -> str:
944
+ """Vary case."""
945
+ if self.rng.random() < 0.3:
946
+ return text.upper()
947
+ elif self.rng.random() < 0.1:
948
+ return text.lower()
949
+ return text
950
+
951
+ def _truncate(self, text: str) -> str:
952
+ """Truncate to SMS limit."""
953
+ if len(text) > 160:
954
+ return text[:157] + "..."
955
+ return text
956
+
957
+ def _add_typos(self, text: str) -> str:
958
+ """Add occasional typos."""
959
+ for word, typos in self.COMMON_TYPOS.items():
960
+ if word in text.lower() and self.rng.random() < 0.2:
961
+ # Case-insensitive replace
962
+ pattern = re.compile(re.escape(word), re.IGNORECASE)
963
+ text = pattern.sub(self.rng.choice(typos), text, count=1)
964
+ return text
965
+
966
+ def _vary_punctuation(self, text: str) -> str:
967
+ """Vary punctuation."""
968
+ if self.rng.random() < 0.3:
969
+ text = text.replace(".", "")
970
+ if self.rng.random() < 0.2:
971
+ text = text.replace(",", "")
972
+ return text
973
+
974
+
975
+ # ============================================================================
976
+ # MERCHANT & COUNTERPARTY DATABASE
977
+ # ============================================================================
978
+
979
+ @dataclass
980
+ class Merchant:
981
+ """Merchant entity with VPA and metadata."""
982
+ name: str
983
+ vpa: str
984
+ category: Category
985
+ aliases: List[str] = field(default_factory=list)
986
+
987
+ def get_display_name(self, rng: random.Random) -> str:
988
+ """Get a random display name variation."""
989
+ options = [self.name] + self.aliases
990
+ return rng.choice(options)
991
+
992
+
993
+ class MerchantDatabase:
994
+ """
995
+ Database of merchants organized by category.
996
+
997
+ Provides O(1) lookup by category and O(1) random selection.
998
+ """
999
+
1000
+ def __init__(self):
1001
+ self._by_category: Dict[Category, List[Merchant]] = defaultdict(list)
1002
+ self._by_name: Dict[str, Merchant] = {}
1003
+ self._all: List[Merchant] = []
1004
+ self._build_database()
1005
+
1006
+ def _build_database(self):
1007
+ """Build the merchant database."""
1008
+ merchants_data = [
1009
+ # Food Delivery
1010
+ ("Swiggy", "swiggy@ybl", Category.FOOD, ["SWIGGY", "Swiggy Instamart"]),
1011
+ ("Zomato", "zomato@paytm", Category.FOOD, ["ZOMATO", "Zomato Gold"]),
1012
+ ("Dominos", "dominos@hdfcbank", Category.FOOD, ["DOMINOS", "Domino's Pizza"]),
1013
+ ("Pizza Hut", "pizzahut@icici", Category.FOOD, ["PIZZA HUT"]),
1014
+ ("McDonalds", "mcdonalds@ybl", Category.FOOD, ["McDonald's", "MCD"]),
1015
+ ("KFC", "kfc@paytm", Category.FOOD, ["KFC"]),
1016
+ ("Starbucks", "starbucks@icici", Category.FOOD, ["STARBUCKS"]),
1017
+ ("Subway", "subway@hdfcbank", Category.FOOD, ["SUBWAY"]),
1018
+
1019
+ # E-commerce
1020
+ ("Amazon", "amazon@apl", Category.SHOPPING, ["AMAZON", "Amazon.in"]),
1021
+ ("Flipkart", "flipkart@ybl", Category.SHOPPING, ["FLIPKART"]),
1022
+ ("Myntra", "myntra@ybl", Category.SHOPPING, ["MYNTRA"]),
1023
+ ("Ajio", "ajio@icici", Category.SHOPPING, ["AJIO"]),
1024
+ ("Nykaa", "nykaa@paytm", Category.SHOPPING, ["NYKAA"]),
1025
+ ("Meesho", "meesho@paytm", Category.SHOPPING, ["MEESHO"]),
1026
+ ("Croma", "croma@hdfcbank", Category.SHOPPING, ["CROMA"]),
1027
+
1028
+ # Grocery
1029
+ ("Zepto", "zepto@ybl", Category.GROCERY, ["ZEPTO"]),
1030
+ ("BigBasket", "bigbasket@ybl", Category.GROCERY, ["BIGBASKET"]),
1031
+ ("Blinkit", "blinkit@paytm", Category.GROCERY, ["BLINKIT", "Grofers"]),
1032
+ ("DMart", "dmart@hdfcbank", Category.GROCERY, ["DMART", "D-Mart"]),
1033
+ ("JioMart", "jiomart@icici", Category.GROCERY, ["JIOMART"]),
1034
+
1035
+ # Transport
1036
+ ("Uber", "uber@paytm", Category.TRANSPORT, ["UBER"]),
1037
+ ("Ola", "ola@icici", Category.TRANSPORT, ["OLA", "Ola Cabs"]),
1038
+ ("Rapido", "rapido@ybl", Category.TRANSPORT, ["RAPIDO"]),
1039
+
1040
+ # Travel
1041
+ ("IRCTC", "irctc@sbi", Category.TRAVEL, ["IRCTC"]),
1042
+ ("MakeMyTrip", "makemytrip@icici", Category.TRAVEL, ["MMT"]),
1043
+ ("Goibibo", "goibibo@ybl", Category.TRAVEL, ["GOIBIBO"]),
1044
+ ("RedBus", "redbus@paytm", Category.TRAVEL, ["REDBUS"]),
1045
+
1046
+ # Fuel
1047
+ ("IOCL", "iocl@sbi", Category.FUEL, ["Indian Oil", "IndianOil"]),
1048
+ ("HPCL", "hpcl@hdfcbank", Category.FUEL, ["HP Petrol"]),
1049
+ ("BPCL", "bpcl@icici", Category.FUEL, ["Bharat Petroleum"]),
1050
+
1051
+ # Bills
1052
+ ("Airtel", "airtel@paytm", Category.BILLS, ["AIRTEL"]),
1053
+ ("Jio", "jio@icici", Category.BILLS, ["Reliance Jio"]),
1054
+ ("Vi", "vi@ybl", Category.BILLS, ["Vodafone Idea"]),
1055
+ ("Tata Power", "tatapower@hdfcbank", Category.BILLS, ["TATA POWER"]),
1056
+ ("BESCOM", "bescom@ybl", Category.BILLS, ["BESCOM"]),
1057
+
1058
+ # Entertainment
1059
+ ("Netflix", "netflix@icici", Category.ENTERTAINMENT, ["NETFLIX"]),
1060
+ ("Hotstar", "hotstar@ybl", Category.ENTERTAINMENT, ["Disney+ Hotstar"]),
1061
+ ("Spotify", "spotify@paytm", Category.ENTERTAINMENT, ["SPOTIFY"]),
1062
+ ("BookMyShow", "bookmyshow@paytm", Category.ENTERTAINMENT, ["BMS"]),
1063
+ ("PVR", "pvr@hdfcbank", Category.ENTERTAINMENT, ["PVR Cinemas"]),
1064
+
1065
+ # Healthcare
1066
+ ("Apollo", "apollo@hdfcbank", Category.HEALTHCARE, ["Apollo Pharmacy"]),
1067
+ ("PharmEasy", "pharmeasy@paytm", Category.HEALTHCARE, ["PHARMEASY"]),
1068
+ ("1mg", "1mg@ybl", Category.HEALTHCARE, ["Tata 1mg"]),
1069
+
1070
+ # Investment
1071
+ ("Zerodha", "zerodha@hdfcbank", Category.INVESTMENT, ["ZERODHA"]),
1072
+ ("Groww", "groww@axisbank", Category.INVESTMENT, ["GROWW"]),
1073
+ ("Upstox", "upstox@icici", Category.INVESTMENT, ["UPSTOX"]),
1074
+ ("Angel One", "angelone@ybl", Category.INVESTMENT, ["Angel Broking"]),
1075
+ ("ICICI Direct", "icicidirect@icici", Category.INVESTMENT, ["ICICIdirect"]),
1076
+ ("Paytm Money", "paytmmoney@paytm", Category.INVESTMENT, ["PAYTM MONEY"]),
1077
+ ("5Paisa", "5paisa@icici", Category.INVESTMENT, ["5paisa"]),
1078
+ ("Dhan", "dhan@okaxis", Category.INVESTMENT, ["DHAN"]),
1079
+ ]
1080
+
1081
+ for name, vpa, category, aliases in merchants_data:
1082
+ merchant = Merchant(name=name, vpa=vpa, category=category, aliases=aliases)
1083
+ self._by_category[category].append(merchant)
1084
+ self._by_name[name.lower()] = merchant
1085
+ self._all.append(merchant)
1086
+
1087
+ def get_by_category(self, category: Category) -> List[Merchant]:
1088
+ """Get all merchants in a category."""
1089
+ return self._by_category.get(category, [])
1090
+
1091
+ def get_random(self, rng: random.Random, category: Optional[Category] = None) -> Merchant:
1092
+ """Get a random merchant, optionally filtered by category."""
1093
+ if category:
1094
+ merchants = self._by_category.get(category, self._all)
1095
+ else:
1096
+ merchants = self._all
1097
+
1098
+ return rng.choice(merchants) if merchants else self._all[0]
1099
+
1100
+ def get_by_name(self, name: str) -> Optional[Merchant]:
1101
+ """Lookup merchant by name."""
1102
+ return self._by_name.get(name.lower())
1103
+
1104
+
1105
+ class PersonDatabase:
1106
+ """Database of Indian names for P2P transactions."""
1107
+
1108
+ FIRST_NAMES = [
1109
+ "Rahul", "Priya", "Amit", "Neha", "Vijay", "Deepak", "Anjali", "Rajesh",
1110
+ "Sunita", "Arun", "Pooja", "Sanjay", "Kavita", "Manoj", "Rekha", "Suresh",
1111
+ "Lakshmi", "Ganesh", "Meera", "Prakash", "Asha", "Ramesh", "Geeta", "Mohan",
1112
+ "Savita", "Kiran", "Vinod", "Usha", "Ashok", "Padma", "Rohit", "Sneha",
1113
+ "Vikas", "Divya", "Nitin", "Swati", "Abhishek", "Ritu", "Manish", "Preeti",
1114
+ ]
1115
+
1116
+ LAST_NAMES = [
1117
+ "Sharma", "Singh", "Kumar", "Gupta", "Patel", "Verma", "Mehta", "Nair",
1118
+ "Iyer", "Joshi", "Reddy", "Mishra", "Das", "Pillai", "Bose", "Menon",
1119
+ "Venkat", "Rao", "Kulkarni", "Shah", "Patil", "Chandra", "Devi", "Lal",
1120
+ "Sinha", "Chopra", "Saxena", "Rani", "Tiwari", "Hegde", "Agarwal", "Kapoor",
1121
+ "Yadav", "Bansal", "Jain", "Pandey", "Malhotra", "Behera", "Sahu", "Tarai",
1122
+ ]
1123
+
1124
+ VPA_SUFFIXES = [
1125
+ "@ybl", "@paytm", "@okicici", "@okhdfcbank", "@oksbi",
1126
+ "@axl", "@apl", "@ibl", "@upi", "@okaxis",
1127
+ ]
1128
+
1129
+ @classmethod
1130
+ def generate_name(cls, rng: random.Random) -> str:
1131
+ """Generate a random Indian name."""
1132
+ return f"{rng.choice(cls.FIRST_NAMES)} {rng.choice(cls.LAST_NAMES)}"
1133
+
1134
+ @classmethod
1135
+ def generate_vpa(cls, name: str, rng: random.Random) -> str:
1136
+ """Generate VPA from name."""
1137
+ parts = name.lower().split()
1138
+ patterns = [
1139
+ f"{parts[0]}{rng.randint(1, 99)}{rng.choice(cls.VPA_SUFFIXES)}",
1140
+ f"{parts[0]}.{parts[-1]}{rng.choice(cls.VPA_SUFFIXES)}",
1141
+ f"{parts[0]}{parts[-1][0]}{rng.randint(1, 9)}{rng.choice(cls.VPA_SUFFIXES)}",
1142
+ f"{parts[0]}_{rng.randint(100, 999)}{rng.choice(cls.VPA_SUFFIXES)}",
1143
+ ]
1144
+ return rng.choice(patterns)
1145
+
1146
+
1147
+ # ============================================================================
1148
+ # BANK DATABASE
1149
+ # ============================================================================
1150
+
1151
+ @dataclass
1152
+ class Bank:
1153
+ """Bank entity with metadata."""
1154
+ name: str
1155
+ code: str
1156
+ helpline: str
1157
+
1158
+ def get_intro(self, rng: random.Random) -> str:
1159
+ """Get bank introduction variation."""
1160
+ intros = [
1161
+ f"{self.name}:",
1162
+ f"{self.name} Bank:",
1163
+ f"{self.name} Bk:",
1164
+ f"Alert: {self.name}",
1165
+ f"Dear Customer, {self.name}:",
1166
+ ]
1167
+ return rng.choice(intros)
1168
+
1169
+
1170
+ class BankDatabase:
1171
+ """Database of Indian banks."""
1172
+
1173
+ BANKS = [
1174
+ Bank("HDFC", "HDFC", "18002586161"),
1175
+ Bank("ICICI", "ICIC", "18002662"),
1176
+ Bank("SBI", "SBIN", "1800112211"),
1177
+ Bank("Axis", "UTIB", "18004195959"),
1178
+ Bank("Kotak", "KKBK", "18601266022"),
1179
+ Bank("PNB", "PUNB", "18001802222"),
1180
+ Bank("BOB", "BARB", "18001024455"),
1181
+ Bank("IDFC", "IDFB", "18001024"),
1182
+ Bank("Yes Bank", "YESB", "18001200"),
1183
+ Bank("IndusInd", "INDB", "18602677777"),
1184
+ Bank("Canara", "CNRB", "18004250018"),
1185
+ Bank("Union Bank", "UBIN", "18002082244"),
1186
+ ]
1187
+
1188
+ @classmethod
1189
+ def get_all(cls) -> List[Bank]:
1190
+ return cls.BANKS
1191
+
1192
+ @classmethod
1193
+ def get_random(cls, rng: random.Random) -> Bank:
1194
+ return rng.choice(cls.BANKS)
1195
+
1196
+ @classmethod
1197
+ def get_by_name(cls, name: str) -> Optional[Bank]:
1198
+ for bank in cls.BANKS:
1199
+ if bank.name.lower() == name.lower():
1200
+ return bank
1201
+ return None
1202
+
1203
+
1204
+ # ============================================================================
1205
+ # GROUND TRUTH SCHEMA
1206
+ # ============================================================================
1207
+
1208
+ @dataclass
1209
+ class GroundTruth:
1210
+ """
1211
+ Complete ground truth for training.
1212
+
1213
+ Includes all extractable fields with both raw and normalized versions.
1214
+ """
1215
+ # Core
1216
+ amount: Optional[float] = None
1217
+ amount_raw: Optional[str] = None
1218
+ currency: str = "INR"
1219
+ type: Optional[str] = None
1220
+ status: str = "success"
1221
+
1222
+ # Account
1223
+ account: Optional[str] = None
1224
+ account_raw: Optional[str] = None
1225
+ bank: Optional[str] = None
1226
+
1227
+ # DateTime
1228
+ date: Optional[str] = None # YYYY-MM-DD
1229
+ date_raw: Optional[str] = None
1230
+ time: Optional[str] = None # HH:MM:SS
1231
+ time_raw: Optional[str] = None
1232
+
1233
+ # Reference
1234
+ reference: Optional[str] = None
1235
+ reference_raw: Optional[str] = None
1236
+ reference_type: Optional[str] = None
1237
+
1238
+ # Counterparty
1239
+ merchant: Optional[str] = None
1240
+ merchant_raw: Optional[str] = None
1241
+ vpa: Optional[str] = None
1242
+ beneficiary: Optional[str] = None
1243
+
1244
+ # Classification
1245
+ payment_method: Optional[str] = None
1246
+ category: Optional[str] = None
1247
+
1248
+ # Balance
1249
+ balance_after: Optional[float] = None
1250
+ balance_raw: Optional[str] = None
1251
+
1252
+ # Metadata
1253
+ message_type: str = "transaction"
1254
+ is_p2m: bool = False
1255
+
1256
+ def to_dict(self) -> Dict[str, Any]:
1257
+ """Convert to dict, excluding None values."""
1258
+ return {k: v for k, v in asdict(self).items() if v is not None}
1259
+
1260
+ def to_json(self) -> str:
1261
+ """Convert to JSON string."""
1262
+ return json.dumps(self.to_dict(), ensure_ascii=False)
1263
+
1264
+
1265
+ # ============================================================================
1266
+ # MAIN TRANSACTION GENERATOR
1267
+ # ============================================================================
1268
+
1269
+ class TransactionGenerator:
1270
+ """
1271
+ Main generator class that orchestrates all components.
1272
+
1273
+ Architecture:
1274
+ 1. Sample from statistical distributions
1275
+ 2. Generate using grammar rules
1276
+ 3. Apply noise injection
1277
+ 4. Validate output
1278
+ """
1279
+
1280
+ def __init__(self, seed: int = 42):
1281
+ self.seed = seed
1282
+ self.rng = random.Random(seed)
1283
+
1284
+ # Initialize components
1285
+ self.grammar = MessageGrammar(seed)
1286
+ self.noise = NoiseEngine(seed)
1287
+ self.merchants = MerchantDatabase()
1288
+ self.distribution = DistributionConfig()
1289
+
1290
+ def generate_transaction(
1291
+ self,
1292
+ txn_type: Optional[TransactionType] = None,
1293
+ status: Optional[TransactionStatus] = None,
1294
+ category: Optional[Category] = None,
1295
+ payment_method: Optional[PaymentMethod] = None,
1296
+ noise_level: float = 0.3,
1297
+ ) -> Dict[str, Any]:
1298
+ """
1299
+ Generate a single transaction.
1300
+
1301
+ Args:
1302
+ txn_type: Force transaction type (or sample)
1303
+ status: Force status (or sample)
1304
+ category: Force category (or sample)
1305
+ payment_method: Force payment method (or sample)
1306
+ noise_level: Amount of noise to inject
1307
+
1308
+ Returns:
1309
+ Dict with 'text' and 'ground_truth' keys
1310
+ """
1311
+ # Sample missing parameters from distributions
1312
+ if txn_type is None:
1313
+ txn_type = self.distribution.sample_weighted(
1314
+ self.distribution.TRANSACTION_TYPE_WEIGHTS, self.rng
1315
+ )
1316
+
1317
+ if status is None:
1318
+ status = self.distribution.sample_weighted(
1319
+ self.distribution.STATUS_WEIGHTS, self.rng
1320
+ )
1321
+
1322
+ if category is None:
1323
+ category = self.distribution.sample_weighted(
1324
+ self.distribution.CATEGORY_WEIGHTS, self.rng
1325
+ )
1326
+
1327
+ if payment_method is None:
1328
+ payment_method = self.distribution.sample_weighted(
1329
+ self.distribution.PAYMENT_METHOD_WEIGHTS, self.rng
1330
+ )
1331
+
1332
+ # Generate components
1333
+ bank = BankDatabase.get_random(self.rng)
1334
+ amount = self.distribution.sample_amount(category, self.rng)
1335
+ amount_obj = Amount.from_float(amount)
1336
+ amount_fmt = self.rng.choice(list(AmountFormat))
1337
+ amount_raw = amount_obj.format(amount_fmt)
1338
+
1339
+ currency = self.rng.choice(list(CurrencySymbol))
1340
+
1341
+ account = Account(str(self.rng.randint(1000, 9999)))
1342
+ account_style = self.rng.choice(["XX", "xx", "****", "X"])
1343
+
1344
+ # Date
1345
+ days_ago = self.rng.randint(0, 365)
1346
+ txn_date = date.today() - timedelta(days=days_ago)
1347
+ time_tuple = (
1348
+ self.rng.randint(0, 23),
1349
+ self.rng.randint(0, 59),
1350
+ self.rng.randint(0, 59)
1351
+ ) if self.rng.random() > 0.5 else None
1352
+ date_obj = TransactionDate(txn_date, time_tuple)
1353
+ date_fmt = self.rng.choice(list(DateFormat))
1354
+
1355
+ # Reference
1356
+ ref_type = self.rng.choice(["numeric", "alphanumeric", "utr"])
1357
+ ref = Reference.generate(ref_type, bank.code)
1358
+
1359
+ # Balance
1360
+ balance = round(self.rng.uniform(1000, 500000), 2)
1361
+ balance_fmt = self.rng.choice([AmountFormat.COMMA_INTERNATIONAL, AmountFormat.DECIMAL_2])
1362
+ balance_raw = Amount.from_float(balance).format(balance_fmt)
1363
+
1364
+ # Counterparty
1365
+ is_p2m = category not in [Category.TRANSFER, Category.SALARY]
1366
+
1367
+ if is_p2m:
1368
+ merchant = self.merchants.get_random(self.rng, category)
1369
+ vpa = merchant.vpa
1370
+ merchant_raw = merchant.get_display_name(self.rng)
1371
+ beneficiary = None
1372
+ else:
1373
+ merchant = None
1374
+ name = PersonDatabase.generate_name(self.rng)
1375
+ vpa = PersonDatabase.generate_vpa(name, self.rng)
1376
+ merchant_raw = None
1377
+ beneficiary = name
1378
+
1379
+ # Build parameters for grammar
1380
+ params = {
1381
+ 'bank': bank.name,
1382
+ 'currency': currency.value,
1383
+ 'amount': amount_raw,
1384
+ 'account': account.format(account_style),
1385
+ 'date': date_obj.format_date(date_fmt),
1386
+ 'time': date_obj.format_time() or "",
1387
+ 'vpa': vpa,
1388
+ 'beneficiary': beneficiary or (merchant.name if merchant else ""),
1389
+ 'ref': ref.value,
1390
+ 'balance': balance_raw,
1391
+ 'helpline': bank.helpline,
1392
+ 'reason': self.rng.choice([
1393
+ "Insufficient funds",
1394
+ "Transaction declined",
1395
+ "Network error",
1396
+ "Invalid VPA",
1397
+ ]) if status == TransactionStatus.FAILED else "",
1398
+ }
1399
+
1400
+ # Generate message using grammar
1401
+ if status == TransactionStatus.FAILED:
1402
+ text = self.grammar.generate_failed_message(params)
1403
+ elif status == TransactionStatus.PENDING:
1404
+ text = self.grammar.generate_pending_message(params)
1405
+ elif txn_type == TransactionType.CREDIT:
1406
+ text = self.grammar.generate_credit_message(params)
1407
+ else:
1408
+ text = self.grammar.generate_debit_message(params)
1409
+
1410
+ # Apply noise
1411
+ text = self.noise.apply(text, noise_level)
1412
+
1413
+ # Build ground truth
1414
+ ground_truth = GroundTruth(
1415
+ amount=amount,
1416
+ amount_raw=amount_raw,
1417
+ currency="INR",
1418
+ type=txn_type.value,
1419
+ status=status.value,
1420
+ account=account.last_four,
1421
+ account_raw=account.format(account_style),
1422
+ bank=bank.name,
1423
+ date=date_obj.normalized(),
1424
+ date_raw=date_obj.format_date(date_fmt),
1425
+ time=f"{time_tuple[0]:02d}:{time_tuple[1]:02d}:{time_tuple[2]:02d}" if time_tuple else None,
1426
+ time_raw=date_obj.format_time(),
1427
+ reference=ref.value,
1428
+ reference_type=ref_type,
1429
+ merchant=merchant.name.lower() if merchant else None,
1430
+ merchant_raw=merchant_raw,
1431
+ vpa=vpa,
1432
+ beneficiary=beneficiary,
1433
+ payment_method=payment_method.value,
1434
+ category=category.value,
1435
+ balance_after=balance,
1436
+ balance_raw=balance_raw,
1437
+ message_type="transaction",
1438
+ is_p2m=is_p2m,
1439
+ )
1440
+
1441
+ return {
1442
+ 'text': text,
1443
+ 'ground_truth': ground_truth.to_dict(),
1444
+ }
1445
+
1446
+ def generate_non_transaction(self, msg_type: MessageType) -> Dict[str, Any]:
1447
+ """Generate non-transaction message (OTP, promo, etc.)."""
1448
+ bank = BankDatabase.get_random(self.rng)
1449
+
1450
+ if msg_type == MessageType.OTP:
1451
+ otp = ''.join(self.rng.choices('0123456789', k=6))
1452
+ templates = [
1453
+ f"{bank.name} Bank: Your OTP is {otp}. Valid for 10 mins. Do not share with anyone.",
1454
+ f"{bank.name}: {otp} is your OTP for transaction. Valid for 5 mins.",
1455
+ f"OTP for {bank.name} Bank: {otp}. Do not share this with anyone.",
1456
+ ]
1457
+ text = self.rng.choice(templates)
1458
+
1459
+ elif msg_type == MessageType.PROMOTIONAL:
1460
+ templates = [
1461
+ f"{bank.name} Bank wishes you a Happy Diwali! Enjoy 10% cashback on all transactions.",
1462
+ f"Exclusive offer! Get 5% cashback on shopping using {bank.name} Credit Card.",
1463
+ f"{bank.name}: Apply for Personal Loan at lowest interest rates. Click here.",
1464
+ ]
1465
+ text = self.rng.choice(templates)
1466
+
1467
+ elif msg_type == MessageType.ALERT:
1468
+ account = Account(str(self.rng.randint(1000, 9999)))
1469
+ templates = [
1470
+ f"{bank.name} Bank: Your Debit Card has been blocked. Call {bank.helpline}.",
1471
+ f"{bank.name}: Suspicious activity detected on A/c {account.format('XX')}. Call immediately.",
1472
+ f"{bank.name}: Your account KYC is pending. Update by end of month.",
1473
+ ]
1474
+ text = self.rng.choice(templates)
1475
+
1476
+ else: # STATEMENT
1477
+ account = Account(str(self.rng.randint(1000, 9999)))
1478
+ balance = round(self.rng.uniform(1000, 500000), 2)
1479
+ text = f"{bank.name} Bank: Your A/c {account.format('XX')} balance is Rs.{balance:,.2f}."
1480
+
1481
+ ground_truth = GroundTruth(
1482
+ message_type=msg_type.value,
1483
+ bank=bank.name,
1484
+ )
1485
+
1486
+ return {
1487
+ 'text': text,
1488
+ 'ground_truth': ground_truth.to_dict(),
1489
+ }
1490
+
1491
+ def generate_batch(
1492
+ self,
1493
+ count: int,
1494
+ include_non_transactions: bool = True,
1495
+ include_edge_cases: bool = True,
1496
+ noise_level: float = 0.3,
1497
+ ) -> List[Dict[str, Any]]:
1498
+ """
1499
+ Generate a batch of transactions.
1500
+
1501
+ Args:
1502
+ count: Number of records to generate
1503
+ include_non_transactions: Include OTP, promo messages (5%)
1504
+ include_edge_cases: Include edge cases (3%)
1505
+ noise_level: Noise intensity
1506
+ """
1507
+ records = []
1508
+
1509
+ # Calculate distribution
1510
+ edge_case_count = int(count * 0.03) if include_edge_cases else 0
1511
+ non_txn_count = int(count * 0.05) if include_non_transactions else 0
1512
+ txn_count = count - edge_case_count - non_txn_count
1513
+
1514
+ print(f"Generating {count:,} records...")
1515
+ print(f" - Transactions: {txn_count:,}")
1516
+ print(f" - Edge cases: {edge_case_count:,}")
1517
+ print(f" - Non-transactions: {non_txn_count:,}")
1518
+
1519
+ # Generate transactions
1520
+ for i in range(txn_count):
1521
+ record = self.generate_transaction(noise_level=noise_level)
1522
+ record['id'] = i + 1
1523
+ record['hash'] = hashlib.sha256(record['text'].encode()).hexdigest()[:16]
1524
+ records.append(record)
1525
+
1526
+ if (i + 1) % 10000 == 0:
1527
+ print(f" Generated {i+1:,}/{count:,}")
1528
+
1529
+ # Generate edge cases
1530
+ edge_configs = EdgeCaseGenerator.generate_edge_case_batch(edge_case_count, self.rng)
1531
+ for i, config in enumerate(edge_configs):
1532
+ # Generate with edge case parameters
1533
+ record = self.generate_transaction(noise_level=noise_level * 0.5)
1534
+
1535
+ # Apply edge case modifications
1536
+ if config.get('amount'):
1537
+ record['ground_truth']['amount'] = config['amount']
1538
+ if config.get('merchant'):
1539
+ record['ground_truth']['merchant_raw'] = config['merchant']
1540
+ if config.get('beneficiary'):
1541
+ record['ground_truth']['beneficiary'] = config['beneficiary']
1542
+
1543
+ record['id'] = txn_count + i + 1
1544
+ record['edge_case'] = config['edge_type']
1545
+ records.append(record)
1546
+
1547
+ # Generate non-transactions
1548
+ for i in range(non_txn_count):
1549
+ msg_type = self.rng.choice([MessageType.OTP, MessageType.PROMOTIONAL, MessageType.ALERT])
1550
+ record = self.generate_non_transaction(msg_type)
1551
+ record['id'] = txn_count + edge_case_count + i + 1
1552
+ records.append(record)
1553
+
1554
+ # Shuffle
1555
+ self.rng.shuffle(records)
1556
+
1557
+ return records
1558
+
1559
+
1560
+ # ============================================================================
1561
+ # VALIDATION
1562
+ # ============================================================================
1563
+
1564
+ class DatasetValidator:
1565
+ """Validate generated dataset for quality."""
1566
+
1567
+ @staticmethod
1568
+ def validate(data: List[Dict]) -> Dict[str, Any]:
1569
+ """
1570
+ Validate dataset quality.
1571
+
1572
+ Checks:
1573
+ 1. Required fields present
1574
+ 2. Distribution matches expectations
1575
+ 3. No duplicate texts
1576
+ 4. Format correctness
1577
+ """
1578
+ issues = []
1579
+ warnings = []
1580
+
1581
+ # Check duplicates
1582
+ texts = [r['text'] for r in data]
1583
+ unique = set(texts)
1584
+ dup_rate = 1 - len(unique) / len(texts)
1585
+ if dup_rate > 0.01:
1586
+ warnings.append(f"Duplicate rate: {dup_rate:.2%}")
1587
+
1588
+ # Check field completeness
1589
+ txn_records = [r for r in data if r['ground_truth'].get('message_type') == 'transaction']
1590
+
1591
+ for r in txn_records:
1592
+ gt = r['ground_truth']
1593
+ if gt.get('amount') is None:
1594
+ issues.append(f"Missing amount in record {r.get('id')}")
1595
+ if gt.get('type') is None:
1596
+ issues.append(f"Missing type in record {r.get('id')}")
1597
+
1598
+ # Check distributions
1599
+ categories = defaultdict(int)
1600
+ statuses = defaultdict(int)
1601
+ types = defaultdict(int)
1602
+
1603
+ for r in txn_records:
1604
+ gt = r['ground_truth']
1605
+ categories[gt.get('category', 'unknown')] += 1
1606
+ statuses[gt.get('status', 'unknown')] += 1
1607
+ types[gt.get('type', 'unknown')] += 1
1608
+
1609
+ # Calculate statistics
1610
+ total_txn = len(txn_records)
1611
+
1612
+ result = {
1613
+ 'total_records': len(data),
1614
+ 'transaction_records': total_txn,
1615
+ 'non_transaction_records': len(data) - total_txn,
1616
+ 'unique_texts': len(unique),
1617
+ 'duplicate_rate': dup_rate,
1618
+ 'issues': issues[:20],
1619
+ 'warnings': warnings,
1620
+ 'distributions': {
1621
+ 'categories': {k: v/total_txn for k, v in categories.items()},
1622
+ 'statuses': {k: v/total_txn for k, v in statuses.items()},
1623
+ 'types': {k: v/total_txn for k, v in types.items()},
1624
+ },
1625
+ 'valid': len(issues) == 0,
1626
+ }
1627
+
1628
+ return result
1629
+
1630
+
1631
+ # ============================================================================
1632
+ # OUTPUT WRITERS
1633
+ # ============================================================================
1634
+
1635
+ def save_jsonl(data: List[Dict], path: Path):
1636
+ """Save as JSONL."""
1637
+ with open(path, 'w', encoding='utf-8') as f:
1638
+ for record in data:
1639
+ line = {
1640
+ 'input': record['text'],
1641
+ 'output': json.dumps(record['ground_truth'], ensure_ascii=False),
1642
+ 'id': record.get('id'),
1643
+ 'hash': record.get('hash'),
1644
+ }
1645
+ f.write(json.dumps(line, ensure_ascii=False) + '\n')
1646
+ print(f"✅ Saved: {path}")
1647
+
1648
+
1649
+ def save_chat_format(data: List[Dict], path: Path):
1650
+ """Save in chat format for instruction tuning."""
1651
+ system = """Extract financial entities from Indian banking messages. Output JSON with:
1652
+ - amount, amount_raw, type, status, account, date, reference
1653
+ - merchant, vpa, beneficiary, payment_method, category
1654
+ Only include fields present in the message."""
1655
+
1656
+ with open(path, 'w', encoding='utf-8') as f:
1657
+ for record in data:
1658
+ chat = {
1659
+ 'messages': [
1660
+ {'role': 'system', 'content': system},
1661
+ {'role': 'user', 'content': record['text']},
1662
+ {'role': 'assistant', 'content': json.dumps(record['ground_truth'], ensure_ascii=False)},
1663
+ ]
1664
+ }
1665
+ f.write(json.dumps(chat, ensure_ascii=False) + '\n')
1666
+ print(f"✅ Saved: {path}")
1667
+
1668
+
1669
+ # ============================================================================
1670
+ # CLI
1671
+ # ============================================================================
1672
+
1673
+ def main():
1674
+ parser = argparse.ArgumentParser(
1675
+ description="Production-grade synthetic data generator for Indian banking transactions"
1676
+ )
1677
+ parser.add_argument("-n", "--count", type=int, default=10000, help="Number of records")
1678
+ parser.add_argument("-o", "--output", default="data/synthetic.jsonl", help="Output path")
1679
+ parser.add_argument("--seed", type=int, default=42, help="Random seed")
1680
+ parser.add_argument("--noise", type=float, default=0.3, help="Noise level (0-1)")
1681
+ parser.add_argument("--validate", action="store_true", help="Validate after generation")
1682
+ parser.add_argument("--chat-format", action="store_true", help="Also save chat format")
1683
+
1684
+ args = parser.parse_args()
1685
+
1686
+ # Create output directory
1687
+ output_path = Path(args.output)
1688
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1689
+
1690
+ # Generate
1691
+ generator = TransactionGenerator(seed=args.seed)
1692
+ data = generator.generate_batch(
1693
+ count=args.count,
1694
+ noise_level=args.noise,
1695
+ )
1696
+
1697
+ # Save
1698
+ save_jsonl(data, output_path)
1699
+
1700
+ if args.chat_format:
1701
+ chat_path = output_path.with_suffix('.chat.jsonl')
1702
+ save_chat_format(data, chat_path)
1703
+
1704
+ # Validate
1705
+ if args.validate:
1706
+ print("\n📊 Validation:")
1707
+ result = DatasetValidator.validate(data)
1708
+ print(f" Total: {result['total_records']:,}")
1709
+ print(f" Unique: {result['unique_texts']:,}")
1710
+ print(f" Duplicate rate: {result['duplicate_rate']:.2%}")
1711
+ print(f" Valid: {'✅' if result['valid'] else '❌'}")
1712
+
1713
+ if result['issues']:
1714
+ print(f"\n⚠️ Issues ({len(result['issues'])}):")
1715
+ for issue in result['issues'][:5]:
1716
+ print(f" - {issue}")
1717
+
1718
+ print("\n📈 Distributions:")
1719
+ for dist_name, dist in result['distributions'].items():
1720
+ print(f"\n {dist_name}:")
1721
+ for k, v in sorted(dist.items(), key=lambda x: -x[1])[:5]:
1722
+ print(f" {k}: {v:.1%}")
1723
+
1724
+
1725
+ if __name__ == "__main__":
1726
+ main()
scripts/data_pipeline/step1_unify.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Step 1: Data Unification Script
4
+ ================================
5
+
6
+ Reads various data formats (XML, JSON, CSV, MBOX) and
7
+ combines them into a single standardized DataFrame.
8
+
9
+ Output Schema: ['timestamp', 'sender', 'body', 'source']
10
+
11
+ Usage:
12
+ python step1_unify.py --input /path/to/raw/data --output step1_unified.csv
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import csv
18
+ import os
19
+ import re
20
+ import pandas as pd
21
+ from pathlib import Path
22
+ from datetime import datetime
23
+ from typing import List, Dict, Any, Optional
24
+ import mailbox
25
+ import email
26
+ from email.utils import parsedate_to_datetime
27
+ import xml.etree.ElementTree as ET
28
+
29
+
30
+ def parse_mbox(filepath: Path) -> List[Dict[str, Any]]:
31
+ """Parse Gmail MBOX export."""
32
+ records = []
33
+ try:
34
+ mbox = mailbox.mbox(str(filepath))
35
+ for message in mbox:
36
+ try:
37
+ # Get timestamp
38
+ date_str = message.get('Date', '')
39
+ try:
40
+ timestamp = parsedate_to_datetime(date_str).isoformat()
41
+ except:
42
+ timestamp = date_str
43
+
44
+ # Get sender
45
+ sender = message.get('From', '')
46
+
47
+ # Get body
48
+ body = ''
49
+ if message.is_multipart():
50
+ for part in message.walk():
51
+ if part.get_content_type() == 'text/plain':
52
+ payload = part.get_payload(decode=True)
53
+ if payload:
54
+ body = payload.decode('utf-8', errors='ignore')
55
+ break
56
+ else:
57
+ payload = message.get_payload(decode=True)
58
+ if payload:
59
+ body = payload.decode('utf-8', errors='ignore')
60
+
61
+ if body.strip():
62
+ records.append({
63
+ 'timestamp': timestamp,
64
+ 'sender': sender,
65
+ 'body': body.strip(),
66
+ 'source': 'mbox'
67
+ })
68
+ except Exception as e:
69
+ continue
70
+ except Exception as e:
71
+ print(f" ⚠️ Error parsing MBOX {filepath}: {e}")
72
+
73
+ return records
74
+
75
+
76
+ def parse_json(filepath: Path) -> List[Dict[str, Any]]:
77
+ """Parse JSON exports (Google Takeout format)."""
78
+ records = []
79
+ try:
80
+ with open(filepath, 'r', encoding='utf-8') as f:
81
+ data = json.load(f)
82
+
83
+ # Handle different JSON structures
84
+ if isinstance(data, list):
85
+ items = data
86
+ elif isinstance(data, dict):
87
+ # Common Google Takeout patterns
88
+ items = data.get('messages', []) or \
89
+ data.get('transactions', []) or \
90
+ data.get('items', []) or \
91
+ data.get('data', []) or \
92
+ [data]
93
+ else:
94
+ items = []
95
+
96
+ for item in items:
97
+ if not isinstance(item, dict):
98
+ continue
99
+
100
+ # Try common field names
101
+ timestamp = item.get('timestamp') or item.get('date') or \
102
+ item.get('time') or item.get('created_at') or ''
103
+
104
+ sender = item.get('sender') or item.get('from') or \
105
+ item.get('source') or item.get('merchant') or ''
106
+
107
+ body = item.get('body') or item.get('message') or \
108
+ item.get('text') or item.get('content') or \
109
+ item.get('description') or item.get('title') or ''
110
+
111
+ # For Google Pay transactions
112
+ if 'amount' in item:
113
+ amount = item.get('amount', '')
114
+ merchant = item.get('merchant', {})
115
+ if isinstance(merchant, dict):
116
+ merchant_name = merchant.get('name', '')
117
+ else:
118
+ merchant_name = str(merchant)
119
+ body = f"Transaction: Rs.{amount} to {merchant_name}"
120
+
121
+ if body and str(body).strip():
122
+ records.append({
123
+ 'timestamp': str(timestamp),
124
+ 'sender': str(sender),
125
+ 'body': str(body).strip(),
126
+ 'source': f'json:{filepath.name}'
127
+ })
128
+
129
+ except Exception as e:
130
+ print(f" ⚠️ Error parsing JSON {filepath}: {e}")
131
+
132
+ return records
133
+
134
+
135
+ def parse_csv(filepath: Path) -> List[Dict[str, Any]]:
136
+ """Parse CSV exports."""
137
+ records = []
138
+ try:
139
+ df = pd.read_csv(filepath, encoding='utf-8', on_bad_lines='skip')
140
+
141
+ # Find relevant columns (case-insensitive)
142
+ cols = {c.lower(): c for c in df.columns}
143
+
144
+ timestamp_col = None
145
+ for name in ['timestamp', 'date', 'time', 'datetime', 'created_at']:
146
+ if name in cols:
147
+ timestamp_col = cols[name]
148
+ break
149
+
150
+ sender_col = None
151
+ for name in ['sender', 'from', 'source', 'bank', 'merchant']:
152
+ if name in cols:
153
+ sender_col = cols[name]
154
+ break
155
+
156
+ body_col = None
157
+ for name in ['body', 'message', 'text', 'content', 'description', 'sms']:
158
+ if name in cols:
159
+ body_col = cols[name]
160
+ break
161
+
162
+ if body_col:
163
+ for _, row in df.iterrows():
164
+ body = str(row.get(body_col, ''))
165
+ if body.strip() and body != 'nan':
166
+ records.append({
167
+ 'timestamp': str(row.get(timestamp_col, '')) if timestamp_col else '',
168
+ 'sender': str(row.get(sender_col, '')) if sender_col else '',
169
+ 'body': body.strip(),
170
+ 'source': f'csv:{filepath.name}'
171
+ })
172
+
173
+ except Exception as e:
174
+ print(f" ⚠️ Error parsing CSV {filepath}: {e}")
175
+
176
+ return records
177
+
178
+
179
+ def parse_xml(filepath: Path) -> List[Dict[str, Any]]:
180
+ """Parse XML exports (SMS Backup format)."""
181
+ records = []
182
+ try:
183
+ tree = ET.parse(filepath)
184
+ root = tree.getroot()
185
+
186
+ # Common SMS backup format
187
+ for sms in root.findall('.//sms') or root.findall('.//message'):
188
+ body = sms.get('body') or sms.text or ''
189
+ timestamp = sms.get('date') or sms.get('timestamp') or ''
190
+ sender = sms.get('address') or sms.get('sender') or sms.get('from') or ''
191
+
192
+ if body.strip():
193
+ # Convert timestamp if it's milliseconds
194
+ if timestamp.isdigit() and len(timestamp) > 10:
195
+ try:
196
+ timestamp = datetime.fromtimestamp(int(timestamp)/1000).isoformat()
197
+ except:
198
+ pass
199
+
200
+ records.append({
201
+ 'timestamp': timestamp,
202
+ 'sender': sender,
203
+ 'body': body.strip(),
204
+ 'source': f'xml:{filepath.name}'
205
+ })
206
+
207
+ except Exception as e:
208
+ print(f" ⚠️ Error parsing XML {filepath}: {e}")
209
+
210
+ return records
211
+
212
+
213
+ def find_all_files(input_dir: Path) -> Dict[str, List[Path]]:
214
+ """Find all data files recursively."""
215
+ files = {
216
+ 'mbox': [],
217
+ 'json': [],
218
+ 'csv': [],
219
+ 'xml': []
220
+ }
221
+
222
+ for filepath in input_dir.rglob('*'):
223
+ if filepath.is_file():
224
+ ext = filepath.suffix.lower()
225
+ if ext == '.mbox':
226
+ files['mbox'].append(filepath)
227
+ elif ext == '.json':
228
+ files['json'].append(filepath)
229
+ elif ext == '.csv':
230
+ files['csv'].append(filepath)
231
+ elif ext == '.xml':
232
+ files['xml'].append(filepath)
233
+
234
+ return files
235
+
236
+
237
+ def unify_data(input_dir: Path) -> pd.DataFrame:
238
+ """Main function to unify all data sources."""
239
+ print("=" * 60)
240
+ print("📂 STEP 1: DATA UNIFICATION")
241
+ print("=" * 60)
242
+
243
+ all_records = []
244
+
245
+ # Find all files
246
+ print(f"\n🔍 Scanning: {input_dir}")
247
+ files = find_all_files(input_dir)
248
+
249
+ total_files = sum(len(v) for v in files.values())
250
+ print(f" Found {total_files} files to process")
251
+
252
+ # Parse MBOX files
253
+ if files['mbox']:
254
+ print(f"\n📧 Processing {len(files['mbox'])} MBOX files...")
255
+ for f in files['mbox']:
256
+ print(f" Processing: {f.name}")
257
+ records = parse_mbox(f)
258
+ all_records.extend(records)
259
+ print(f" ✅ Extracted {len(records)} messages")
260
+
261
+ # Parse JSON files
262
+ if files['json']:
263
+ print(f"\n📋 Processing {len(files['json'])} JSON files...")
264
+ for f in files['json']:
265
+ print(f" Processing: {f.name}")
266
+ records = parse_json(f)
267
+ all_records.extend(records)
268
+ print(f" ✅ Extracted {len(records)} records")
269
+
270
+ # Parse CSV files
271
+ if files['csv']:
272
+ print(f"\n📊 Processing {len(files['csv'])} CSV files...")
273
+ for f in files['csv']:
274
+ print(f" Processing: {f.name}")
275
+ records = parse_csv(f)
276
+ all_records.extend(records)
277
+ print(f" ✅ Extracted {len(records)} records")
278
+
279
+ # Parse XML files
280
+ if files['xml']:
281
+ print(f"\n📝 Processing {len(files['xml'])} XML files...")
282
+ for f in files['xml']:
283
+ print(f" Processing: {f.name}")
284
+ records = parse_xml(f)
285
+ all_records.extend(records)
286
+ print(f" ✅ Extracted {len(records)} records")
287
+
288
+ # Create DataFrame
289
+ df = pd.DataFrame(all_records, columns=['timestamp', 'sender', 'body', 'source'])
290
+
291
+ # Remove exact duplicates
292
+ original_count = len(df)
293
+ df = df.drop_duplicates(subset=['body'])
294
+ dedup_count = len(df)
295
+
296
+ print(f"\n📊 SUMMARY:")
297
+ print(f" Total records: {original_count}")
298
+ print(f" After dedup: {dedup_count}")
299
+ print(f" Removed: {original_count - dedup_count} duplicates")
300
+
301
+ return df
302
+
303
+
304
+ def main():
305
+ parser = argparse.ArgumentParser(description="Step 1: Unify data sources")
306
+ parser.add_argument("--input", "-i", required=True, help="Input directory with raw data")
307
+ parser.add_argument("--output", "-o", default="data/pipeline/step1_unified.csv",
308
+ help="Output CSV path")
309
+ args = parser.parse_args()
310
+
311
+ input_dir = Path(args.input)
312
+ if not input_dir.exists():
313
+ print(f"❌ Input directory not found: {input_dir}")
314
+ return
315
+
316
+ # Unify data
317
+ df = unify_data(input_dir)
318
+
319
+ if len(df) == 0:
320
+ print("\n❌ No data extracted! Check your input directory.")
321
+ return
322
+
323
+ # Save output
324
+ output_path = Path(args.output)
325
+ output_path.parent.mkdir(parents=True, exist_ok=True)
326
+ df.to_csv(output_path, index=False)
327
+
328
+ print(f"\n✅ Saved to: {output_path}")
329
+ print(f" Records: {len(df)}")
330
+ print("\nNext: python scripts/data_pipeline/step2_filter.py")
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()
scripts/data_pipeline/step2_filter.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Step 2: Garbage Filter
4
+ ======================
5
+
6
+ Filters out noise from unified data:
7
+ - OTPs / Verification codes
8
+ - Login alerts
9
+ - Marketing spam
10
+ - Bill reminders (non-transaction)
11
+ - Password reset
12
+
13
+ Keeps only actual transaction messages.
14
+
15
+ Usage:
16
+ python step2_filter.py --input step1_unified.csv --output step2_training_ready.csv
17
+ """
18
+
19
+ import argparse
20
+ import re
21
+ import pandas as pd
22
+ from pathlib import Path
23
+ from typing import Tuple
24
+
25
+
26
+ # ============================================================================
27
+ # GARBAGE PATTERNS (Messages to REMOVE)
28
+ # ============================================================================
29
+
30
+ GARBAGE_PATTERNS = [
31
+ # OTPs and verification codes
32
+ r'\bOTP\b',
33
+ r'\bone.time.password\b',
34
+ r'\bverification.code\b',
35
+ r'\bverify.your\b',
36
+ r'\bconfirm.your.identity\b',
37
+ r'\bcvv\b',
38
+ r'\bpin\b.*\bdo.not.share\b',
39
+ r'\b\d{4,6}\b.*\bexpires?\b',
40
+ r'\bvalid.for.\d+.min',
41
+
42
+ # Login/Security alerts
43
+ r'\blogin.alert\b',
44
+ r'\blogged.in\b',
45
+ r'\bnew.device\b',
46
+ r'\bnew.login\b',
47
+ r'\bsecurity.alert\b',
48
+ r'\bsign.in.attempt\b',
49
+ r'\bpassword.changed\b',
50
+ r'\bpassword.reset\b',
51
+ r'\baccount.locked\b',
52
+ r'\bunusual.activity\b',
53
+
54
+ # Marketing / Promotional
55
+ r'\b\d+%.off\b',
56
+ r'\bdiscount\b',
57
+ r'\bcashback.offer\b',
58
+ r'\bflat.rs\b.*\boff\b',
59
+ r'\bget.upto\b',
60
+ r'\bwin.upto\b',
61
+ r'\bexclusive.offer\b',
62
+ r'\blimited.time\b',
63
+ r'\bspecial.offer\b',
64
+ r'\bsale.live\b',
65
+ r'\bshop.now\b',
66
+ r'\bbuy.now\b',
67
+ r'\bsubscribe\b',
68
+ r'\bunsubscribe\b',
69
+ r'\bnewsletter\b',
70
+
71
+ # Bill reminders (not actual transactions)
72
+ r'\bbill.due\b',
73
+ r'\bbill.reminder\b',
74
+ r'\bdue.date\b',
75
+ r'\bpay.before\b',
76
+ r'\bauto.debit.scheduled\b',
77
+ r'\bemi.due\b',
78
+ r'\bpayment.reminder\b',
79
+ r'\bminimum.amount.due\b',
80
+ r'\boutstanding.balance\b',
81
+
82
+ # Account statements / Summaries
83
+ r'\baccount.statement\b',
84
+ r'\bmonthly.statement\b',
85
+ r'\be.statement\b',
86
+ r'\bstatement.ready\b',
87
+ r'\bdownload.statement\b',
88
+
89
+ # Delivery / Shipping (Not finance)
90
+ r'\bout.for.delivery\b',
91
+ r'\bdelivered.to\b',
92
+ r'\bshipment.update\b',
93
+ r'\btracking.number\b',
94
+ r'\border.confirmed\b',
95
+ r'\border.placed\b',
96
+ r'\border.shipped\b',
97
+
98
+ # App notifications
99
+ r'\brate.your.experience\b',
100
+ r'\bleave.a.review\b',
101
+ r'\bupdate.available\b',
102
+ r'\bapp.update\b',
103
+ r'\bdownload.app\b',
104
+ r'\binstall.app\b',
105
+
106
+ # Generic noise
107
+ r'\bclick.here\b',
108
+ r'\bvisit.us\b',
109
+ r'\bcall.us\b',
110
+ r'\bcontact.us\b',
111
+ r'\bfollow.us\b',
112
+ r'\bjoin.us\b',
113
+ r'\blearn.more\b',
114
+ r'\bread.more\b',
115
+ ]
116
+
117
+ # ============================================================================
118
+ # KEEP PATTERNS (Messages to KEEP - actual transactions)
119
+ # ============================================================================
120
+
121
+ KEEP_PATTERNS = [
122
+ # Transaction keywords
123
+ r'\bdebited?\b',
124
+ r'\bcredited?\b',
125
+ r'\btransferred?\b',
126
+ r'\bwithdra(?:wn|wal)\b',
127
+ r'\bdeposited?\b',
128
+ r'\breceived?\b',
129
+ r'\bsent\b',
130
+ r'\bpaid\b',
131
+ r'\bpurchase\b',
132
+ r'\btransaction\b',
133
+ r'\btxn\b',
134
+ r'\bupi\b',
135
+ r'\bneft\b',
136
+ r'\bimps\b',
137
+ r'\brtgs\b',
138
+
139
+ # Amount patterns
140
+ r'rs\.?\s*[\d,]+',
141
+ r'inr\s*[\d,]+',
142
+ r'₹\s*[\d,]+',
143
+
144
+ # Account references
145
+ r'a/c\s*\w+',
146
+ r'acct?\s*\w+',
147
+ r'account\s*\w+',
148
+
149
+ # Reference numbers
150
+ r'ref\.?\s*:?\s*\d{10,}',
151
+ r'upi.?ref\s*:?\s*\d{10,}',
152
+ ]
153
+
154
+
155
+ def is_garbage(text: str) -> bool:
156
+ """Check if message is garbage (should be removed)."""
157
+ text_lower = text.lower()
158
+
159
+ for pattern in GARBAGE_PATTERNS:
160
+ if re.search(pattern, text_lower):
161
+ return True
162
+
163
+ return False
164
+
165
+
166
+ def is_transaction(text: str) -> bool:
167
+ """Check if message is a transaction (should be kept)."""
168
+ text_lower = text.lower()
169
+
170
+ matches = 0
171
+ for pattern in KEEP_PATTERNS:
172
+ if re.search(pattern, text_lower):
173
+ matches += 1
174
+
175
+ # Require at least 2 transaction indicators
176
+ return matches >= 2
177
+
178
+
179
+ def classify_message(text: str) -> Tuple[str, str]:
180
+ """
181
+ Classify a message as 'keep', 'garbage', or 'uncertain'.
182
+
183
+ Returns: (classification, reason)
184
+ """
185
+ if not text or len(text.strip()) < 20:
186
+ return 'garbage', 'too_short'
187
+
188
+ # First check garbage patterns (high confidence negative)
189
+ if is_garbage(text):
190
+ return 'garbage', 'matched_garbage_pattern'
191
+
192
+ # Then check transaction patterns (high confidence positive)
193
+ if is_transaction(text):
194
+ return 'keep', 'matched_transaction_pattern'
195
+
196
+ # Messages with amounts but no clear transaction type
197
+ if re.search(r'(?:rs\.?|inr|₹)\s*[\d,]+', text.lower()):
198
+ return 'uncertain', 'has_amount_no_transaction_type'
199
+
200
+ # Default: uncertain
201
+ return 'uncertain', 'no_strong_signals'
202
+
203
+
204
+ def filter_data(df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
205
+ """
206
+ Filter DataFrame into keep/garbage/uncertain.
207
+
208
+ Returns: (keep_df, garbage_df, uncertain_df)
209
+ """
210
+ print("=" * 60)
211
+ print("🧹 STEP 2: GARBAGE FILTER")
212
+ print("=" * 60)
213
+
214
+ results = []
215
+ for _, row in df.iterrows():
216
+ body = row.get('body', '')
217
+ classification, reason = classify_message(str(body))
218
+ results.append({
219
+ **row.to_dict(),
220
+ 'classification': classification,
221
+ 'filter_reason': reason
222
+ })
223
+
224
+ result_df = pd.DataFrame(results)
225
+
226
+ keep_df = result_df[result_df['classification'] == 'keep'].copy()
227
+ garbage_df = result_df[result_df['classification'] == 'garbage'].copy()
228
+ uncertain_df = result_df[result_df['classification'] == 'uncertain'].copy()
229
+
230
+ # Print summary
231
+ total = len(df)
232
+ print(f"\n📊 FILTER RESULTS:")
233
+ print(f" Total input: {total:,}")
234
+ print(f" ✅ Keep: {len(keep_df):,} ({100*len(keep_df)/total:.1f}%)")
235
+ print(f" ❌ Garbage: {len(garbage_df):,} ({100*len(garbage_df)/total:.1f}%)")
236
+ print(f" ❓ Uncertain: {len(uncertain_df):,} ({100*len(uncertain_df)/total:.1f}%)")
237
+
238
+ # Show reason breakdown for garbage
239
+ if len(garbage_df) > 0:
240
+ print(f"\n📋 Garbage Reasons:")
241
+ for reason, count in garbage_df['filter_reason'].value_counts().items():
242
+ print(f" {reason}: {count:,}")
243
+
244
+ return keep_df, garbage_df, uncertain_df
245
+
246
+
247
+ def main():
248
+ parser = argparse.ArgumentParser(description="Step 2: Filter garbage messages")
249
+ parser.add_argument("--input", "-i", default="data/pipeline/step1_unified.csv",
250
+ help="Input CSV from step 1")
251
+ parser.add_argument("--output", "-o", default="data/pipeline/step2_training_ready.csv",
252
+ help="Output CSV with clean data")
253
+ parser.add_argument("--save-all", action="store_true",
254
+ help="Also save garbage and uncertain to separate files")
255
+ args = parser.parse_args()
256
+
257
+ input_path = Path(args.input)
258
+ if not input_path.exists():
259
+ print(f"❌ Input file not found: {input_path}")
260
+ print(f" Run step1_unify.py first!")
261
+ return
262
+
263
+ # Load data
264
+ print(f"\n📂 Loading: {input_path}")
265
+ df = pd.read_csv(input_path)
266
+ print(f" Loaded {len(df):,} records")
267
+
268
+ # Filter
269
+ keep_df, garbage_df, uncertain_df = filter_data(df)
270
+
271
+ # Save results
272
+ output_path = Path(args.output)
273
+ output_path.parent.mkdir(parents=True, exist_ok=True)
274
+
275
+ # Drop helper columns before saving
276
+ keep_df = keep_df.drop(columns=['classification', 'filter_reason'])
277
+ keep_df.to_csv(output_path, index=False)
278
+ print(f"\n✅ Saved clean data to: {output_path}")
279
+ print(f" Records: {len(keep_df):,}")
280
+
281
+ if args.save_all:
282
+ garbage_path = output_path.parent / "step2_garbage.csv"
283
+ uncertain_path = output_path.parent / "step2_uncertain.csv"
284
+
285
+ garbage_df.to_csv(garbage_path, index=False)
286
+ uncertain_df.to_csv(uncertain_path, index=False)
287
+ print(f"\n📁 Also saved:")
288
+ print(f" Garbage: {garbage_path}")
289
+ print(f" Uncertain: {uncertain_path}")
290
+
291
+ print("\nNext: python scripts/data_pipeline/step3_baseline.py")
292
+
293
+
294
+ if __name__ == "__main__":
295
+ main()
scripts/data_pipeline/step3_baseline.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Step 3: Baseline Test
4
+ =====================
5
+
6
+ Tests the current finee extractor on the cleaned training data
7
+ to establish a baseline before fine-tuning.
8
+
9
+ This answers: "How good is our Regex engine on real data?"
10
+
11
+ Usage:
12
+ python step3_baseline.py --input step2_training_ready.csv
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import pandas as pd
18
+ from pathlib import Path
19
+ from typing import Dict, Any, List
20
+ from datetime import datetime
21
+ import sys
22
+
23
+ # Add parent to path for imports
24
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
25
+
26
+ try:
27
+ from finee import extract
28
+ from finee.schema import ExtractionResult
29
+ except ImportError:
30
+ print("❌ finee not installed!")
31
+ print(" Run: pip install finee")
32
+ sys.exit(1)
33
+
34
+
35
+ def extract_and_analyze(row: Dict[str, Any]) -> Dict[str, Any]:
36
+ """Extract entities from a message and analyze results."""
37
+ body = str(row.get('body', ''))
38
+
39
+ try:
40
+ result = extract(body)
41
+
42
+ return {
43
+ # Original data
44
+ 'timestamp': row.get('timestamp', ''),
45
+ 'sender': row.get('sender', ''),
46
+ 'body': body[:200] + '...' if len(body) > 200 else body,
47
+ 'source': row.get('source', ''),
48
+
49
+ # Extracted fields
50
+ 'extracted_amount': result.amount,
51
+ 'extracted_type': result.type.value if result.type else None,
52
+ 'extracted_account': result.account,
53
+ 'extracted_date': result.date,
54
+ 'extracted_reference': result.reference,
55
+ 'extracted_vpa': result.vpa,
56
+ 'extracted_merchant': result.merchant,
57
+ 'extracted_category': result.category.value if result.category else None,
58
+ 'extracted_confidence': result.confidence.value if result.confidence else None,
59
+ 'extracted_confidence_score': result.confidence_score,
60
+
61
+ # Quality metrics
62
+ 'has_amount': result.amount is not None,
63
+ 'has_type': result.type is not None,
64
+ 'has_merchant': result.merchant is not None,
65
+ 'has_category': result.category is not None,
66
+ 'fields_extracted': sum([
67
+ result.amount is not None,
68
+ result.type is not None,
69
+ result.account is not None,
70
+ result.date is not None,
71
+ result.reference is not None,
72
+ result.merchant is not None,
73
+ result.category is not None,
74
+ ]),
75
+
76
+ # Processing info
77
+ 'processing_time_ms': result.processing_time_ms,
78
+ 'extraction_success': result.amount is not None and result.type is not None,
79
+ }
80
+ except Exception as e:
81
+ return {
82
+ 'timestamp': row.get('timestamp', ''),
83
+ 'sender': row.get('sender', ''),
84
+ 'body': body[:200],
85
+ 'source': row.get('source', ''),
86
+ 'extraction_error': str(e),
87
+ 'extraction_success': False,
88
+ }
89
+
90
+
91
+ def run_baseline(df: pd.DataFrame) -> pd.DataFrame:
92
+ """Run baseline extraction on all rows."""
93
+ print("=" * 60)
94
+ print("📊 STEP 3: BASELINE TEST")
95
+ print("=" * 60)
96
+ print(f"\nTesting finee extractor on {len(df):,} messages...")
97
+ print("(This tests Regex-only mode, no LLM)\n")
98
+
99
+ results = []
100
+ success_count = 0
101
+
102
+ for i, (_, row) in enumerate(df.iterrows()):
103
+ result = extract_and_analyze(row.to_dict())
104
+ results.append(result)
105
+
106
+ if result.get('extraction_success'):
107
+ success_count += 1
108
+
109
+ # Progress every 100
110
+ if (i + 1) % 100 == 0:
111
+ pct = 100 * success_count / (i + 1)
112
+ print(f" Processed {i+1:,}/{len(df):,} ({pct:.1f}% success rate)")
113
+
114
+ return pd.DataFrame(results)
115
+
116
+
117
+ def analyze_results(results_df: pd.DataFrame) -> Dict[str, Any]:
118
+ """Analyze extraction results."""
119
+ total = len(results_df)
120
+
121
+ # Core metrics
122
+ success_count = results_df['extraction_success'].sum()
123
+ has_amount = results_df['has_amount'].sum()
124
+ has_type = results_df['has_type'].sum()
125
+ has_merchant = results_df['has_merchant'].sum()
126
+ has_category = results_df['has_category'].sum()
127
+
128
+ # Confidence distribution
129
+ confidence_counts = results_df['extracted_confidence'].value_counts().to_dict()
130
+
131
+ # Type distribution
132
+ type_counts = results_df['extracted_type'].value_counts().to_dict()
133
+
134
+ # Category distribution
135
+ category_counts = results_df['extracted_category'].value_counts().to_dict()
136
+
137
+ # Top merchants
138
+ merchant_counts = results_df['extracted_merchant'].value_counts().head(20).to_dict()
139
+
140
+ # Performance
141
+ avg_time = results_df['processing_time_ms'].mean()
142
+
143
+ analysis = {
144
+ 'total_messages': total,
145
+ 'extraction_success_rate': 100 * success_count / total,
146
+ 'field_coverage': {
147
+ 'amount': 100 * has_amount / total,
148
+ 'type': 100 * has_type / total,
149
+ 'merchant': 100 * has_merchant / total,
150
+ 'category': 100 * has_category / total,
151
+ },
152
+ 'confidence_distribution': confidence_counts,
153
+ 'type_distribution': type_counts,
154
+ 'category_distribution': category_counts,
155
+ 'top_merchants': merchant_counts,
156
+ 'avg_processing_time_ms': avg_time,
157
+ 'timestamp': datetime.now().isoformat(),
158
+ }
159
+
160
+ return analysis
161
+
162
+
163
+ def print_analysis(analysis: Dict[str, Any]) -> None:
164
+ """Print analysis results."""
165
+ print("\n" + "=" * 60)
166
+ print("📈 BASELINE RESULTS")
167
+ print("=" * 60)
168
+
169
+ print(f"\n📊 COVERAGE:")
170
+ print(f" Total messages: {analysis['total_messages']:,}")
171
+ print(f" Extraction success: {analysis['extraction_success_rate']:.1f}%")
172
+
173
+ print(f"\n📋 FIELD COVERAGE:")
174
+ for field, pct in analysis['field_coverage'].items():
175
+ status = "✅" if pct >= 80 else "⚠️" if pct >= 50 else "❌"
176
+ print(f" {field:12} {pct:5.1f}% {status}")
177
+
178
+ print(f"\n📊 CONFIDENCE DISTRIBUTION:")
179
+ for level, count in sorted(analysis['confidence_distribution'].items(), key=lambda x: -x[1]):
180
+ if level:
181
+ pct = 100 * count / analysis['total_messages']
182
+ print(f" {level:10} {count:,} ({pct:.1f}%)")
183
+
184
+ print(f"\n💳 TRANSACTION TYPES:")
185
+ for txn_type, count in sorted(analysis['type_distribution'].items(), key=lambda x: -x[1]):
186
+ if txn_type:
187
+ pct = 100 * count / analysis['total_messages']
188
+ print(f" {txn_type:10} {count:,} ({pct:.1f}%)")
189
+
190
+ print(f"\n🏪 TOP 10 MERCHANTS:")
191
+ for i, (merchant, count) in enumerate(list(analysis['top_merchants'].items())[:10]):
192
+ if merchant:
193
+ print(f" {i+1:2}. {merchant:20} {count:,}")
194
+
195
+ print(f"\n⚡ PERFORMANCE:")
196
+ print(f" Avg processing time: {analysis['avg_processing_time_ms']:.2f}ms")
197
+ print(f" Throughput: ~{1000/analysis['avg_processing_time_ms']:.0f} msg/sec")
198
+
199
+ print("\n" + "=" * 60)
200
+
201
+
202
+ def main():
203
+ parser = argparse.ArgumentParser(description="Step 3: Baseline extraction test")
204
+ parser.add_argument("--input", "-i", default="data/pipeline/step2_training_ready.csv",
205
+ help="Input CSV from step 2")
206
+ parser.add_argument("--output", "-o", default="data/pipeline/step3_baseline_results.csv",
207
+ help="Output CSV with extraction results")
208
+ parser.add_argument("--limit", "-n", type=int, default=None,
209
+ help="Limit number of rows to process (for testing)")
210
+ args = parser.parse_args()
211
+
212
+ input_path = Path(args.input)
213
+ if not input_path.exists():
214
+ print(f"❌ Input file not found: {input_path}")
215
+ print(f" Run step2_filter.py first!")
216
+ return
217
+
218
+ # Load data
219
+ print(f"\n📂 Loading: {input_path}")
220
+ df = pd.read_csv(input_path)
221
+
222
+ if args.limit:
223
+ df = df.head(args.limit)
224
+ print(f" (Limited to {args.limit} rows for testing)")
225
+
226
+ print(f" Loaded {len(df):,} records")
227
+
228
+ # Run baseline
229
+ results_df = run_baseline(df)
230
+
231
+ # Analyze
232
+ analysis = analyze_results(results_df)
233
+ print_analysis(analysis)
234
+
235
+ # Save results
236
+ output_path = Path(args.output)
237
+ output_path.parent.mkdir(parents=True, exist_ok=True)
238
+ results_df.to_csv(output_path, index=False)
239
+ print(f"\n✅ Saved extraction results to: {output_path}")
240
+
241
+ # Save analysis as JSON
242
+ analysis_path = output_path.parent / "step3_baseline_analysis.json"
243
+ with open(analysis_path, 'w') as f:
244
+ json.dump(analysis, f, indent=2, default=str)
245
+ print(f" Analysis saved to: {analysis_path}")
246
+
247
+ # Summary
248
+ success_rate = analysis['extraction_success_rate']
249
+ if success_rate >= 80:
250
+ print(f"\n🎉 Great! {success_rate:.1f}% success rate. Regex is working well!")
251
+ elif success_rate >= 50:
252
+ print(f"\n⚠️ {success_rate:.1f}% success rate. Room for improvement.")
253
+ print(" Consider adding more regex patterns or enabling LLM mode.")
254
+ else:
255
+ print(f"\n❌ Low {success_rate:.1f}% success rate.")
256
+ print(" Your data may have unusual formats. Review failed extractions.")
257
+
258
+
259
+ if __name__ == "__main__":
260
+ main()
scripts/data_pipeline/step4_label.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Step 4: Create Labeled Training Data
4
+ =====================================
5
+
6
+ Processes the clean SMS data and creates training labels.
7
+ Extracts:
8
+ - amount, type, account, date, reference (regex)
9
+ - beneficiary_name (from SMS pattern)
10
+ - Detects merchant vs P2P transactions
11
+
12
+ Usage:
13
+ python step4_label.py --input step2_sms_clean.csv --output step4_labeled.csv
14
+ """
15
+
16
+ import argparse
17
+ import re
18
+ import json
19
+ import pandas as pd
20
+ from pathlib import Path
21
+ from typing import Dict, Any, Optional, Tuple
22
+
23
+
24
+ # ============================================================================
25
+ # ICICI BANK SMS PATTERNS (Dominant format in data)
26
+ # ============================================================================
27
+
28
+ ICICI_DEBIT_PATTERN = re.compile(
29
+ r'ICICI Bank Acc?t?\s*XX?(\d+)\s+debited\s+(?:for\s+)?Rs\.?\s*([\d,]+(?:\.\d{2})?)\s+on\s+(\d{1,2}-[A-Za-z]{3}-\d{2,4})[\s;]+([A-Za-z0-9\s]+?)\s+credited\.\s*UPI[:\s]*(\d+)',
30
+ re.IGNORECASE
31
+ )
32
+
33
+ ICICI_CREDIT_PATTERN = re.compile(
34
+ r'(?:Dear Customer,?\s*)?Acc?t?\s*XX?(\d+)\s+(?:is\s+)?credited\s+(?:with\s+)?Rs\.?\s*([\d,]+(?:\.\d{2})?)\s+on\s+(\d{1,2}-[A-Za-z]{3}-\d{2,4})\s+from\s+([A-Za-z0-9\s]+?)[\.\s]+UPI[:\s]*(\d+)',
35
+ re.IGNORECASE
36
+ )
37
+
38
+ # Generic amount pattern
39
+ AMOUNT_PATTERN = re.compile(r'Rs\.?\s*([\d,]+(?:\.\d{1,2})?)', re.IGNORECASE)
40
+ DATE_PATTERN = re.compile(r'(\d{1,2}[-/][A-Za-z]{3}[-/]\d{2,4}|\d{1,2}[-/]\d{1,2}[-/]\d{2,4})')
41
+ UPI_REF_PATTERN = re.compile(r'UPI[:\s]*(\d{12,16})', re.IGNORECASE)
42
+ ACCOUNT_PATTERN = re.compile(r'XX?(\d{3,4})', re.IGNORECASE)
43
+
44
+ # Merchant detection keywords (P2M vs P2P)
45
+ MERCHANT_KEYWORDS = {
46
+ 'swiggy', 'zomato', 'uber', 'ola', 'amazon', 'flipkart', 'paytm',
47
+ 'phonepe', 'google', 'youtube', 'netflix', 'spotify', 'airtel',
48
+ 'jio', 'vodafone', 'bsnl', 'electricity', 'gas', 'water', 'bill',
49
+ 'store', 'mart', 'shop', 'restaurant', 'hotel', 'hospital', 'clinic',
50
+ 'pharmacy', 'petrol', 'fuel', 'charging', 'parking', 'toll', 'metro',
51
+ 'railway', 'flight', 'bus', 'cab', 'taxi', 'rent', 'insurance',
52
+ 'zepto', 'bigbasket', 'blinkit', 'instamart', 'dunzo', 'myntra',
53
+ 'ajio', 'nykaa', 'tata', 'reliance', 'dmart', 'more', 'grofers'
54
+ }
55
+
56
+
57
+ def is_merchant(beneficiary: str) -> bool:
58
+ """Determine if beneficiary is a merchant (P2M) or person (P2P)."""
59
+ if not beneficiary:
60
+ return False
61
+
62
+ name_lower = beneficiary.lower().strip()
63
+
64
+ # Check against known merchant keywords
65
+ for keyword in MERCHANT_KEYWORDS:
66
+ if keyword in name_lower:
67
+ return True
68
+
69
+ # Heuristics for P2M vs P2P:
70
+ # - Merchants often have Ltd, Pvt, Inc, Store, Shop
71
+ # - Person names are usually 2-3 words
72
+
73
+ if any(x in name_lower for x in ['ltd', 'pvt', 'inc', 'llp', 'corp',
74
+ 'store', 'shop', 'mart', 'services',
75
+ 'limited', 'private']):
76
+ return True
77
+
78
+ # All caps names with numbers are likely merchants
79
+ if beneficiary.isupper() and any(c.isdigit() for c in beneficiary):
80
+ return True
81
+
82
+ return False
83
+
84
+
85
+ def normalize_beneficiary(name: str) -> str:
86
+ """Clean up beneficiary name."""
87
+ if not name:
88
+ return ""
89
+
90
+ # Remove trailing/leading whitespace
91
+ name = name.strip()
92
+
93
+ # Remove common suffixes
94
+ name = re.sub(r'\s+credited\.?$', '', name, flags=re.IGNORECASE)
95
+ name = re.sub(r'\s+debited\.?$', '', name, flags=re.IGNORECASE)
96
+
97
+ # Title case if all uppercase
98
+ if name.isupper():
99
+ name = name.title()
100
+
101
+ return name.strip()
102
+
103
+
104
+ def extract_from_sms(body: str) -> Dict[str, Any]:
105
+ """Extract all fields from SMS body."""
106
+ result = {
107
+ 'amount': None,
108
+ 'type': None,
109
+ 'account': None,
110
+ 'date': None,
111
+ 'reference': None,
112
+ 'beneficiary': None,
113
+ 'is_merchant': False,
114
+ 'category': None,
115
+ 'extraction_method': None
116
+ }
117
+
118
+ # Try ICICI debit pattern
119
+ match = ICICI_DEBIT_PATTERN.search(body)
120
+ if match:
121
+ result['account'] = match.group(1)
122
+ result['amount'] = float(match.group(2).replace(',', ''))
123
+ result['date'] = match.group(3)
124
+ result['beneficiary'] = normalize_beneficiary(match.group(4))
125
+ result['reference'] = match.group(5)
126
+ result['type'] = 'debit'
127
+ result['is_merchant'] = is_merchant(result['beneficiary'])
128
+ result['extraction_method'] = 'icici_debit_pattern'
129
+ return result
130
+
131
+ # Try ICICI credit pattern
132
+ match = ICICI_CREDIT_PATTERN.search(body)
133
+ if match:
134
+ result['account'] = match.group(1)
135
+ result['amount'] = float(match.group(2).replace(',', ''))
136
+ result['date'] = match.group(3)
137
+ result['beneficiary'] = normalize_beneficiary(match.group(4))
138
+ result['reference'] = match.group(5)
139
+ result['type'] = 'credit'
140
+ result['is_merchant'] = is_merchant(result['beneficiary'])
141
+ result['extraction_method'] = 'icici_credit_pattern'
142
+ return result
143
+
144
+ # Fallback: generic extraction
145
+ # Amount
146
+ amount_match = AMOUNT_PATTERN.search(body)
147
+ if amount_match:
148
+ try:
149
+ result['amount'] = float(amount_match.group(1).replace(',', ''))
150
+ except:
151
+ pass
152
+
153
+ # Type
154
+ if re.search(r'\bdebit', body, re.IGNORECASE):
155
+ result['type'] = 'debit'
156
+ elif re.search(r'\bcredit', body, re.IGNORECASE):
157
+ result['type'] = 'credit'
158
+
159
+ # Account
160
+ acc_match = ACCOUNT_PATTERN.search(body)
161
+ if acc_match:
162
+ result['account'] = acc_match.group(1)
163
+
164
+ # Date
165
+ date_match = DATE_PATTERN.search(body)
166
+ if date_match:
167
+ result['date'] = date_match.group(1)
168
+
169
+ # Reference
170
+ ref_match = UPI_REF_PATTERN.search(body)
171
+ if ref_match:
172
+ result['reference'] = ref_match.group(1)
173
+
174
+ result['extraction_method'] = 'generic_fallback'
175
+ return result
176
+
177
+
178
+ def create_training_label(row: Dict[str, Any], extraction: Dict[str, Any]) -> Dict[str, Any]:
179
+ """Create a training label with ground truth."""
180
+ body = str(row.get('body', ''))
181
+
182
+ # Build ground truth JSON (what we want the model to output)
183
+ ground_truth = {
184
+ 'amount': extraction['amount'],
185
+ 'type': extraction['type'],
186
+ 'account': extraction['account'],
187
+ 'date': extraction['date'],
188
+ 'reference': extraction['reference'],
189
+ 'beneficiary': extraction['beneficiary'],
190
+ 'is_p2m': extraction['is_merchant'],
191
+ }
192
+
193
+ # Remove None values
194
+ ground_truth = {k: v for k, v in ground_truth.items() if v is not None}
195
+
196
+ return {
197
+ # Original data
198
+ 'timestamp': row.get('timestamp', ''),
199
+ 'sender': row.get('sender', ''),
200
+ 'body': body,
201
+ 'source': row.get('source', ''),
202
+
203
+ # Extracted fields
204
+ **{f'extracted_{k}': v for k, v in extraction.items()},
205
+
206
+ # Training label (JSON format for LLM fine-tuning)
207
+ 'ground_truth_json': json.dumps(ground_truth, ensure_ascii=False),
208
+
209
+ # Quality flags
210
+ 'has_amount': extraction['amount'] is not None,
211
+ 'has_type': extraction['type'] is not None,
212
+ 'has_beneficiary': extraction['beneficiary'] is not None and len(extraction['beneficiary']) > 0,
213
+ 'complete_extraction': all([
214
+ extraction['amount'] is not None,
215
+ extraction['type'] is not None,
216
+ extraction['reference'] is not None
217
+ ]),
218
+ }
219
+
220
+
221
+ def label_data(df: pd.DataFrame) -> pd.DataFrame:
222
+ """Label all data for training."""
223
+ print("=" * 60)
224
+ print("🏷️ STEP 4: CREATING LABELED TRAINING DATA")
225
+ print("=" * 60)
226
+
227
+ results = []
228
+ complete_count = 0
229
+
230
+ for i, (_, row) in enumerate(df.iterrows()):
231
+ body = str(row.get('body', ''))
232
+ extraction = extract_from_sms(body)
233
+ label = create_training_label(row.to_dict(), extraction)
234
+ results.append(label)
235
+
236
+ if label['complete_extraction']:
237
+ complete_count += 1
238
+
239
+ if (i + 1) % 500 == 0:
240
+ print(f" Processed {i+1:,}/{len(df):,} ({100*complete_count/(i+1):.1f}% complete)")
241
+
242
+ result_df = pd.DataFrame(results)
243
+
244
+ print(f"\n📊 LABELING RESULTS:")
245
+ print(f" Total records: {len(result_df):,}")
246
+ print(f" Complete extractions: {complete_count:,} ({100*complete_count/len(result_df):.1f}%)")
247
+ print(f" Has amount: {result_df['has_amount'].sum():,}")
248
+ print(f" Has type: {result_df['has_type'].sum():,}")
249
+ print(f" Has beneficiary: {result_df['has_beneficiary'].sum():,}")
250
+
251
+ # Show breakdown by extraction method
252
+ print(f"\n📋 EXTRACTION METHODS:")
253
+ method_counts = result_df['extracted_extraction_method'].value_counts()
254
+ for method, count in method_counts.items():
255
+ print(f" {method}: {count:,}")
256
+
257
+ return result_df
258
+
259
+
260
+ def main():
261
+ parser = argparse.ArgumentParser(description="Step 4: Create labeled training data")
262
+ parser.add_argument("--input", "-i", default="data/pipeline/step2_sms_clean.csv",
263
+ help="Input CSV from step 2 (SMS only)")
264
+ parser.add_argument("--output", "-o", default="data/pipeline/step4_labeled.csv",
265
+ help="Output CSV with labels")
266
+ args = parser.parse_args()
267
+
268
+ input_path = Path(args.input)
269
+ if not input_path.exists():
270
+ print(f"❌ Input file not found: {input_path}")
271
+ return
272
+
273
+ # Load data
274
+ print(f"\n📂 Loading: {input_path}")
275
+ df = pd.read_csv(input_path)
276
+ print(f" Loaded {len(df):,} records")
277
+
278
+ # Label data
279
+ labeled_df = label_data(df)
280
+
281
+ # Save output
282
+ output_path = Path(args.output)
283
+ output_path.parent.mkdir(parents=True, exist_ok=True)
284
+ labeled_df.to_csv(output_path, index=False)
285
+
286
+ print(f"\n✅ Saved labeled data to: {output_path}")
287
+
288
+ # Also save training-ready JSONL (for LLM fine-tuning)
289
+ jsonl_path = output_path.parent / "step4_training.jsonl"
290
+ with open(jsonl_path, 'w') as f:
291
+ for _, row in labeled_df[labeled_df['complete_extraction']].iterrows():
292
+ training_example = {
293
+ 'input': row['body'],
294
+ 'output': row['ground_truth_json']
295
+ }
296
+ f.write(json.dumps(training_example, ensure_ascii=False) + '\n')
297
+
298
+ print(f" JSONL for LLM training: {jsonl_path}")
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()
scripts/finetune.py ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ LLM Fine-tuning Pipeline for Finance Entity Extraction
4
+ =======================================================
5
+
6
+ Fine-tunes a language model on the combined training data
7
+ for Indian banking transaction entity extraction.
8
+
9
+ Supports:
10
+ - MLX (Apple Silicon) via mlx-lm
11
+ - PyTorch/Transformers (GPU/CPU)
12
+
13
+ Usage:
14
+ python finetune.py --model microsoft/Phi-3-mini-4k-instruct --epochs 3
15
+ """
16
+
17
+ import json
18
+ import argparse
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from datetime import datetime
23
+ from typing import List, Dict, Optional, Tuple
24
+ import random
25
+
26
+ # Check for MLX (Apple Silicon)
27
+ try:
28
+ import mlx
29
+ import mlx.core as mx
30
+ HAS_MLX = True
31
+ except ImportError:
32
+ HAS_MLX = False
33
+
34
+ # Check for PyTorch
35
+ try:
36
+ import torch
37
+ HAS_TORCH = True
38
+ except ImportError:
39
+ HAS_TORCH = False
40
+
41
+
42
+ # ============================================================================
43
+ # DATA PREPARATION
44
+ # ============================================================================
45
+
46
+ class DataPreparer:
47
+ """Prepare training data for fine-tuning."""
48
+
49
+ SYSTEM_PROMPT = """You are a finance entity extraction assistant for Indian banking.
50
+ Extract structured information from banking SMS/email messages.
51
+
52
+ Output JSON with these fields (only include if found):
53
+ - amount: float (transaction amount)
54
+ - type: "debit" or "credit"
55
+ - account: string (last 4 digits)
56
+ - bank: string (bank name)
57
+ - date: string (transaction date)
58
+ - reference: string (UPI/NEFT reference)
59
+ - merchant: string (business name for P2M)
60
+ - beneficiary: string (person name for P2P)
61
+ - vpa: string (UPI ID)
62
+ - category: string (food, shopping, travel, etc.)
63
+ - is_p2m: boolean (true if merchant, false if person)
64
+
65
+ Be precise. Extract exactly what's in the message."""
66
+
67
+ def __init__(self, data_path: Path, val_split: float = 0.1):
68
+ self.data_path = data_path
69
+ self.val_split = val_split
70
+ self.train_data = []
71
+ self.val_data = []
72
+
73
+ def load_and_split(self) -> Tuple[List[Dict], List[Dict]]:
74
+ """Load data and split into train/val."""
75
+ print(f"Loading data from {self.data_path}...")
76
+
77
+ all_data = []
78
+ with open(self.data_path, 'r', encoding='utf-8') as f:
79
+ for line in f:
80
+ try:
81
+ record = json.loads(line)
82
+ all_data.append(record)
83
+ except json.JSONDecodeError:
84
+ continue
85
+
86
+ print(f" Loaded {len(all_data):,} records")
87
+
88
+ # Shuffle
89
+ random.shuffle(all_data)
90
+
91
+ # Split
92
+ split_idx = int(len(all_data) * (1 - self.val_split))
93
+ self.train_data = all_data[:split_idx]
94
+ self.val_data = all_data[split_idx:]
95
+
96
+ print(f" Train: {len(self.train_data):,}, Val: {len(self.val_data):,}")
97
+
98
+ return self.train_data, self.val_data
99
+
100
+ def format_for_chat(self, record: Dict) -> Dict:
101
+ """Format record for chat-style fine-tuning."""
102
+ input_text = record.get('input', record.get('text', ''))
103
+ output_text = record.get('output', '{}')
104
+
105
+ if isinstance(output_text, dict):
106
+ output_text = json.dumps(output_text, ensure_ascii=False)
107
+
108
+ return {
109
+ 'messages': [
110
+ {'role': 'system', 'content': self.SYSTEM_PROMPT},
111
+ {'role': 'user', 'content': input_text},
112
+ {'role': 'assistant', 'content': output_text},
113
+ ]
114
+ }
115
+
116
+ def format_for_completion(self, record: Dict) -> Dict:
117
+ """Format record for completion-style fine-tuning."""
118
+ input_text = record.get('input', record.get('text', ''))
119
+ output_text = record.get('output', '{}')
120
+
121
+ if isinstance(output_text, dict):
122
+ output_text = json.dumps(output_text, ensure_ascii=False)
123
+
124
+ prompt = f"""Extract financial entities from this message:
125
+
126
+ Message: {input_text}
127
+
128
+ JSON:"""
129
+
130
+ return {
131
+ 'prompt': prompt,
132
+ 'completion': output_text,
133
+ }
134
+
135
+ def save_formatted(
136
+ self,
137
+ output_dir: Path,
138
+ format_type: str = 'chat'
139
+ ) -> Tuple[Path, Path]:
140
+ """Save formatted train/val data."""
141
+ output_dir.mkdir(parents=True, exist_ok=True)
142
+
143
+ train_path = output_dir / 'train.jsonl'
144
+ val_path = output_dir / 'valid.jsonl'
145
+
146
+ formatter = (
147
+ self.format_for_chat if format_type == 'chat'
148
+ else self.format_for_completion
149
+ )
150
+
151
+ # Save train
152
+ with open(train_path, 'w', encoding='utf-8') as f:
153
+ for record in self.train_data:
154
+ formatted = formatter(record)
155
+ f.write(json.dumps(formatted, ensure_ascii=False) + '\n')
156
+
157
+ # Save val
158
+ with open(val_path, 'w', encoding='utf-8') as f:
159
+ for record in self.val_data:
160
+ formatted = formatter(record)
161
+ f.write(json.dumps(formatted, ensure_ascii=False) + '\n')
162
+
163
+ print(f" Saved train: {train_path}")
164
+ print(f" Saved valid: {val_path}")
165
+
166
+ return train_path, val_path
167
+
168
+
169
+ # ============================================================================
170
+ # MLX FINE-TUNING (Apple Silicon)
171
+ # ============================================================================
172
+
173
+ class MLXFineTuner:
174
+ """Fine-tune using MLX-LM on Apple Silicon."""
175
+
176
+ def __init__(
177
+ self,
178
+ model_name: str,
179
+ output_dir: Path,
180
+ lora_rank: int = 8,
181
+ lora_layers: int = 16,
182
+ ):
183
+ self.model_name = model_name
184
+ self.output_dir = output_dir
185
+ self.lora_rank = lora_rank
186
+ self.lora_layers = lora_layers
187
+
188
+ def train(
189
+ self,
190
+ train_path: Path,
191
+ val_path: Path,
192
+ epochs: int = 3,
193
+ batch_size: int = 4,
194
+ learning_rate: float = 1e-5,
195
+ save_every: int = 100,
196
+ ):
197
+ """Run MLX-LM LoRA fine-tuning."""
198
+ import subprocess
199
+
200
+ cmd = [
201
+ sys.executable, '-m', 'mlx_lm.lora',
202
+ '--model', self.model_name,
203
+ '--train',
204
+ '--data', str(train_path.parent),
205
+ '--lora-layers', str(self.lora_layers),
206
+ '--lora-rank', str(self.lora_rank),
207
+ '--batch-size', str(batch_size),
208
+ '--iters', str(epochs * 1000),
209
+ '--learning-rate', str(learning_rate),
210
+ '--save-every', str(save_every),
211
+ '--adapter-path', str(self.output_dir / 'adapters'),
212
+ ]
213
+
214
+ print(f"\n🚀 Starting MLX-LM LoRA training...")
215
+ print(f" Command: {' '.join(cmd)}")
216
+ print()
217
+
218
+ result = subprocess.run(cmd, capture_output=False)
219
+
220
+ return result.returncode == 0
221
+
222
+ def fuse(self):
223
+ """Fuse LoRA adapters with base model."""
224
+ import subprocess
225
+
226
+ adapter_path = self.output_dir / 'adapters'
227
+ fused_path = self.output_dir / 'fused'
228
+
229
+ cmd = [
230
+ sys.executable, '-m', 'mlx_lm.fuse',
231
+ '--model', self.model_name,
232
+ '--adapter-path', str(adapter_path),
233
+ '--save-path', str(fused_path),
234
+ ]
235
+
236
+ print(f"\n🔗 Fusing LoRA adapters...")
237
+ result = subprocess.run(cmd, capture_output=False)
238
+
239
+ return result.returncode == 0
240
+
241
+
242
+ # ============================================================================
243
+ # PYTORCH/TRANSFORMERS FINE-TUNING
244
+ # ============================================================================
245
+
246
+ class TransformersFineTuner:
247
+ """Fine-tune using PyTorch/Transformers."""
248
+
249
+ def __init__(
250
+ self,
251
+ model_name: str,
252
+ output_dir: Path,
253
+ lora_rank: int = 8,
254
+ ):
255
+ self.model_name = model_name
256
+ self.output_dir = output_dir
257
+ self.lora_rank = lora_rank
258
+
259
+ def train(
260
+ self,
261
+ train_path: Path,
262
+ val_path: Path,
263
+ epochs: int = 3,
264
+ batch_size: int = 4,
265
+ learning_rate: float = 2e-5,
266
+ ):
267
+ """Run Transformers fine-tuning with PEFT."""
268
+ try:
269
+ from transformers import (
270
+ AutoModelForCausalLM,
271
+ AutoTokenizer,
272
+ TrainingArguments,
273
+ Trainer,
274
+ DataCollatorForSeq2Seq,
275
+ )
276
+ from peft import LoraConfig, get_peft_model
277
+ from datasets import load_dataset
278
+ except ImportError as e:
279
+ print(f"❌ Missing dependencies: {e}")
280
+ print(" Run: pip install transformers peft datasets")
281
+ return False
282
+
283
+ print(f"\n🚀 Loading model: {self.model_name}")
284
+
285
+ # Load model & tokenizer
286
+ tokenizer = AutoTokenizer.from_pretrained(self.model_name)
287
+ model = AutoModelForCausalLM.from_pretrained(
288
+ self.model_name,
289
+ torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
290
+ device_map='auto' if torch.cuda.is_available() else None,
291
+ )
292
+
293
+ # Add padding token if needed
294
+ if tokenizer.pad_token is None:
295
+ tokenizer.pad_token = tokenizer.eos_token
296
+
297
+ # LoRA config
298
+ lora_config = LoraConfig(
299
+ r=self.lora_rank,
300
+ lora_alpha=32,
301
+ target_modules=['q_proj', 'v_proj', 'k_proj', 'o_proj'],
302
+ lora_dropout=0.05,
303
+ bias='none',
304
+ task_type='CAUSAL_LM',
305
+ )
306
+
307
+ model = get_peft_model(model, lora_config)
308
+ model.print_trainable_parameters()
309
+
310
+ # Load dataset
311
+ dataset = load_dataset(
312
+ 'json',
313
+ data_files={
314
+ 'train': str(train_path),
315
+ 'validation': str(val_path),
316
+ }
317
+ )
318
+
319
+ # Tokenize
320
+ def tokenize(examples):
321
+ # For chat format
322
+ if 'messages' in examples:
323
+ texts = []
324
+ for msgs in examples['messages']:
325
+ text = ''
326
+ for msg in msgs:
327
+ text += f"<|{msg['role']}|>\n{msg['content']}\n"
328
+ texts.append(text)
329
+ else:
330
+ texts = [f"{p}\n{c}" for p, c in zip(examples['prompt'], examples['completion'])]
331
+
332
+ return tokenizer(
333
+ texts,
334
+ truncation=True,
335
+ max_length=512,
336
+ padding='max_length',
337
+ )
338
+
339
+ tokenized = dataset.map(tokenize, batched=True, remove_columns=dataset['train'].column_names)
340
+
341
+ # Training args
342
+ training_args = TrainingArguments(
343
+ output_dir=str(self.output_dir),
344
+ num_train_epochs=epochs,
345
+ per_device_train_batch_size=batch_size,
346
+ per_device_eval_batch_size=batch_size,
347
+ learning_rate=learning_rate,
348
+ logging_steps=100,
349
+ save_steps=500,
350
+ evaluation_strategy='steps',
351
+ eval_steps=500,
352
+ fp16=torch.cuda.is_available(),
353
+ report_to='none',
354
+ )
355
+
356
+ # Trainer
357
+ trainer = Trainer(
358
+ model=model,
359
+ args=training_args,
360
+ train_dataset=tokenized['train'],
361
+ eval_dataset=tokenized['validation'],
362
+ data_collator=DataCollatorForSeq2Seq(tokenizer, padding=True),
363
+ )
364
+
365
+ print(f"\n🚀 Starting training...")
366
+ trainer.train()
367
+
368
+ # Save
369
+ model.save_pretrained(self.output_dir / 'adapters')
370
+ tokenizer.save_pretrained(self.output_dir / 'adapters')
371
+
372
+ print(f"\n✅ Saved to: {self.output_dir / 'adapters'}")
373
+ return True
374
+
375
+
376
+ # ============================================================================
377
+ # EVALUATION
378
+ # ============================================================================
379
+
380
+ class Evaluator:
381
+ """Evaluate fine-tuned model."""
382
+
383
+ def __init__(self, model_path: Path, backend: str = 'mlx'):
384
+ self.model_path = model_path
385
+ self.backend = backend
386
+
387
+ def evaluate(self, test_data: List[Dict], max_samples: int = 100) -> Dict:
388
+ """Evaluate on test data."""
389
+ if self.backend == 'mlx':
390
+ return self._evaluate_mlx(test_data[:max_samples])
391
+ else:
392
+ return self._evaluate_torch(test_data[:max_samples])
393
+
394
+ def _evaluate_mlx(self, test_data: List[Dict]) -> Dict:
395
+ """Evaluate with MLX."""
396
+ from mlx_lm import load, generate
397
+
398
+ model, tokenizer = load(str(self.model_path))
399
+
400
+ correct = 0
401
+ total = 0
402
+ field_matches = {'amount': 0, 'type': 0, 'merchant': 0}
403
+
404
+ for record in test_data:
405
+ input_text = record.get('input', record.get('text', ''))
406
+ expected = record.get('output', '{}')
407
+ if isinstance(expected, str):
408
+ expected = json.loads(expected)
409
+
410
+ prompt = f"Extract financial entities:\n\n{input_text}\n\nJSON:"
411
+
412
+ output = generate(
413
+ model, tokenizer, prompt,
414
+ max_tokens=256,
415
+ temp=0.0,
416
+ )
417
+
418
+ try:
419
+ predicted = json.loads(output)
420
+
421
+ # Check fields
422
+ for field in field_matches:
423
+ if predicted.get(field) == expected.get(field):
424
+ field_matches[field] += 1
425
+
426
+ # Full match
427
+ if predicted == expected:
428
+ correct += 1
429
+ except json.JSONDecodeError:
430
+ pass
431
+
432
+ total += 1
433
+
434
+ return {
435
+ 'accuracy': correct / total if total > 0 else 0,
436
+ 'field_accuracy': {k: v/total for k, v in field_matches.items()},
437
+ 'total_samples': total,
438
+ }
439
+
440
+ def _evaluate_torch(self, test_data: List[Dict]) -> Dict:
441
+ """Evaluate with PyTorch."""
442
+ from transformers import AutoModelForCausalLM, AutoTokenizer
443
+ from peft import PeftModel
444
+
445
+ # Load
446
+ base_model = AutoModelForCausalLM.from_pretrained(
447
+ self.model_path.parent / 'base',
448
+ torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
449
+ )
450
+ model = PeftModel.from_pretrained(base_model, str(self.model_path))
451
+ tokenizer = AutoTokenizer.from_pretrained(str(self.model_path))
452
+
453
+ # Similar evaluation logic...
454
+ return {'accuracy': 0, 'note': 'PyTorch evaluation not fully implemented'}
455
+
456
+
457
+ # ============================================================================
458
+ # MAIN PIPELINE
459
+ # ============================================================================
460
+
461
+ def main():
462
+ parser = argparse.ArgumentParser(description="LLM Fine-tuning Pipeline")
463
+ parser.add_argument('--data', default='data/training/final_combined_training.jsonl',
464
+ help='Training data path')
465
+ parser.add_argument('--model', default='microsoft/Phi-3-mini-4k-instruct',
466
+ help='Base model')
467
+ parser.add_argument('--output', default='models/finetune',
468
+ help='Output directory')
469
+ parser.add_argument('--epochs', type=int, default=3, help='Training epochs')
470
+ parser.add_argument('--batch-size', type=int, default=4, help='Batch size')
471
+ parser.add_argument('--lr', type=float, default=1e-5, help='Learning rate')
472
+ parser.add_argument('--lora-rank', type=int, default=8, help='LoRA rank')
473
+ parser.add_argument('--backend', choices=['mlx', 'torch', 'auto'], default='auto',
474
+ help='Training backend')
475
+ parser.add_argument('--skip-train', action='store_true', help='Skip training, just prepare data')
476
+ parser.add_argument('--evaluate', action='store_true', help='Evaluate after training')
477
+
478
+ args = parser.parse_args()
479
+
480
+ # Determine backend
481
+ if args.backend == 'auto':
482
+ if HAS_MLX:
483
+ backend = 'mlx'
484
+ print("🍎 Using MLX (Apple Silicon)")
485
+ elif HAS_TORCH:
486
+ backend = 'torch'
487
+ print("🔥 Using PyTorch/Transformers")
488
+ else:
489
+ print("❌ No backend available. Install mlx-lm or transformers+peft")
490
+ return
491
+ else:
492
+ backend = args.backend
493
+
494
+ data_path = Path(args.data)
495
+ output_dir = Path(args.output)
496
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
497
+ run_dir = output_dir / f"run_{timestamp}"
498
+
499
+ print("=" * 60)
500
+ print("🚀 LLM FINE-TUNING PIPELINE")
501
+ print("=" * 60)
502
+ print(f" Data: {data_path}")
503
+ print(f" Model: {args.model}")
504
+ print(f" Output: {run_dir}")
505
+ print(f" Backend: {backend}")
506
+ print(f" Epochs: {args.epochs}")
507
+
508
+ # Step 1: Prepare data
509
+ print("\n📋 Step 1: Preparing data...")
510
+ preparer = DataPreparer(data_path)
511
+ train_data, val_data = preparer.load_and_split()
512
+
513
+ formatted_dir = run_dir / 'data'
514
+ format_type = 'chat' if backend == 'torch' else 'completion'
515
+ train_path, val_path = preparer.save_formatted(formatted_dir, format_type)
516
+
517
+ if args.skip_train:
518
+ print("\n⏭️ Skipping training (--skip-train)")
519
+ return
520
+
521
+ # Step 2: Train
522
+ print("\n🎯 Step 2: Training...")
523
+
524
+ if backend == 'mlx':
525
+ trainer = MLXFineTuner(
526
+ model_name=args.model,
527
+ output_dir=run_dir,
528
+ lora_rank=args.lora_rank,
529
+ )
530
+ success = trainer.train(
531
+ train_path, val_path,
532
+ epochs=args.epochs,
533
+ batch_size=args.batch_size,
534
+ learning_rate=args.lr,
535
+ )
536
+
537
+ if success:
538
+ print("\n🔗 Step 3: Fusing adapters...")
539
+ trainer.fuse()
540
+ else:
541
+ trainer = TransformersFineTuner(
542
+ model_name=args.model,
543
+ output_dir=run_dir,
544
+ lora_rank=args.lora_rank,
545
+ )
546
+ success = trainer.train(
547
+ train_path, val_path,
548
+ epochs=args.epochs,
549
+ batch_size=args.batch_size,
550
+ learning_rate=args.lr,
551
+ )
552
+
553
+ # Step 3: Evaluate
554
+ if args.evaluate and success:
555
+ print("\n📊 Step 4: Evaluating...")
556
+ evaluator = Evaluator(run_dir / 'fused' if backend == 'mlx' else run_dir / 'adapters', backend)
557
+ results = evaluator.evaluate(val_data)
558
+
559
+ print(f"\n📊 Results:")
560
+ print(f" Overall Accuracy: {results.get('accuracy', 0):.1%}")
561
+ for field, acc in results.get('field_accuracy', {}).items():
562
+ print(f" {field}: {acc:.1%}")
563
+
564
+ # Save results
565
+ with open(run_dir / 'eval_results.json', 'w') as f:
566
+ json.dump(results, f, indent=2)
567
+
568
+ print("\n" + "=" * 60)
569
+ print("✅ FINE-TUNING COMPLETE")
570
+ print("=" * 60)
571
+ print(f" Output: {run_dir}")
572
+ print(f" Adapters: {run_dir / 'adapters'}")
573
+ if backend == 'mlx':
574
+ print(f" Fused model: {run_dir / 'fused'}")
575
+
576
+
577
+ if __name__ == "__main__":
578
+ main()