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 +7 -0
- indic_itn/base.py +105 -0
- indic_itn/detector.py +447 -0
- indic_itn/loader.py +21 -0
- indic_itn/main.py +138 -0
- indic_itn/parsers/__init__.py +23 -0
- indic_itn/parsers/base.py +31 -0
- indic_itn/parsers/currency.py +105 -0
- indic_itn/parsers/date.py +78 -0
- indic_itn/parsers/decimal.py +103 -0
- indic_itn/parsers/number.py +96 -0
- indic_itn/parsers/otp.py +72 -0
- indic_itn/parsers/percentage.py +60 -0
- indic_itn/parsers/phone.py +89 -0
- indic_itn/parsers/time.py +263 -0
- indic_itn/rewriter.py +52 -0
- indic_itn/tokenizer.py +42 -0
- indic_itn-0.1.0.dist-info/METADATA +104 -0
- indic_itn-0.1.0.dist-info/RECORD +22 -0
- indic_itn-0.1.0.dist-info/WHEEL +5 -0
- indic_itn-0.1.0.dist-info/licenses/LICENSE +21 -0
- indic_itn-0.1.0.dist-info/top_level.txt +1 -0
indic_itn/__init__.py
ADDED
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
|