indic-itn 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
indic_itn/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Inverse Text Normalization (ITN) for Indic languages."""
2
+
3
+ from indic_itn.main import HindiITN
4
+ from indic_itn.tokenizer import HindiTokenizer
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["HindiITN", "HindiTokenizer"]
indic_itn/base.py ADDED
@@ -0,0 +1,105 @@
1
+ """Core domain classes and base interfaces for the ITN pipeline."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Token:
10
+ """A single token within the input text.
11
+
12
+ Attributes:
13
+ text: The text content of the token.
14
+ start_idx: The starting character index (inclusive) in the source text.
15
+ end_idx: The ending character index (exclusive) in the source text.
16
+ """
17
+
18
+ text: str
19
+ start_idx: int
20
+ end_idx: int
21
+
22
+ @property
23
+ def value(self) -> str:
24
+ """Alias for text."""
25
+ return self.text
26
+
27
+ @property
28
+ def start_offset(self) -> int:
29
+ """Alias for start_idx."""
30
+ return self.start_idx
31
+
32
+ @property
33
+ def end_offset(self) -> int:
34
+ """Alias for end_idx."""
35
+ return self.end_idx
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Entity:
40
+ """A detected entity that requires normalization.
41
+
42
+ Attributes:
43
+ text: The raw spoken text representing the entity.
44
+ entity_type: The type of entity (e.g., "number", "date", "currency").
45
+ start_idx: The starting character index (inclusive) in the source text.
46
+ end_idx: The ending character index (exclusive) in the source text.
47
+ metadata: Optional dictionary containing additional structured details
48
+ about the detected entity.
49
+ """
50
+
51
+ text: str
52
+ entity_type: str
53
+ start_idx: int
54
+ end_idx: int
55
+ metadata: dict[str, Any] = field(default_factory=dict, hash=False, compare=False)
56
+
57
+
58
+ class BaseTokenizer(ABC):
59
+ """Abstract base class for all tokenizers in the ITN pipeline."""
60
+
61
+ @abstractmethod
62
+ def tokenize(self, text: str) -> list[Token]:
63
+ """Split input text into a list of Token objects.
64
+
65
+ Args:
66
+ text: The raw input text.
67
+
68
+ Returns:
69
+ A list of Token objects with their corresponding indices.
70
+ """
71
+ pass
72
+
73
+
74
+ class BaseEntityDetector(ABC):
75
+ """Abstract base class for detecting normalizeable entities in tokenized text."""
76
+
77
+ @abstractmethod
78
+ def detect(self, tokens: list[Token]) -> list[Entity]:
79
+ """Detect entities from the list of tokens.
80
+
81
+ Args:
82
+ tokens: A list of input Token objects.
83
+
84
+ Returns:
85
+ A list of detected Entity objects.
86
+ """
87
+ pass
88
+
89
+
90
+ class BaseRewriter(ABC):
91
+ """Abstract base class for rewriting the input text with normalized values."""
92
+
93
+ @abstractmethod
94
+ def rewrite(self, text: str, entity_rewrites: dict[Entity, str]) -> str:
95
+ """Replace raw entities in the input text with their normalized string values.
96
+
97
+ Args:
98
+ text: The original input text.
99
+ entity_rewrites: A dictionary mapping each detected Entity to its
100
+ normalized string representation.
101
+
102
+ Returns:
103
+ The final normalized text.
104
+ """
105
+ pass
indic_itn/detector.py ADDED
@@ -0,0 +1,447 @@
1
+ """Entity detector implementation for Hindi ITN."""
2
+
3
+ import re
4
+
5
+ from indic_itn.base import BaseEntityDetector, Entity, Token
6
+ from indic_itn.loader import load_hindi_resource
7
+
8
+
9
+ class HindiEntityDetector(BaseEntityDetector):
10
+ """Devanagari-aware entity detector for Hindi ITN.
11
+
12
+ Identifies candidate spans for numbers, phone numbers, OTP, currency,
13
+ dates, and time from a list of tokens.
14
+ """
15
+
16
+ def __init__(self) -> None:
17
+ """Initializes HindiEntityDetector by loading language resources."""
18
+ # Load resources
19
+ numbers_res = load_hindi_resource("numbers")
20
+ temporal_res = load_hindi_resource("temporal")
21
+ ordinals_res = load_hindi_resource("ordinals")
22
+
23
+ # Classify vocabulary sets
24
+ self._digits = set(numbers_res["digits"].keys())
25
+ self._numbers = (
26
+ set(numbers_res["numbers"].keys())
27
+ | set(ordinals_res["ordinals"].keys())
28
+ | set(numbers_res["tens"].keys())
29
+ | set(numbers_res["hundreds"].keys())
30
+ | set(numbers_res["multipliers"].keys())
31
+ | set(numbers_res["lakh"].keys())
32
+ | set(numbers_res["crore"].keys())
33
+ )
34
+
35
+ self._months = set(temporal_res["months"].keys())
36
+ self._weekdays = set(temporal_res["weekdays"].keys())
37
+
38
+ # Define keyword sets
39
+ self._currency_words = {
40
+ "रुपया",
41
+ "रुपये",
42
+ "रुपए",
43
+ "रु",
44
+ "पैसे",
45
+ "पैसा",
46
+ "रू",
47
+ "rupees",
48
+ "rupee",
49
+ "rs",
50
+ "paise",
51
+ "paisa",
52
+ }
53
+ self._time_words = {
54
+ "बजे",
55
+ "बजकर",
56
+ "मिनट",
57
+ "सेकंड",
58
+ "सेकण्ड",
59
+ "घंटे",
60
+ "घण्टे",
61
+ "घंटा",
62
+ "घण्टा",
63
+ "सुबह",
64
+ "शाम",
65
+ "दोपहर",
66
+ "रात",
67
+ "am",
68
+ "pm",
69
+ }
70
+ self._time_modifiers = {
71
+ "साढ़े",
72
+ "साढ़े",
73
+ "सवा",
74
+ "पौने",
75
+ "डेढ़",
76
+ "ढाई",
77
+ "डेढ़",
78
+ }
79
+
80
+ self._decimal_indicators = {
81
+ "दशमलव",
82
+ "दशमलब",
83
+ "पॉइंट",
84
+ "पॉइन्ट",
85
+ "point",
86
+ "decimal",
87
+ }
88
+
89
+ self._percentage_indicators = {
90
+ "प्रतिशत",
91
+ "फीसदी",
92
+ "फीसदि",
93
+ "percent",
94
+ "percentage",
95
+ }
96
+
97
+ # Numeric regex patterns
98
+ self._numeric_date_pattern = re.compile(r"^\d{1,4}[-/.]\d{1,2}[-/.]\d{2,4}$")
99
+ self._numeric_time_pattern = re.compile(
100
+ r"^\d{1,2}:\d{2}(?::\d{2})?\s*(?:am|pm|AM|PM)?$"
101
+ )
102
+
103
+ def _get_period_len(self, tokens: list[Token], idx: int) -> int:
104
+ """Get the length of the period indicator starting at idx.
105
+
106
+ Returns length in tokens (0 if not a period).
107
+ """
108
+ if idx >= len(tokens):
109
+ return 0
110
+ t1 = tokens[idx].text.lower()
111
+ if t1 in {"am", "pm", "पीएम", "एएम"}:
112
+ return 1
113
+ if idx + 1 < len(tokens):
114
+ t2 = tokens[idx + 1].text.lower()
115
+ if (t1 == "पी" and t2 == "एम") or (t1 == "ए" and t2 == "एम"):
116
+ return 2
117
+ if idx + 3 < len(tokens):
118
+ t2 = tokens[idx + 1].text
119
+ t3 = tokens[idx + 2].text.lower()
120
+ t4 = tokens[idx + 3].text
121
+ if (t1 == "पी" and t2 == "." and t3 == "एम" and t4 == ".") or (
122
+ t1 == "ए" and t2 == "." and t3 == "एम" and t4 == "."
123
+ ):
124
+ return 4
125
+ return 0
126
+
127
+ def _is_num(self, token: Token) -> bool:
128
+ """Check if a token represents a digit or a number word.
129
+
130
+ Args:
131
+ token: The Token object.
132
+
133
+ Returns:
134
+ True if the token is a number/digit word or numeric digits string.
135
+ """
136
+ text = token.text
137
+ return (
138
+ text.isdigit()
139
+ or text in self._numbers
140
+ or text in self._digits
141
+ or text.replace(".", "", 1).isdigit() # handles decimals
142
+ )
143
+
144
+ def _get_num_sequence_len(self, tokens: list[Token], start_idx: int) -> int:
145
+ """Get the length of a contiguous sequence of number tokens.
146
+
147
+ Args:
148
+ tokens: The list of tokens.
149
+ start_idx: The index to start scanning.
150
+
151
+ Returns:
152
+ The number of consecutive number tokens.
153
+ """
154
+ length = 0
155
+ while start_idx + length < len(tokens):
156
+ if self._is_num(tokens[start_idx + length]):
157
+ length += 1
158
+ else:
159
+ break
160
+ return length
161
+
162
+ def detect(self, tokens: list[Token]) -> list[Entity]:
163
+ """Detect entities from the list of tokens.
164
+
165
+ Args:
166
+ tokens: A list of input Token objects.
167
+
168
+ Returns:
169
+ A list of detected Entity objects.
170
+ """
171
+ entities: list[Entity] = []
172
+ n = len(tokens)
173
+ i = 0
174
+
175
+ # Helper to construct an Entity from a slice of tokens
176
+ def make_entity(start_pos: int, end_pos: int, entity_type: str) -> Entity:
177
+ span_tokens = tokens[start_pos:end_pos]
178
+ start_offset = tokens[start_pos].start_idx
179
+ end_offset = tokens[end_pos - 1].end_idx
180
+
181
+ # Reconstruct the original substring using character bounds
182
+ parts = []
183
+ for k, t in enumerate(span_tokens):
184
+ parts.append(t.text)
185
+ if k < len(span_tokens) - 1:
186
+ gap = span_tokens[k + 1].start_idx - t.end_idx
187
+ if gap > 0:
188
+ parts.append(" " * gap)
189
+ reconstructed_text = "".join(parts)
190
+
191
+ return Entity(
192
+ text=reconstructed_text,
193
+ entity_type=entity_type,
194
+ start_idx=start_offset,
195
+ end_idx=end_offset,
196
+ )
197
+
198
+ while i < n:
199
+ token = tokens[i]
200
+
201
+ # 1. Numeric Date check (e.g. 15-08-1947 or 15/08/47)
202
+ if (
203
+ i + 4 < n
204
+ and tokens[i].text.isdigit()
205
+ and tokens[i + 1].text in ("-", "/", ".")
206
+ and tokens[i + 2].text.isdigit()
207
+ and tokens[i + 3].text in ("-", "/", ".")
208
+ and tokens[i + 4].text.isdigit()
209
+ ):
210
+ joined_date = "".join([t.text for t in tokens[i : i + 5]])
211
+ if self._numeric_date_pattern.match(joined_date):
212
+ entities.append(make_entity(i, i + 5, "date"))
213
+ i += 5
214
+ continue
215
+
216
+ # 2. Standalone Weekday
217
+ if token.text in self._weekdays:
218
+ entities.append(make_entity(i, i + 1, "date"))
219
+ i += 1
220
+ continue
221
+
222
+ # 3. Numeric Time check (e.g. 10:30 or 10:30 pm)
223
+ if (
224
+ i + 2 < n
225
+ and tokens[i].text.isdigit()
226
+ and tokens[i + 1].text == ":"
227
+ and tokens[i + 2].text.isdigit()
228
+ ):
229
+ joined_time = "".join([t.text for t in tokens[i : i + 3]])
230
+ if i + 3 < n and tokens[i + 3].text.lower() in ("am", "pm"):
231
+ joined_time_am_pm = f"{joined_time} {tokens[i + 3].text.lower()}"
232
+ if self._numeric_time_pattern.match(joined_time_am_pm):
233
+ entities.append(make_entity(i, i + 4, "time"))
234
+ i += 4
235
+ continue
236
+ if self._numeric_time_pattern.match(joined_time):
237
+ entities.append(make_entity(i, i + 3, "time"))
238
+ i += 3
239
+ continue
240
+
241
+ # Retrieve number sequences
242
+ num_len = self._get_num_sequence_len(tokens, i)
243
+
244
+ # Check for patterns starting with a number sequence
245
+ if num_len > 0:
246
+ next_idx = i + num_len
247
+
248
+ # A. Currency: [NumSequence] [Currency] [NumSequence] [Currency]
249
+ if (
250
+ next_idx + 2 < n
251
+ and tokens[next_idx].text.lower() in self._currency_words
252
+ and self._is_num(tokens[next_idx + 1])
253
+ ):
254
+ sub_num_len = self._get_num_sequence_len(tokens, next_idx + 1)
255
+ curr2_idx = next_idx + 1 + sub_num_len
256
+ if (
257
+ curr2_idx < n
258
+ and tokens[curr2_idx].text.lower() in self._currency_words
259
+ ):
260
+ entities.append(make_entity(i, curr2_idx + 1, "currency"))
261
+ i = curr2_idx + 1
262
+ continue
263
+
264
+ # B. Currency: [NumSequence] [Currency]
265
+ if (
266
+ next_idx < n
267
+ and tokens[next_idx].text.lower() in self._currency_words
268
+ ):
269
+ entities.append(make_entity(i, next_idx + 1, "currency"))
270
+ i = next_idx + 1
271
+ continue
272
+
273
+ # C. Time: [NumSequence] बजकर [NumSequence] मिनट
274
+ if (
275
+ next_idx + 2 < n
276
+ and tokens[next_idx].text == "बजकर"
277
+ and self._is_num(tokens[next_idx + 1])
278
+ ):
279
+ sub_num_len = self._get_num_sequence_len(tokens, next_idx + 1)
280
+ min_idx = next_idx + 1 + sub_num_len
281
+ if min_idx < n and tokens[min_idx].text == "मिनट":
282
+ entities.append(make_entity(i, min_idx + 1, "time"))
283
+ i = min_idx + 1
284
+ continue
285
+
286
+ # D. Time: [NumSequence] [TimeWord]
287
+ if next_idx < n and tokens[next_idx].text.lower() in self._time_words:
288
+ entities.append(make_entity(i, next_idx + 1, "time"))
289
+ i = next_idx + 1
290
+ continue
291
+
292
+ # E. Date: [NumSequence] [Month] [NumSequence]
293
+ if (
294
+ next_idx + 1 < n
295
+ and tokens[next_idx].text in self._months
296
+ and self._is_num(tokens[next_idx + 1])
297
+ ):
298
+ sub_num_len = self._get_num_sequence_len(tokens, next_idx + 1)
299
+ entities.append(make_entity(i, next_idx + 1 + sub_num_len, "date"))
300
+ i = next_idx + 1 + sub_num_len
301
+ continue
302
+
303
+ # F. Date: [NumSequence] [Month]
304
+ if next_idx < n and tokens[next_idx].text in self._months:
305
+ entities.append(make_entity(i, next_idx + 1, "date"))
306
+ i = next_idx + 1
307
+ continue
308
+
309
+ # G. Decimal: [NumSequence] [DecimalIndicator] [NumSequence]
310
+ if (
311
+ next_idx + 1 < n
312
+ and tokens[next_idx].text.lower() in self._decimal_indicators
313
+ and self._is_num(tokens[next_idx + 1])
314
+ ):
315
+ sub_num_len = self._get_num_sequence_len(tokens, next_idx + 1)
316
+ entities.append(
317
+ make_entity(i, next_idx + 1 + sub_num_len, "decimal")
318
+ )
319
+ i = next_idx + 1 + sub_num_len
320
+ continue
321
+
322
+ # H. Percentage: [NumSequence] [PercentageIndicator]
323
+ if (
324
+ next_idx < n
325
+ and tokens[next_idx].text.lower() in self._percentage_indicators
326
+ ):
327
+ entities.append(make_entity(i, next_idx + 1, "percentage"))
328
+ i = next_idx + 1
329
+ continue
330
+
331
+ # I. Time: [NumSequence] [Period] (e.g. पाँच बीस पी एम, पाँच पी एम)
332
+ period_len = self._get_period_len(tokens, next_idx)
333
+ if period_len > 0:
334
+ entities.append(make_entity(i, next_idx + period_len, "time"))
335
+ i = next_idx + period_len
336
+ continue
337
+
338
+ # Check for patterns starting with a modifier or keyword
339
+ if token.text in self._time_modifiers:
340
+ # Case A: [Modifier] [NumSequence] [TimeWord] (e.g., साढ़े पांच बजे)
341
+ if i + 1 < n and self._is_num(tokens[i + 1]):
342
+ sub_num_len = self._get_num_sequence_len(tokens, i + 1)
343
+ t_idx = i + 1 + sub_num_len
344
+ if t_idx < n and tokens[t_idx].text.lower() in self._time_words:
345
+ entities.append(make_entity(i, t_idx + 1, "time"))
346
+ i = t_idx + 1
347
+ continue
348
+
349
+ # Case B: [Modifier] [TimeWord] (e.g., डेढ़ बजे, ढाई बजे)
350
+ if i + 1 < n and tokens[i + 1].text.lower() in self._time_words:
351
+ entities.append(make_entity(i, i + 2, "time"))
352
+ i = i + 2
353
+ continue
354
+
355
+ # Case C: [Modifier] [NumSequence] only (e.g., साढ़े पांच, सवा पांच, पौने दो)
356
+ if (
357
+ token.text in {"साढ़े", "साढ़े", "सवा", "पौने"}
358
+ and i + 1 < n
359
+ and self._is_num(tokens[i + 1])
360
+ ):
361
+ sub_num_len = self._get_num_sequence_len(tokens, i + 1)
362
+ entities.append(make_entity(i, i + 1 + sub_num_len, "time"))
363
+ i = i + 1 + sub_num_len
364
+ continue
365
+
366
+ # H. Date: [Month] [NumSequence]
367
+ if token.text in self._months and i + 1 < n and self._is_num(tokens[i + 1]):
368
+ sub_num_len = self._get_num_sequence_len(tokens, i + 1)
369
+ entities.append(make_entity(i, i + 1 + sub_num_len, "date"))
370
+ i = i + 1 + sub_num_len
371
+ continue
372
+
373
+ # 4. Fallback: process plain number sequences
374
+ if num_len > 0:
375
+ span_tokens = tokens[i : i + num_len]
376
+ # Determine classification based on digit-like structure
377
+ digit_count = 0
378
+ all_single_digits = True
379
+
380
+ for t in span_tokens:
381
+ if t.text.isdigit():
382
+ digit_count += len(t.text)
383
+ if len(t.text) > 1:
384
+ all_single_digits = False
385
+ elif t.text in self._digits:
386
+ digit_count += 1
387
+ else:
388
+ digit_count += 1
389
+ all_single_digits = False
390
+
391
+ # Check for phone context
392
+ has_phone_context = False
393
+ for check_idx in (i - 1, i - 2):
394
+ if check_idx >= 0:
395
+ context_word = tokens[check_idx].text.lower()
396
+ if context_word in {
397
+ "फोन",
398
+ "मोबाइल",
399
+ "नंबर",
400
+ "नम्बर",
401
+ "सम्पर्क",
402
+ "संपर्क",
403
+ "phone",
404
+ "mobile",
405
+ "number",
406
+ "contact",
407
+ }:
408
+ has_phone_context = True
409
+ break
410
+
411
+ # Check for OTP context
412
+ has_otp_context = False
413
+ for check_idx in (i - 1, i - 2):
414
+ if check_idx >= 0:
415
+ word = tokens[check_idx].text.lower()
416
+ if word in {"otp", "ओटीपी", "pin", "पिन"}:
417
+ has_otp_context = True
418
+ break
419
+ if not has_otp_context and i - 2 >= 0:
420
+ phrase = (
421
+ f"{tokens[i - 2].text.lower()} {tokens[i - 1].text.lower()}"
422
+ )
423
+ if phrase in {
424
+ "verification code",
425
+ "security code",
426
+ "वेरिफिकेशन कोड",
427
+ "सिक्योरिटी कोड",
428
+ }:
429
+ has_otp_context = True
430
+
431
+ # Apply length classification heuristic
432
+ if digit_count == 10 or (has_phone_context and 8 <= digit_count <= 13):
433
+ entities.append(make_entity(i, i + num_len, "phone"))
434
+ elif (4 <= digit_count <= 8 and all_single_digits) or (
435
+ has_otp_context and 4 <= digit_count <= 8
436
+ ):
437
+ entities.append(make_entity(i, i + num_len, "otp"))
438
+ else:
439
+ entities.append(make_entity(i, i + num_len, "number"))
440
+
441
+ i += num_len
442
+ continue
443
+
444
+ # If no pattern matches, move to the next token
445
+ i += 1
446
+
447
+ return entities
indic_itn/loader.py ADDED
@@ -0,0 +1,21 @@
1
+ """Utility module to load packages resources."""
2
+
3
+ import json
4
+ from importlib.resources import files
5
+ from typing import Any
6
+
7
+
8
+ def load_hindi_resource(name: str) -> dict[str, Any]:
9
+ """Load a Devanagari Hindi JSON resource dictionary.
10
+
11
+ Args:
12
+ name: The filename of the resource without extension (e.g., "numbers",
13
+ "temporal", "ordinals").
14
+
15
+ Returns:
16
+ A dictionary containing the parsed resource content.
17
+ """
18
+ resource_path = files("indic_itn").joinpath("resources", "hi", f"{name}.json")
19
+ with resource_path.open(encoding="utf-8") as f:
20
+ data: dict[str, Any] = json.load(f)
21
+ return data