SoyVitou commited on
Commit
7c112cb
Β·
verified Β·
1 Parent(s): 5cc0616

Create inverse-text.py

Browse files
Files changed (1) hide show
  1. libs/inverse-text.py +499 -0
libs/inverse-text.py ADDED
@@ -0,0 +1,499 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ class KhmerInverseText:
7
+ # -----------------------
8
+ # Khmer Digit Words
9
+ # -----------------------
10
+ DIGIT_MAP = {
11
+ "αžŸαžΌαž“αŸ’αž™": 0,
12
+ "αž˜αž½αž™": 1,
13
+ "αž˜αŸ‰αžΆ": 1,
14
+ "αž–αžΈ": 2, # common ASR omission of trailing រ
15
+ "αž–αžΈαžš": 2,
16
+ "αž”αžΈ": 3,
17
+ "αž”αž½αž“": 4,
18
+ "αž”αŸ’αžšαžΆαŸ†": 5,
19
+ "αž”αŸ’αžšαžΆαŸ†αž˜αž½αž™": 6,
20
+ "αž”αŸ’αžšαžΆαŸ†αž–αžΈαžš": 7,
21
+ "αž”αŸ’αžšαžΆαŸ†αž”αžΈ": 8,
22
+ "αž”αŸ’αžšαžΆαŸ†αž”αž½αž“": 9,
23
+ "αž αž»αž€": 6,
24
+ "αž˜αŸ’αž—αŸƒ": 20,
25
+ }
26
+
27
+ # -----------------------
28
+ # Khmer Units
29
+ # -----------------------
30
+ UNIT_MAP = {
31
+ "αžŠαž”αŸ‹": 10,
32
+ "αžŸαž·αž”": 10,
33
+ "αžšαž™": 100,
34
+ "αž–αžΆαž“αŸ‹": 1000,
35
+ "αž˜αŸ‰αžΊαž“": 10000,
36
+ "αžŸαŸ‚αž“": 100000,
37
+ "αž›αžΆαž“": 1000000,
38
+ }
39
+
40
+ # Prefix tens
41
+ # Example:
42
+ # αžŸαžΆαž˜αžŸαž·αž” = 30
43
+ # αžŸαŸ‚αžŸαž·αž” = 40
44
+ # αž αžΆαžŸαž·αž” = 50
45
+ TENS_PREFIX_MAP = {
46
+ "សអម": 30,
47
+ "αžŸαŸ‚": 40,
48
+ "ហអ": 50,
49
+ "αž αž»αž€": 60,
50
+ "αž…αž·αž": 70,
51
+ "αž”αŸ‰αŸ‚αž": 80,
52
+ "αž€αŸ…": 90,
53
+ }
54
+
55
+ NUMBER_TOKENS = sorted(
56
+ set(DIGIT_MAP) | set(TENS_PREFIX_MAP) | {"αžŠαž”αŸ‹", "αžŸαž·αž”"} | set(UNIT_MAP),
57
+ key=len,
58
+ reverse=True,
59
+ )
60
+
61
+ KHMER_CHAR_PATTERN = r"\u1780-\u17FF"
62
+
63
+ def normalize_text(self, text: str) -> str:
64
+ """
65
+ Normalize mixed English + Khmer text.
66
+
67
+ Important:
68
+ - Keep English spaces.
69
+ - Remove spaces only between Khmer characters.
70
+ - Fix truncated dollar word: αžŠαž»αž›αŸ’αž›αžΆ -> αžŠαž»αž›αŸ’αž›αžΆαžš
71
+ """
72
+ if text is None:
73
+ return ""
74
+
75
+ text = str(text).strip()
76
+
77
+ # Normalize repeated whitespace to one space first.
78
+ # Example: "How to pay" -> "How to pay"
79
+ text = re.sub(r"\s+", " ", text)
80
+
81
+ # Remove spaces only when both sides are Khmer characters.
82
+ # Example:
83
+ # "αžŠαžΎαž˜αŸ’αž”αžΈ αž‡αŸ€αžŸαžœαžΆαž„ αž€αžΆαžšαž•αžΆαž€αž–αž·αž“αŸαž™"
84
+ # -> "αžŠαžΎαž˜αŸ’αž”αžΈαž‡αŸ€αžŸαžœαžΆαž„αž€αžΆαžšαž•αžΆαž€αž–αž·αž“αŸαž™"
85
+ #
86
+ # But keep:
87
+ # "How to pay bills αžŠαžΎαž˜αŸ’αž”αžΈ"
88
+ # -> "How to pay bills αžŠαžΎαž˜αŸ’αž”αžΈ"
89
+ text = re.sub(
90
+ rf"(?<=[{self.KHMER_CHAR_PATTERN}])\s+(?=[{self.KHMER_CHAR_PATTERN}])",
91
+ "",
92
+ text,
93
+ )
94
+
95
+ # Only fix the truncated dollar form.
96
+ # Avoid changing already-correct "αžŠαž»αž›αŸ’αž›αžΆαžš" into "αžŠαž»αž›αŸ’αž›αžΆαžšαžš".
97
+ text = re.sub(r"αžŠαž»αž›αŸ’αž›αžΆ(?!រ)", "αžŠαž»αž›αŸ’αž›αžΆαžš", text)
98
+
99
+ return text
100
+
101
+ def extract_number_phrase(
102
+ self,
103
+ clean_text: str,
104
+ end_idx: int,
105
+ limit_start: int = 0,
106
+ ) -> tuple[str, int]:
107
+ """
108
+ Walk backwards from end_idx and grab the contiguous Khmer number words.
109
+ Returns:
110
+ phrase, start_index_of_phrase
111
+ """
112
+ tokens = []
113
+ cursor = end_idx
114
+
115
+ while cursor > limit_start:
116
+ matched = False
117
+
118
+ for token in self.NUMBER_TOKENS:
119
+ start = cursor - len(token)
120
+
121
+ if start < limit_start:
122
+ continue
123
+
124
+ if clean_text[start:cursor] == token:
125
+ tokens.append(token)
126
+ cursor = start
127
+ matched = True
128
+ break
129
+
130
+ if not matched:
131
+ break
132
+
133
+ tokens.reverse()
134
+ phrase = "".join(tokens)
135
+
136
+ return phrase, cursor
137
+
138
+ def tokenize_number_words(self, text: str) -> list[str]:
139
+ """
140
+ Split a contiguous Khmer number phrase into known tokens.
141
+ """
142
+ tokens = []
143
+ i = 0
144
+
145
+ while i < len(text):
146
+ matched = False
147
+
148
+ for token in self.NUMBER_TOKENS:
149
+ if text.startswith(token, i):
150
+ tokens.append(token)
151
+ i += len(token)
152
+ matched = True
153
+ break
154
+
155
+ if not matched:
156
+ return []
157
+
158
+ # Merge patterns like "<digit>αžŸαž·αž”" into tens prefix tokens if possible.
159
+ # Example:
160
+ # αž”αžΈ + αžŸαž·αž” -> សអម
161
+ # αž”αž½αž“ + αžŸαž·αž” -> αžŸαŸ‚
162
+ merged = []
163
+ i = 0
164
+
165
+ while i < len(tokens):
166
+ if (
167
+ tokens[i] in self.DIGIT_MAP
168
+ and i + 1 < len(tokens)
169
+ and tokens[i + 1] == "αžŸαž·αž”"
170
+ ):
171
+ tens_val = self.DIGIT_MAP[tokens[i]] * 10
172
+
173
+ prefix_token = None
174
+
175
+ for key, value in self.TENS_PREFIX_MAP.items():
176
+ if value == tens_val:
177
+ prefix_token = key
178
+ break
179
+
180
+ if prefix_token:
181
+ merged.append(prefix_token)
182
+ else:
183
+ merged.append(tokens[i])
184
+ merged.append("αžŸαž·αž”")
185
+
186
+ i += 2
187
+ else:
188
+ merged.append(tokens[i])
189
+ i += 1
190
+
191
+ return merged
192
+
193
+ def inverse_number_words(self, text: str) -> str:
194
+ """
195
+ Convert Khmer number words to digits.
196
+
197
+ Examples:
198
+ αž”αžΈ -> 3
199
+ αž”αžΈαž–αžΆαž“αŸ‹ -> 3000
200
+ αž˜αž½αž™αžŸαŸ‚αž“ -> 100000
201
+ """
202
+ tokens = self.tokenize_number_words(text)
203
+
204
+ if not tokens:
205
+ return "0"
206
+
207
+ total = 0
208
+ current = 0
209
+ i = 0
210
+
211
+ while i < len(tokens):
212
+ token = tokens[i]
213
+
214
+ # Handle digits
215
+ if token in self.DIGIT_MAP:
216
+ current += self.DIGIT_MAP[token]
217
+ i += 1
218
+ continue
219
+
220
+ # Handle tens prefixes
221
+ # Example:
222
+ # ហអ -> 50
223
+ # αž αžΆαžŸαž·αž” -> 50
224
+ if token in self.TENS_PREFIX_MAP:
225
+ current += self.TENS_PREFIX_MAP[token]
226
+
227
+ if i + 1 < len(tokens) and tokens[i + 1] == "αžŸαž·αž”":
228
+ i += 2
229
+ else:
230
+ i += 1
231
+
232
+ continue
233
+
234
+ # Handle pure tens markers
235
+ if token == "αžŠαž”αŸ‹":
236
+ current = 10 if current == 0 else current + 10
237
+ i += 1
238
+ continue
239
+
240
+ if token == "αžŸαž·αž”":
241
+ current = 10 if current == 0 else current * 10
242
+ i += 1
243
+ continue
244
+
245
+ # Handle large units
246
+ if token in self.UNIT_MAP:
247
+ unit = self.UNIT_MAP[token]
248
+
249
+ if current == 0:
250
+ current = 1
251
+
252
+ current *= unit
253
+ i += 1
254
+
255
+ if unit >= 1000:
256
+ total += current
257
+ current = 0
258
+
259
+ continue
260
+
261
+ i += 1
262
+
263
+ return str(total + current)
264
+
265
+ def parse_exchange_amount(self, text: str) -> dict:
266
+ """
267
+ Extract amount, currency, target_currency from normalized text.
268
+
269
+ Rules:
270
+ - If amount < 100 and currency is αžšαŸ€αž›, auto-convert source currency
271
+ to αžŠαž»αž›αŸ’αž›αžΆαžš and target_currency to αžšαŸ€αž›.
272
+ - If only source currency is provided, target_currency defaults to the opposite.
273
+ """
274
+ clean = self.normalize_text(text)
275
+
276
+ currency = None
277
+
278
+ if "αžŠαž»αž›αŸ’αž›αžΆαžš" in clean:
279
+ currency = "αžŠαž»αž›αŸ’αž›αžΆαžš"
280
+ elif "αžšαŸ€αž›" in clean:
281
+ currency = "αžšαŸ€αž›"
282
+
283
+ if not currency:
284
+ return {}
285
+
286
+ idx = clean.index(currency)
287
+ number_words, _ = self.extract_number_phrase(clean, idx)
288
+
289
+ amount = self.inverse_number_words(number_words) if number_words else "0"
290
+
291
+ try:
292
+ amount_val = float(amount)
293
+ except ValueError:
294
+ amount_val = 0.0
295
+
296
+ target_currency = None
297
+
298
+ if currency == "αžŠαž»αž›αŸ’αž›αžΆαžš" and "αžšαŸ€αž›" in clean[idx + len(currency):]:
299
+ target_currency = "αžšαŸ€αž›"
300
+ elif currency == "αžšαŸ€αž›" and "αžŠαž»αž›αŸ’αž›αžΆαžš" in clean[idx + len(currency):]:
301
+ target_currency = "αžŠαž»αž›αŸ’αž›αžΆαžš"
302
+
303
+ if amount_val < 100 and currency == "αžšαŸ€αž›":
304
+ currency = "αžŠαž»αž›αŸ’αž›αžΆαžš"
305
+ target_currency = "αžšαŸ€αž›"
306
+ elif target_currency is None:
307
+ target_currency = "αžŠαž»αž›αŸ’αž›αžΆαžš" if currency == "αžšαŸ€αž›" else "αžšαŸ€αž›"
308
+
309
+ return {
310
+ "amount": int(amount_val) if amount_val.is_integer() else amount_val,
311
+ "currency": currency,
312
+ "target_currency": target_currency,
313
+ }
314
+
315
+ def convert(self, text: str) -> str:
316
+ """
317
+ Full sentence normalizer.
318
+
319
+ Keeps English word spaces.
320
+ Removes Khmer-to-Khmer spaces.
321
+ Converts Khmer number words before currency to digits.
322
+ """
323
+ clean = self.normalize_text(text)
324
+
325
+ # ---------------------------------------------------
326
+ # CASE: <dollar><αžŠαž»αž›αŸ’αž›αžΆαžš><cent><αžŸαŸαž“>
327
+ # Example:
328
+ # αž˜αž½αž™αžŠαž»αž›αŸ’αž›αžΆαžšαž αžΆαžŸαž·αž”αžŸαŸαž“ -> 1.50αžŠαž»αž›αŸ’αž›αžΆαžš
329
+ # ---------------------------------------------------
330
+ if "αžŠαž»αž›αŸ’αž›αžΆαžš" in clean and "αžŸαŸαž“" in clean:
331
+ dollar_idx = clean.index("αžŠαž»αž›αŸ’αž›αžΆαžš")
332
+ cent_idx = clean.rindex("αžŸαŸαž“")
333
+
334
+ if cent_idx > dollar_idx:
335
+ dollar_words, dollar_start = self.extract_number_phrase(
336
+ clean,
337
+ dollar_idx,
338
+ )
339
+
340
+ cent_words, _ = self.extract_number_phrase(
341
+ clean,
342
+ cent_idx,
343
+ dollar_idx + len("αžŠαž»αž›αŸ’αž›αžΆαžš"),
344
+ )
345
+
346
+ if dollar_words and cent_words:
347
+ dollar_value = self.inverse_number_words(dollar_words)
348
+ cent_value = self.inverse_number_words(cent_words).zfill(2)
349
+
350
+ prefix = clean[:dollar_start]
351
+ after = clean[cent_idx + len("αžŸαŸαž“"):]
352
+
353
+ return f"{prefix}{dollar_value}.{cent_value}αžŠαž»αž›αŸ’αž›αžΆαžš{after}"
354
+
355
+ # ---------------------------------------------------
356
+ # CASE: ASR may output "αžšαŸ€αž›" where user means dollar before cents
357
+ # Example:
358
+ # αž˜αž½αž™αžšαŸ€αž›αž αžΆαžŸαž·αž”αžŸαŸαž“ -> 1.50αžŠαž»αž›αŸ’αž›αžΆαžš
359
+ # ---------------------------------------------------
360
+ if "αžšαŸ€αž›" in clean and "αžŸαŸαž“" in clean and "αžŠαž»αž›αŸ’αž›αžΆαžš" not in clean:
361
+ riel_idx = clean.index("αžšαŸ€αž›")
362
+ cent_idx = clean.rindex("αžŸαŸαž“")
363
+
364
+ if cent_idx > riel_idx:
365
+ dollar_words, dollar_start = self.extract_number_phrase(
366
+ clean,
367
+ riel_idx,
368
+ )
369
+
370
+ cent_words, _ = self.extract_number_phrase(
371
+ clean,
372
+ cent_idx,
373
+ riel_idx + len("αžšαŸ€αž›"),
374
+ )
375
+
376
+ if dollar_words and cent_words:
377
+ dollar_value = self.inverse_number_words(dollar_words)
378
+ cent_value = self.inverse_number_words(cent_words).zfill(2)
379
+
380
+ prefix = clean[:dollar_start]
381
+ after = clean[cent_idx + len("αžŸαŸαž“"):]
382
+
383
+ return f"{prefix}{dollar_value}.{cent_value}αžŠαž»αž›αŸ’αž›αžΆαžš{after}"
384
+
385
+ # ---------------------------------------------------
386
+ # CASE: cents only
387
+ # Example:
388
+ # αž αžΆαžŸαž·αž”αžŸαŸαž“ -> 0.50αžŠαž»αž›αŸ’αž›αžΆαžš
389
+ # ---------------------------------------------------
390
+ if "αžŸαŸαž“" in clean and "αžŠαž»αž›αŸ’αž›αžΆαžš" not in clean:
391
+ cent_idx = clean.index("αžŸαŸαž“")
392
+ cent_words, cent_start = self.extract_number_phrase(clean, cent_idx)
393
+
394
+ if cent_words:
395
+ cent_value = self.inverse_number_words(cent_words).zfill(2)
396
+
397
+ prefix = clean[:cent_start]
398
+ after = clean[cent_idx + len("αžŸαŸαž“"):]
399
+
400
+ return f"{prefix}0.{cent_value}αžŠαž»αž›αŸ’αž›αžΆαžš{after}"
401
+
402
+ # ---------------------------------------------------
403
+ # CASE: normal dollar
404
+ # Example:
405
+ # αž”αžΈαžŠαž»αž›αŸ’αž›αžΆαžš -> 3αžŠαž»αž›αŸ’αž›αžΆαžš
406
+ # ---------------------------------------------------
407
+ if "αžŠαž»αž›αŸ’αž›αžΆαžš" in clean:
408
+ idx = clean.index("αžŠαž»αž›αŸ’αž›αžΆαžš")
409
+ number_words, start = self.extract_number_phrase(clean, idx)
410
+
411
+ if number_words:
412
+ number = self.inverse_number_words(number_words)
413
+
414
+ prefix = clean[:start]
415
+ after = clean[idx + len("αžŠαž»αž›αŸ’αž›αžΆαžš"):]
416
+
417
+ return f"{prefix}{number}αžŠαž»αž›αŸ’αž›αžΆαžš{after}"
418
+
419
+ # ---------------------------------------------------
420
+ # CASE: normal riel
421
+ # Example:
422
+ # αž”αžΈαž–αžΆαž“αŸ‹αžšαŸ€αž› -> 3000αžšαŸ€αž›
423
+ # ---------------------------------------------------
424
+ if "αžšαŸ€αž›" in clean:
425
+ idx = clean.index("αžšαŸ€αž›")
426
+ number_words, start = self.extract_number_phrase(clean, idx)
427
+
428
+ if number_words:
429
+ number = self.inverse_number_words(number_words)
430
+
431
+ prefix = clean[:start]
432
+ after = clean[idx + len("αžšαŸ€αž›"):]
433
+
434
+ return f"{prefix}{number}αžšαŸ€αž›{after}"
435
+
436
+ return clean
437
+
438
+
439
+ # ------------------------------------------------
440
+ # Singleton instance
441
+ # ------------------------------------------------
442
+ _inverse_text_engine = KhmerInverseText()
443
+
444
+
445
+ # ------------------------------------------------
446
+ # FINAL FUNCTION
447
+ # app.py should import and use this function.
448
+ # ------------------------------------------------
449
+ def InverseText(text: str) -> str:
450
+ return _inverse_text_engine.convert(text)
451
+
452
+
453
+ # ------------------------------------------------
454
+ # Optional compatibility helper functions
455
+ # ------------------------------------------------
456
+ def normalize_text(text: str) -> str:
457
+ return _inverse_text_engine.normalize_text(text)
458
+
459
+
460
+ def tokenize_number_words(text: str) -> list[str]:
461
+ return _inverse_text_engine.tokenize_number_words(text)
462
+
463
+
464
+ def inverse_number_words(text: str) -> str:
465
+ return _inverse_text_engine.inverse_number_words(text)
466
+
467
+
468
+ def extract_number_phrase(
469
+ clean_text: str,
470
+ end_idx: int,
471
+ limit_start: int = 0,
472
+ ) -> tuple[str, int]:
473
+ return _inverse_text_engine.extract_number_phrase(
474
+ clean_text,
475
+ end_idx,
476
+ limit_start,
477
+ )
478
+
479
+
480
+ def intent_exchange_rate_from_inverse_text(text: str) -> dict:
481
+ return _inverse_text_engine.parse_exchange_amount(text)
482
+
483
+
484
+ # ------------------------------------------------
485
+ # Test examples
486
+ # ------------------------------------------------
487
+ if __name__ == "__main__":
488
+ examples = [
489
+ "How to pay bills on time αžŠαžΎαž˜αŸ’αž”αžΈ αž‡αŸ€αžŸαžœαžΆαž„ αž€αžΆαžšαž•αžΆαž€αž–αž·αž“αŸαž™ αž”αž“αŸ’αžαŸ‚αž˜",
490
+ "αž”αžΈ αžŠαž»αž›αŸ’αž›αžΆαžš",
491
+ "αž”αžΈ αž–αžΆαž“αŸ‹ αžšαŸ€αž›",
492
+ "αž˜αž½αž™ αžŠαž»αž›αŸ’αž›αžΆαžš ហអ αžŸαž·αž” αžŸαŸαž“",
493
+ "αž˜αž½αž™ αžšαŸ€αž› ហអ αžŸαž·αž” αžŸαŸαž“",
494
+ "ហអ αžŸαž·αž” αžŸαŸαž“",
495
+ "I want to pay αž”αžΈ αž–αžΆαž“αŸ‹ αžšαŸ€αž› today",
496
+ ]
497
+
498
+ for example in examples:
499
+ print(example, "=>", InverseText(example))