From 14110e7bce3451c1514b3fa45670a44f4a1128e9 Mon Sep 17 00:00:00 2001 From: pengzhendong <275331498@qq.com> Date: Sat, 31 Oct 2026 10:06:08 +0800 Subject: [PATCH 1/2] feat: add Hindi-English code-mixed ITN with raw-field tagging --- itn/hindi_english/__init__.py | 0 itn/hindi_english/data/measurements.tsv | 2 + itn/hindi_english/inverse_normalizer.py | 93 ++++++++ itn/hindi_english/rules/__init__.py | 0 itn/hindi_english/rules/english.py | 175 +++++++++++++++ itn/hindi_english/test/__init__.py | 0 .../test/data/test_cases_address.txt | 41 ++++ .../test/data/test_cases_cardinal.txt | 61 +++++ .../test/data/test_cases_date.txt | 37 +++ .../test/data/test_cases_date_cased.txt | 70 ++++++ .../test/data/test_cases_decimal.txt | 25 +++ .../test/data/test_cases_electronic.txt | 17 ++ .../test/data/test_cases_fraction.txt | 39 ++++ .../test/data/test_cases_measure.txt | 25 +++ .../test/data/test_cases_money.txt | 41 ++++ .../test/data/test_cases_ordinal.txt | 26 +++ .../test/data/test_cases_telephone.txt | 39 ++++ .../test/data/test_cases_time.txt | 29 +++ .../test/data/test_cases_whitelist.txt | 17 ++ .../test/data/test_cases_word.txt | 19 ++ itn/hindi_english/test/normalizer_test.py | 211 ++++++++++++++++++ 21 files changed, 967 insertions(+) create mode 100644 itn/hindi_english/__init__.py create mode 100644 itn/hindi_english/data/measurements.tsv create mode 100644 itn/hindi_english/inverse_normalizer.py create mode 100644 itn/hindi_english/rules/__init__.py create mode 100644 itn/hindi_english/rules/english.py create mode 100644 itn/hindi_english/test/__init__.py create mode 100644 itn/hindi_english/test/data/test_cases_address.txt create mode 100644 itn/hindi_english/test/data/test_cases_cardinal.txt create mode 100644 itn/hindi_english/test/data/test_cases_date.txt create mode 100644 itn/hindi_english/test/data/test_cases_date_cased.txt create mode 100644 itn/hindi_english/test/data/test_cases_decimal.txt create mode 100644 itn/hindi_english/test/data/test_cases_electronic.txt create mode 100644 itn/hindi_english/test/data/test_cases_fraction.txt create mode 100644 itn/hindi_english/test/data/test_cases_measure.txt create mode 100644 itn/hindi_english/test/data/test_cases_money.txt create mode 100644 itn/hindi_english/test/data/test_cases_ordinal.txt create mode 100644 itn/hindi_english/test/data/test_cases_telephone.txt create mode 100644 itn/hindi_english/test/data/test_cases_time.txt create mode 100644 itn/hindi_english/test/data/test_cases_whitelist.txt create mode 100644 itn/hindi_english/test/data/test_cases_word.txt create mode 100644 itn/hindi_english/test/normalizer_test.py diff --git a/itn/hindi_english/__init__.py b/itn/hindi_english/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/itn/hindi_english/data/measurements.tsv b/itn/hindi_english/data/measurements.tsv new file mode 100644 index 00000000..eb1b674f --- /dev/null +++ b/itn/hindi_english/data/measurements.tsv @@ -0,0 +1,2 @@ +°C degrees celsius +°F degrees fahrenheit diff --git a/itn/hindi_english/inverse_normalizer.py b/itn/hindi_english/inverse_normalizer.py new file mode 100644 index 00000000..f008940d --- /dev/null +++ b/itn/hindi_english/inverse_normalizer.py @@ -0,0 +1,93 @@ +# Copyright (c) 2026 Zhendong Peng (pzd17@tsinghua.org.cn) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pynini.lib.pynutil import add_weight, delete + +from itn.english.inverse_normalizer import TOKEN_ORDERS as EN_TOKEN_ORDERS +from itn.hindi.inverse_normalizer import TOKEN_ORDERS as HI_TOKEN_ORDERS +from itn.hindi.rules.cardinal import Cardinal +from itn.hindi.rules.date import Date +from itn.hindi.rules.decimal import Decimal +from itn.hindi.rules.fraction import Fraction +from itn.hindi.rules.measure import Measure +from itn.hindi.rules.money import Money +from itn.hindi.rules.ordinal import Ordinal +from itn.hindi.rules.punctuation import Punctuation +from itn.hindi.rules.telephone import Telephone +from itn.hindi.rules.time import Time +from itn.hindi.rules.whitelist import Whitelist +from itn.hindi.rules.word import Word +from itn.hindi_english.rules import english +from tn.processor import Processor, RuleSpec + +# Hindi and English tokens share names (cardinal, date, money, ...); a token is verbalized by whichever +# language's verbalizer accepts its raw field text. The Hindi rules emit fields in written order, so only +# the English canonical orders are needed (Hindi money keeps its own order for its single "value" field). +TOKEN_ORDERS = dict(HI_TOKEN_ORDERS) +TOKEN_ORDERS.update(EN_TOKEN_ORDERS) + +# Classifier weight of a punctuation mark glued to a token. +PUNCT_WEIGHT = 1.1 + + +class InverseNormalizer(Processor): + + def __init__(self, cache_dir=None, overwrite_cache=False, cased=False): + """``cased=True`` also accepts English spoken words with a capitalized first letter ("July twenty").""" + super().__init__(name="hi_en_inverse_normalizer", ordertype="itn", token_orders=TOKEN_ORDERS) + self.cased = cased + self.build_fst("hi_en_itn", cache_dir, overwrite_cache, {"cased": cased}) + + def build_tagger_and_verbalizer(self): + cased = self.cased + cardinal = Cardinal() + decimal = Decimal(cardinal) + en_cardinal = english.Cardinal(cased=cased) + en_ordinal = english.Ordinal(cardinal=en_cardinal, cased=cased) + en_decimal = english.Decimal(cardinal=en_cardinal, cased=cased) + punctuation = Punctuation() + word = Word() + rules = ( + RuleSpec(Whitelist(), 1.01), + RuleSpec(english.Whitelist(cased=cased), 1.01), + RuleSpec(Time(cardinal), 1.1), + RuleSpec(english.Time(cardinal=en_cardinal, cased=cased), 1.1), + RuleSpec(Date(cardinal), 1.1), + RuleSpec(english.Date(cardinal=en_cardinal, ordinal=en_ordinal, cased=cased), 1.09), + RuleSpec(decimal, 1.1), + RuleSpec(en_decimal, 1.1), + RuleSpec(Fraction(cardinal), 1.1), + RuleSpec(Measure(cardinal, decimal), 1.1), + RuleSpec(english.Measure(cardinal=en_cardinal, decimal=en_decimal, cased=cased), 1.1), + RuleSpec(cardinal, 1.1), + RuleSpec(en_cardinal, 1.1), + RuleSpec(Ordinal(cardinal), 1.1), + RuleSpec(en_ordinal, 1.09), + RuleSpec(Money(cardinal, decimal), 1.1), + RuleSpec(english.Money(cardinal=en_cardinal, decimal=en_decimal, cased=cased), 1.08), + RuleSpec(Telephone(), 1.1), + RuleSpec(english.Telephone(cardinal=en_cardinal, cased=cased), 1.1), + RuleSpec(english.Electronic(cased=cased), 1.1), + RuleSpec(word, 100), + RuleSpec(punctuation), + ) + classify = self.tagger_union(rules) + punct = add_weight(punctuation.tagger, PUNCT_WEIGHT) + token = punct.star + classify + punct.star + tagger = token + (delete(self.SPACE.plus) + token).star + self.tagger = self.DELETE_SPACE + tagger + self.DELETE_SPACE + self.tagger |= word.add_tokens(word.tag_field("value", self.SPACE.plus)) + + verbalize = self.verbalizer_union(rules) + self.verbalizer = verbalize + (self.INSERT_SPACE + verbalize).star diff --git a/itn/hindi_english/rules/__init__.py b/itn/hindi_english/rules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/itn/hindi_english/rules/english.py b/itn/hindi_english/rules/english.py new file mode 100644 index 00000000..8883288a --- /dev/null +++ b/itn/hindi_english/rules/english.py @@ -0,0 +1,175 @@ +# Copyright (c) 2026 Zhendong Peng (pzd17@tsinghua.org.cn) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Code-mixed glue around the English ITN rules. + +The English rules are reused unchanged except for three code-mixed details +(Indian lakh/crore numbering, a comma before the year of a full date as in the +Hindi date verbalizer, and plural "degrees" temperature units), plus optional +cased input. Cased input is handled field by field: a field also accepts its +spoken form with a capitalized first letter (the raw text, e.g. ``July``, is +kept in the tag), and the verbalizer lower-cases that letter before converting +and re-capitalizes a written form that starts with a lower-case letter +(``July`` -> ``July``, ``Twenty`` -> ``20``). +""" + +import string + +import pynini +from pynini import closure, union +from pynini.lib import utf8 +from pynini.lib.pynutil import delete, insert + +from itn.english.rules.cardinal import Cardinal as EnCardinal +from itn.english.rules.date import Date as EnDate +from itn.english.rules.decimal import Decimal as EnDecimal +from itn.english.rules.electronic import Electronic as EnElectronic +from itn.english.rules.measure import Measure as EnMeasure +from itn.english.rules.money import Money as EnMoney +from itn.english.rules.ordinal import Ordinal as EnOrdinal +from itn.english.rules.telephone import Telephone as EnTelephone +from itn.english.rules.time import Time as EnTime +from itn.english.rules.whitelist import Whitelist as EnWhitelist +from itn.utils import get_abs_path + +_SIGMA = utf8.VALID_UTF8_CHAR.star +_LOWER = union(*string.ascii_lowercase) +_TO_LOWER = union(*[pynini.cross(u, lo) for u, lo in zip(string.ascii_uppercase, string.ascii_lowercase)]) +# "July" -> "july"; only strings with a capitalized first letter. +LOWER_FIRST = (_TO_LOWER + _SIGMA).optimize() +# Written form re-capitalized only when it starts with a lower-case letter ("july" -> "July", "20" -> "20"). +RECAPITALIZE = (pynini.invert(_TO_LOWER) + _SIGMA | pynini.difference(utf8.VALID_UTF8_CHAR, _LOWER) + _SIGMA + | "").optimize() + +# Indian numbering magnitudes, highest first (each takes a 1-99 multiplier). +INDIAN_MAGNITUDES = ("shankh", "padma", "nil", "kharab", "arab", "crore", "lakh") + + +def data_file(rel_path): + """Loads hi_en ``writtenspoken`` data as spoken -> written.""" + + return pynini.string_file(get_abs_path("hindi_english/data/" + rel_path)).invert().optimize() + + +class CasedFields: + """Mixin: with ``cased=True`` every tagged field also accepts a capitalized first letter.""" + + def __init__(self, *args, cased=False, **kwargs): + self.cased = cased + super().__init__(*args, **kwargs) + + def tag_field(self, name, graph): + if self.cased: + graph = graph | (LOWER_FIRST @ graph) + return super().tag_field(name, graph) + + def verbalize_field(self, name, graph=None, leading_space=False): + if self.cased and graph is not None: + graph = graph | (LOWER_FIRST @ graph @ RECAPITALIZE) + return super().verbalize_field(name, graph, leading_space) + + +class Cardinal(CasedFields, EnCardinal): + """English cardinal plus Indian numbering: ``twenty one crore ninety eight lakh ...`` -> ``219836593``.""" + + def build_tagger(self): + super().build_tagger() + indian = self.indian_graph() + self.graph = (self.graph | indian).optimize() + self.graph_no_exception = self.graph + self.integer = (self.integer | indian).optimize() + optional_minus = closure(self.tag_field("negative", self.negative) + delete(" ") + insert(" "), 0, 1) + self.tagger = self.add_tokens(optional_minus + self.tag_field("integer", self.integer)) + + def indian_graph(self): + digit = pynini.string_file(get_abs_path("english/data/numbers/digit.tsv")) + two_digit = self.graph_two_digit + two_padded = two_digit | insert("0") + digit + ds = delete(" ") + hundreds = digit + ds + delete("hundred") + (ds + two_digit | ds + insert("0") + digit | insert("00")) + up_to_999_padded = hundreds | insert("0") + two_digit | insert("00") + digit + + # Parsed over the input with one leading space, so every present component is " ". + graph = pynini.accep("") + for magnitude in INDIAN_MAGNITUDES + ("thousand", ): + words = pynini.union(magnitude, magnitude + "s") if magnitude != "thousand" else pynini.accep(magnitude) + graph += ds + two_padded + ds + delete(words) | insert("00") + graph += ds + up_to_999_padded | insert("000") + graph = (insert(" ") + _SIGMA) @ graph + has_magnitude = _SIGMA + pynini.union(*INDIAN_MAGNITUDES) + _SIGMA + graph = has_magnitude @ graph + graph = graph @ (delete(closure("0")) + pynini.difference(self.DIGIT, "0") + closure(self.DIGIT)) + delete_and = self.build_rule(delete("and "), " ", self.ALPHA) + return (delete_and @ graph).optimize() + + +class Ordinal(CasedFields, EnOrdinal): + pass + + +class Decimal(CasedFields, EnDecimal): + pass + + +class Date(CasedFields, EnDate): + """English date written like the Hindi date: a comma separates day and year (``july 25, 2012``).""" + + def build_verbalizer(self): + month = self.verbalize_field("month", self.month) + day = self.verbalize_field("day", self.day | self.day_dmy) + period = self.verbalize_field("day", self.day_quarter | self.day_half) + year = self.verbalize_field( + "year", self.year | self.year_decade | self.year_bc | self.year_century | self.year_millennium) + delete_po = delete("preserve_order:") + self.DELETE_SPACE + delete('"true"') + space = self.DELETE_SPACE + insert(" ") + comma_year = closure(self.DELETE_SPACE + insert(", ") + year, 0, 1) + + graph = pynini.union( + month + space + day + comma_year, + month + closure(space + year, 0, 1), + day + space + month + comma_year, + period + space + year, + year, + ) + self.verbalizer = self.delete_tokens(graph + self.DELETE_SPACE + delete_po) + + +class Measure(CasedFields, EnMeasure): + """English measure that also accepts plural temperature units (``five degrees celsius`` -> ``5 °C``).""" + + def build_tagger(self): + super().build_tagger() + self.units = (self.units | data_file("measurements.tsv")).optimize() + value = self.tag_field("value", self.value) + units = self.tag_field("units", self.units) + self.tagger = self.add_tokens(value + delete(" ") + insert(" ") + units) + + +class Money(CasedFields, EnMoney): + pass + + +class Time(CasedFields, EnTime): + pass + + +class Telephone(CasedFields, EnTelephone): + pass + + +class Electronic(CasedFields, EnElectronic): + pass + + +class Whitelist(CasedFields, EnWhitelist): + pass diff --git a/itn/hindi_english/test/__init__.py b/itn/hindi_english/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/itn/hindi_english/test/data/test_cases_address.txt b/itn/hindi_english/test/data/test_cases_address.txt new file mode 100644 index 00000000..b4461477 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_address.txt @@ -0,0 +1,41 @@ +दिल्ली एक एक शून्य शून्य शून्य एक~दिल्ली ११०००१ +मुंबई चार शून्य शून्य शून्य शून्य एक~मुंबई ४००००१ +चेन्नई छह शून्य शून्य शून्य शून्य एक~चेन्नई ६००००१ +कोलकाता सात शून्य शून्य शून्य शून्य एक~कोलकाता ७००००१ +बेंगलुरु पाँच छह शून्य शून्य शून्य एक~बेंगलुरु ५६०००१ +सात शून्य शून्य ओक स्ट्रीट~७०० ओक स्ट्रीट +एक एक जंगल रोड~११ जंगल रोड +तीन शून्य एक पार्क एवेन्यू~३०१ पार्क एवेन्यू +गली नंबर एक सात जीएकगढ़~गली नंबर १७ जीएकगढ़ +अदनान अपार्टमेंट फ्लैट नंबर पाँच पाँच~अदनान अपार्टमेंट फ्लैट नंबर ५५ +प्लॉट नंबर आठ बालाजी मार्केट~प्लॉट नंबर ८ बालाजी मार्केट +बूथ सात शून्य, सेक्टर आठ, चंडीगढ़~बूथ ७०, सेक्टर ८, चंडीगढ़ +दो दो दो एक सदर्न स्ट्रीट~२२२१ सदर्न स्ट्रीट +छह दो पाँच स्कूल स्ट्रीट~६२५ स्कूल स्ट्रीट +पाँच शून्य छह स्टेट रोड~५०६ स्टेट रोड +छह छह हाइफ़न चार, पार्कहर्स्ट रोड~६६-४, पार्कहर्स्ट रोड +एक चार बटा तीन, मथुरा रोड~१४/३, मथुरा रोड +अमरावती छह पाँच पाँच नौ तीन शून्य~अमरावती ६५५९३० +अमरावती चार छह आठ दो पाँच दो~अमरावती ४६८२५२ +शिमला, हिमाचल प्रदेश पाँच नौ तीन नौ आठ आठ~शिमला, हिमाचल प्रदेश ५९३९८८ +रांची, झारखंड सात तीन छह पाँच पाँच सात~रांची, झारखंड ७३६५५७ +कोहिमा, नागालैंड चार चार आठ तीन सात सात~कोहिमा, नागालैंड ४४८३७७ +मुंबई, महाराष्ट्र आठ तीन नौ चार आठ आठ~मुंबई, महाराष्ट्र ८३९४८८ +मुंबई, महाराष्ट्र दो नौ शून्य नौ तीन सात~मुंबई, महाराष्ट्र २९०९३७ +गांधीनगर, गुजरात आठ शून्य आठ तीन सात चार~गांधीनगर, गुजरात ८०८३७४ +रायपुर, छत्तीसगढ़ एक एक शून्य छह तीन पाँच~रायपुर, छत्तीसगढ़ ११०६३५ +भोपाल, मध्य प्रदेश सात पाँच एक दो दो पाँच~भोपाल, मध्य प्रदेश ७५१२२५ +अगरतला, त्रिपुरा नौ एक पाँच तीन शून्य पाँच~अगरतला, त्रिपुरा ९१५३०५ +लखनऊ, उत्तर प्रदेश आठ शून्य दो चार आठ एक~लखनऊ, उत्तर प्रदेश ८०२४८१ +श्रीनगर, जम्मू और कश्मीर नौ छह चार पाँच दो तीन~श्रीनगर, जम्मू और कश्मीर ९६४५२३ +seven hundred oak street~700 oak street +eleven hundred park avenue~1100 park avenue +three hundred one main street~301 main street +two hundred and fifty four san tomas avenue~254 san tomas avenue +fourteen hundred twenty eight elm street~1428 elm street +twelve hundred and eleven east avenue~1211 east avenue +one thousand one hundred laguna court~1100 laguna court +two hundred and fifty four arques avenue~254 arques avenue +मुंबई one one zero zero zero one~मुंबई ११०००१ +चेन्नई six zero zero zero zero one~चेन्नई ६००००१ +दिल्ली one one zero zero zero one~दिल्ली ११०००१ diff --git a/itn/hindi_english/test/data/test_cases_cardinal.txt b/itn/hindi_english/test/data/test_cases_cardinal.txt new file mode 100644 index 00000000..4b6011eb --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_cardinal.txt @@ -0,0 +1,61 @@ +शून्य~० +एक~१ +दो~२ +तीन~३ +दस~१० +ग्यारह~११ +बारह~१२ +तेरह~१३ +चौदह~१४ +पन्द्रह~१५ +सोलह~१६ +बीस~२० +तेईस~२३ +पचास~५० +सत्तर~७० +नब्बे~९० +सौ~१०० +दो सौ~२०० +एक सौ दस~११० +तीन सौ पचास~३५० +हज़ार~१००० +एक हज़ार~१००० +दो हज़ार~२००० +दस हज़ार~१०००० +एक लाख~१००००० +दो लाख~२००००० +एक करोड़~१००००००० +पाँच करोड़~५००००००० +साढ़े तीन सौ~३५० +सवा दो सौ~२२५ +डेढ़ सौ~१५० +ढाई सौ~२५० +साढ़े तीन हज़ार~३५०० +सवा दो हज़ार~२२५० +ढाई हज़ार~२५०० +पौने चार सौ~३७५ +पौने दो सौ~१७५ +zero~zero +sixty~60 +nineteen~19 +two hundred and fifty four~254 +one hundred forty seven thousand four hundred fifty one~147451 +one million one hundred fifty six thousand one hundred seventy three~1156173 +one billion five hundred ninety three million seventy two thousand nine hundred sixty one~1593072961 +minus twenty five thousand thirty seven~-25037 +minus sixty~-60 +forty six thousand six hundred sixty four~46664 +two million three~2000003 +one thousand thirteen~1013 +one thousand one~1001 +one thousand one hundred~1100 +one thousand twenty six~1026 +one thousand one hundred twenty six~1126 +eleven hundred~1100 +twenty one hundred~2100 +twenty one hundred and eleven~2111 +eleven hundred twenty one~1121 +twenty one crore ninety eight lakh thirty six thousand five hundred and ninety three~219836593 +दस and twenty~१० and 20 +सौ and one hundred~१०० and 100 +two hundred और तीन सौ~200 और ३०० diff --git a/itn/hindi_english/test/data/test_cases_date.txt b/itn/hindi_english/test/data/test_cases_date.txt new file mode 100644 index 00000000..6f5db940 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_date.txt @@ -0,0 +1,37 @@ +पाँच जनवरी~५ जनवरी +दस फ़रवरी~१० फ़रवरी +पन्द्रह मार्च~१५ मार्च +बीस अप्रैल~२० अप्रैल +तीस जून~३० जून +पाँच जनवरी दो हज़ार बारह~५ जनवरी, २०१२ +दस फ़रवरी उन्नीस सौ नब्बे~१० फ़रवरी, १९९० +दो हज़ार बारह~२०१२ +उन्नीस सौ सत्तर~१९७० +दो हज़ार~२००० +उन्नीस सौ~१९०० +दो हज़ार बारह से दो हज़ार पन्द्रह~२०१२-२०१५ +पहली सदी~पहली सदी +बीसवीं सदी~२०वीं सदी +दो सौ तीन ईसा पूर्व~२०३ ई.पू. +चार सौ बीस ईसवी~४२० ई. +पन्द्रह सौ ईसवी~१५०० ई. +दो हज़ार बीस ईसवी~२०२० ई. +पन्द्रह अगस्त उन्नीस सौ सैंतालीस~१५ अगस्त, १९४७ +छब्बीस जनवरी उन्नीस सौ पचास~२६ जनवरी, १९५० +january first~january 1 +july twenty second two thousand eight~july 22, 2008 +june thirty~june 30 +july twenty fifth twenty twelve~july 25, 2012 +nineteen seventeen~1917 +twenty twelve~2012 +nineteen ninety four~1994 +two thousand three~2003 +the twenty fifth of july twenty twelve~25 july, 2012 +the fifteenth of january~15 january +february twenty fifth twenty sixteen~february 25, 2016 +november twenty fourth twenty fourteen~november 24, 2014 +january पच्चीस~january २५ +पाँच january~५ january +दस jan~१० jan +march बीस~march २० +दस march twenty twenty~१० march 2020 diff --git a/itn/hindi_english/test/data/test_cases_date_cased.txt b/itn/hindi_english/test/data/test_cases_date_cased.txt new file mode 100644 index 00000000..96bbc7d3 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_date_cased.txt @@ -0,0 +1,70 @@ +july twenty fifth two thousand twelve~july 25, 2012 +nineteen eighties~1980s +two thousand and twenty~2020 +two thousand and nine~2009 +the twenty fifth of july twenty twelve~25 july, 2012 +the twenty fifth of july two thousand twelve~25 july, 2012 +the twenty second of july twenty twelve~22 july, 2012 +the fifteenth of january~15 january +the seventeenth of may twenty ten~17 may, 2010 +january first~january 1 +july twenty second two thousand eight~july 22, 2008 +june thirty~june 30 +july twenty fifth twenty twelve~july 25, 2012 +nineteen seventeen~1917 +twenty twelve~2012 +march sixteen sixty five~march 1665 +sixteen sixty five~1665 +july two thousand twelve~july 2012 +october nineteen oh five~october 1905 +july fifteen o six~july 1506 +the twenty fifth of july twenty twelve~25 july, 2012 +july twenty fifth twenty twelve~july 25, 2012 +july twenty fifth two thousand twelve~july 25, 2012 +july one thousand eight hundred seventy six~july 1876 +february twenty fifth twenty sixteen~february 25, 2016 +november twenty fourth twenty fourteen~november 24, 2014 +nineteen ninety four~1994 +two thousand three~2003 +one thousand eight~1008 +nineteen seventy six~1976 +june twentieth twenty fourteen~june 20, 2014 +nineteen seventy three~1973 +nineteen seventy five~1975 +eleven fifty five~1155 +July twenty fifth two thousand twelve~July 25, 2012 +Nineteen eighties~1980s +Two thousand and twenty~2020 +Two thousand and nine~2009 +The twenty fifth of july twenty twelve~25 july, 2012 +The twenty fifth of july two thousand twelve~25 july, 2012 +The twenty second of july twenty twelve~22 july, 2012 +The fifteenth of january~15 january +The fifteenth of January~15 January +The seventeenth of may twenty ten~17 may, 2010 +January first~January 1 +July twenty second two thousand eight~July 22, 2008 +June thirty~June 30 +July twenty fifth twenty twelve~July 25, 2012 +Nineteen seventeen~1917 +Twenty twelve~2012 +March sixteen sixty five~March 1665 +Sixteen sixty five~1665 +July two thousand twelve~July 2012 +October nineteen oh five~October 1905 +July fifteen o six~July 1506 +The twenty fifth of july twenty twelve~25 july, 2012 +The twenty fifth of July twenty twelve~25 July, 2012 +July twenty fifth twenty twelve~July 25, 2012 +July twenty fifth two thousand twelve~July 25, 2012 +July one thousand eight hundred seventy six~July 1876 +February twenty fifth twenty sixteen~February 25, 2016 +November twenty fourth twenty fourteen~November 24, 2014 +Nineteen ninety four~1994 +Two thousand three~2003 +One thousand eight~1008 +Nineteen seventy six~1976 +June twentieth twenty fourteen~June 20, 2014 +Nineteen seventy three~1973 +Nineteen seventy five~1975 +Eleven fifty five~1155 diff --git a/itn/hindi_english/test/data/test_cases_decimal.txt b/itn/hindi_english/test/data/test_cases_decimal.txt new file mode 100644 index 00000000..075aebab --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_decimal.txt @@ -0,0 +1,25 @@ +एक दशमलव दो छह~१.२६ +शून्य दशमलव पाँच~०.५ +ऋण एक दशमलव दो छह~-१.२६ +दो दशमलव तीन चार~२.३४ +पाँच दशमलव शून्य एक~५.०१ +five point two million~5.2 million +one hundred sixty four point five eight thousand~164.58 thousand +four hundred million~400 million +fifty billion~50 billion +one point two five billion~1.25 billion +thirteen billion~13 billion +thirty billion~30 billion +two thousand eight hundred five point eight seven three billion~2805.873 billion +eighteen~18 +eighteen point eight five~18.85 +eighteen point five o~18.50 +eighteen point five six~18.56 +eighteen point nine~18.9 +eighteen point o five~18.05 +eighteen point one two~18.12 +eighteen point o one~18.01 +zero point two six~0.26 +point zero two~.02 +sixty point two~60.2 +minus sixty point two four zero zero~-60.2400 diff --git a/itn/hindi_english/test/data/test_cases_electronic.txt b/itn/hindi_english/test/data/test_cases_electronic.txt new file mode 100644 index 00000000..de609c26 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_electronic.txt @@ -0,0 +1,17 @@ +a dot b c at g mail dot com~a.bc@gmail.com +a at gmail dot com~a@gmail.com +c d f at a b c dot e d u~cdf@abc.edu +a b c at g mail dot a b c~abc@gmail.abc +a b c at a b c dot com~abc@abc.com +a s d f one two three at a b c dot com~asdf123@abc.com +a one b two at a b c dot com~a1b2@abc.com +a b three dot s d d dot three at g mail dot com~ab3.sdd.3@gmail.com +h t t p colon slash slash w w w dot o u r d a i l y n e w s dot com dot s m~http://www.ourdailynews.com.sm +w w w dot c o m d a i l y n e w s dot a b slash s m~www.comdailynews.ab/sm +n vidia dot com~nvidia.com +abc at gmail dot com~abc@gmail.com +athreed at gmail dot com~athreed@gmail.com +kore dot ai~kore.ai +a at nvidia dot com~a@nvidia.com +a dot b c at nvidia dot com~a.bc@nvidia.com +a b three hyphen s d d dash three at g mail dot com~ab3-sdd-3@gmail.com diff --git a/itn/hindi_english/test/data/test_cases_fraction.txt b/itn/hindi_english/test/data/test_cases_fraction.txt new file mode 100644 index 00000000..ce534bc2 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_fraction.txt @@ -0,0 +1,39 @@ +आठ बटा तीन~८/३ +दो बटा पाँच~२/५ +एक बटा चार~१/४ +दो सही दो बटा तीन~२ २/३ +तीन सही एक बटा चार~३ १/४ +पाँच सही तीन बटा सात~५ ३/७ +सात बटे ग्यारह~७/११ +छह बटा तेरह~६/१३ +डेढ़~१ १/२ +ढाई~२ १/२ +पाव~१/४ +एक सौ नौ बटा एक सौ चौबीस~१०९/१२४ +एक सौ एक बटा दो~१०१/२ +दो सौ एक बटा दो~२०१/२ +एक सौ एक बटा चार~१०१/४ +दो सौ बटा पाँच सौ~२००/५०० +दो सौ बटा बारह~२००/१२ +एक सौ तेईस बटा एक सौ पच्चीस~१२३/१२५ +छह सौ बासठ बटा एक~६६२/१ +एक सौ पाँच बटा सात~१०५/७ +छह सौ चौवन बटा तीन~६५४/३ +एक सौ तैंतीस सही एक बटा दो~१३३ १/२ +एक सौ तैंतीस सही दो बटा तीन~१३३ २/३ +एक सही छह बटा छह~१ ६/६ +दो सही एक बटा छह~२ १/६ +तीन सही तीन बटा चार~३ ३/४ +एक सौ बीस सही तीन बटा चार~१२० ३/४ +एक सौ बीस सही पिछत्तर बटा नब्बे~१२० ७५/९० +सवा चौरासी~८४ १/४ +आधा~१/२ +साढ़े~१/२ +सवा~१/४ +पौन~३/४ +पौना~३/४ +सवा पैंतीस~३५ १/४ +साढ़े चार सौ बटा दस~४५०/१० +तीन चौथाई~३/४ +दो तिहाई~२/३ +एक चौथाई~१/४ diff --git a/itn/hindi_english/test/data/test_cases_measure.txt b/itn/hindi_english/test/data/test_cases_measure.txt new file mode 100644 index 00000000..d232013a --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_measure.txt @@ -0,0 +1,25 @@ +दो सौ मीटर~२०० m +तीन किलोग्राम~३ kg +साढ़े तीन किलोग्राम~३.५ kg +ऋण बारह किलोग्राम~-१२ kg +पचास किलोमीटर~५० km +तीन सौ ग्राम~३०० g +सवा दो किलोग्राम~२.२५ kg +दो सौ किलोमीटर प्रति घंटा~२०० km/h +बीस डिग्री सेल्सियस~२० °C +एक सौ मीटर~१०० m +two hundred meters~200 m +three hours~3 h +two hundred kilometers per hour~200 km/h +minus sixty six kilograms~-66 kg +eight point five megawatts~8.5 mW +eight point five meters~8.5 m +eight point five two percent~8.52 % +eight point four four percent~8.44 % +five degrees celsius~5 °C +seventy two degrees fahrenheit~72 °F +two hundred seventy three kelvin~273 K +eighteen feet~18 ft +eighteen point five kilometers~18.5 km +eight hundred fifty meters~850 m +eight hundred kilograms~800 kg diff --git a/itn/hindi_english/test/data/test_cases_money.txt b/itn/hindi_english/test/data/test_cases_money.txt new file mode 100644 index 00000000..aa910789 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_money.txt @@ -0,0 +1,41 @@ +दस रुपये~₹१० +सौ रुपये~₹१०० +हज़ार रुपये~₹१००० +दो सौ रुपये~₹२०० +पाँच सौ रुपये~₹५०० +दो हज़ार रुपये~₹२००० +एक लाख रुपये~₹१००००० +दस रुपये और पचास पैसे~₹१०.५० +बीस डॉलर~$२० +पचास यूरो~€५० +बहत्तर बिटकॉइन~₿७२ +ढाई सौ रुपये~₹२५० +साढ़े तीन सौ रुपये~₹३५० +सवा दो सौ रुपये~₹२२५ +पौने चार सौ रुपये~₹३७५ +ढाई करोड़ रुपये~₹२५०००००० +साढ़े तीन लाख रुपये~₹३५०००० +सवा दो लाख रुपये~₹२२५००० +पचास हज़ार रुपये~₹५०००० +two dollars~$2 +one cent~$0.01 +four united states dollars and sixty nine cents~$4.69 +seventy five dollars sixty three~$75.63 +twenty nine dollars fifty cents~$29.50 +eleven dollars and fifty one cents~$11.51 +nine hundred ninety three dollars and ninety two cents~$993.92 +four hundred sixty billion won~₩460 billion +thirty billion yen~¥30 billion +two point five billion dollars~$2.5 billion +forty five billion dollars~$45 billion +fifty million dollars~$50 million +one dollar~$1 +fifteen thousand dollars~$15000 +twenty dollar~$20 +eighteen dollars~$18 +fifteen hundred dollars~$1500 +one hundred रुपये~100 रुपये +fifty रुपये~50 रुपये +सौ डॉलर~$१०० +twenty dollars और fifty cents~$20 और $0.50 +दस rupees~१० rupees diff --git a/itn/hindi_english/test/data/test_cases_ordinal.txt b/itn/hindi_english/test/data/test_cases_ordinal.txt new file mode 100644 index 00000000..94910d49 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_ordinal.txt @@ -0,0 +1,26 @@ +पहला~पहला +दूसरा~दूसरा +तीसरा~तीसरा +चौथा~चौथा +दसवां~१०वां +ग्यारहवां~११वां +बीसवां~२०वां +तेईसवां~२३वां +पचासवां~५०वां +नब्बेवाँ~९०वाँ +दसवीं~१०वीं +बीसवीं~२०वीं +first~1st +second~2nd +third~3rd +fourth~4th +eleventh~11th +twelfth~12th +thirteenth~13th +twenty first~21st +twenty third~23rd +one hundredth~100th +one hundred eleventh~111th +one thousandth~1000th +forty second~42nd +seventy first~71st diff --git a/itn/hindi_english/test/data/test_cases_telephone.txt b/itn/hindi_english/test/data/test_cases_telephone.txt new file mode 100644 index 00000000..e318fba4 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_telephone.txt @@ -0,0 +1,39 @@ +एक एक एक एक एक एक~११११११ +पाँच शून्य शून्य शून्य एक दो~५०००१२ +एक दो तीन चार पाँच छह~१२३४५६ +चार शून्य शून्य शून्य एक शून्य~४०००१० +सात पाँच शून्य शून्य शून्य दो~७५०००२ +आठ आठ शून्य नौ नौ शून्य~८८०९९० +नौ आठ सात छह पाँच चार तीन दो एक शून्य~९८७६५४३२१० +सात शून्य एक दो तीन चार पाँच छह सात आठ~७०१२३४५६७८ +आठ आठ आठ सात सात सात छह छह छह छह~८८८७७७६६६६ +छह दो नौ शून्य एक पाँच सात तीन चार आठ~६२९०१५७३४८ +नौ नौ आठ आठ सात सात छह छह पाँच पाँच~९९८८७७६६५५ +प्लस नौ एक नौ आठ सात छह पाँच चार तीन दो एक शून्य~+९१ ९८७६५४३२१० +प्लस नौ एक सात शून्य एक दो तीन चार पाँच छह सात आठ~+९१ ७०१२३४५६७८ +प्लस नौ एक आठ आठ आठ सात सात सात छह छह छह छह~+९१ ८८८७७७६६६६ +प्लस नौ एक एक एक एक एक एक एक एक एक एक एक~+९१ ११११११११११ +शून्य दो शून्य दो चार तीन सात एक पाँच चार दो~०२०२४३७१५४२ +शून्य एक एक दो छह एक दो तीन चार पाँच छह~०११२६१२३४५६ +चार चार दो दो आठ आठ छह छह चार चार~४४२२८८६६४४ +शून्य आठ शून्य चार एक दो तीन चार पाँच छह सात~०८०४१२३४५६७ +दो दो छह छह पांच चार तीन दो एक शून्य~२२६६५४३२१० +पाँच शून्य शून्य नौ~५००९ +एक शून्य दो शून्य~१०२० +one two three one two three five six seven eight~123-123-5678 +plus nine one one two three one two three five six seven eight~+91 123-123-5678 +plus forty four one two three one two three five six seven eight~+44 123-123-5678 +o two three one two three five six seven eight~023-123-5678 +oh two three one two three five six seven eight~023-123-5678 +double oh three one two three five six seven eight~003-123-5678 +two two five dot double five dot o dot four o~225.55.0.40 +two two five dot double five dot o dot forty five~225.55.0.45 +ssn is seven double nine one two three double one three~ssn is 799-12-3113 +seven nine nine~799 +a b nine~ab9 +a b c~a b c +five w k r a three one~5wkra31 +नंबर nine eight seven six five four three two one zero~नंबर 987-654-3210 +मोबाइल one two three four five six seven eight nine zero~मोबाइल 123-456-7890 +मोबाइल nine eight seven six five four three two one zero~मोबाइल 987-654-3210 +phone एक एक एक एक एक एक एक एक एक एक~phone ११११११११११ diff --git a/itn/hindi_english/test/data/test_cases_time.txt b/itn/hindi_english/test/data/test_cases_time.txt new file mode 100644 index 00000000..134b699b --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_time.txt @@ -0,0 +1,29 @@ +एक बजे~१:०० +दो बजे~२:०० +दस बजे~१०:०० +बारह बजे~१२:०० +एक बजके सात मिनट~१:०७ +चार बजे चवालीस मिनट~४:४४ +दस बजके तीस मिनट~१०:३० +तीन बजके पन्द्रह मिनट~३:१५ +एक बजके दस मिनट दो सेकंड~१:१०:०२ +साढ़े तीन बजे~३:३० +सवा चार बजे~४:१५ +पौने पाँच बजे~४:४५ +डेढ़ बजे~१:३० +ढाई बजे~२:३० +eight oclock g m t~08:00 gmt +seven a m e s t~07:00 a.m. est +two p m~02:00 p.m. +two thirty~02:30 +three o'clock~03:00 +quarter past one~01:15 +half past three~03:30 +eight fifty one~08:51 +eight forty~08:40 +eleven fifty five p m~11:55 p.m. +eleven forty five a m~11:45 a.m. +eleven forty six a m~11:46 a.m. +quarter to twelve~11:45 +set alarm at ten to eleven pm~set alarm at 10:50 p.m. +one min to one am~12:59 a.m. diff --git a/itn/hindi_english/test/data/test_cases_whitelist.txt b/itn/hindi_english/test/data/test_cases_whitelist.txt new file mode 100644 index 00000000..2b22a8a7 --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_whitelist.txt @@ -0,0 +1,17 @@ +डॉक्टर~डॉ. +श्रीमती~स्मि. +श्री~श्री. +श्रीमान~श्री. +कुमारी~कु. +मास्टर~मा. +पाव~१/४ +doctor dao~dr. dao +misses smith~mrs. smith +mister dao~mr. dao +saint george~st. george +i like for example ice cream~i like e.g. ice cream +s and p five hundred~S&P 500 +seven eleven stores~7-eleven stores +r t x~RTX +nvidia a one hundred~Nvidia A100 +c u d n n~cuDNN diff --git a/itn/hindi_english/test/data/test_cases_word.txt b/itn/hindi_english/test/data/test_cases_word.txt new file mode 100644 index 00000000..14977fae --- /dev/null +++ b/itn/hindi_english/test/data/test_cases_word.txt @@ -0,0 +1,19 @@ +~ +yahoo!~yahoo! +twenty!~20 ! +x ~x +—~— +aaa~aaa +aabach~aabach +aabenraa~aabenraa +aachen's~aachen's +aadri~aadri +aahar~aahar +aahh~aahh +~ +, one~, one +, one , two , three , four~, one , two , three , four +e s three~es3 +नमस्ते~नमस्ते +भारत~भारत +दुनिया~दुनिया diff --git a/itn/hindi_english/test/normalizer_test.py b/itn/hindi_english/test/normalizer_test.py new file mode 100644 index 00000000..7cfd4a8e --- /dev/null +++ b/itn/hindi_english/test/normalizer_test.py @@ -0,0 +1,211 @@ +# Copyright (c) 2026 Zhendong Peng (pzd17@tsinghua.org.cn) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from pathlib import Path + +import pytest + +from itn.hindi_english.inverse_normalizer import InverseNormalizer + +PACKAGE = Path(__file__).resolve().parent.parent + + +def original_cases(cased): + """Upstream runs ``*_cased.txt`` with cased input and every other file with lower-cased input.""" + + cases = [] + for path in sorted((Path(__file__).parent / "data").glob("test_cases_*.txt")): + if path.stem.endswith("_cased") != cased: + continue + with path.open(encoding="utf-8", newline="") as stream: + for index, line in enumerate(stream, 1): + if line.endswith("\n"): + line = line[:-1] + spoken, written = line.split("~") + cases.append(pytest.param(spoken, written, id="{}-{}".format(path.stem, index))) + return cases + + +CASES = original_cases(cased=False) +CASED_CASES = original_cases(cased=True) + + +@pytest.fixture(scope="module") +def normalizer(shared_pipeline): + return shared_pipeline(InverseNormalizer) + + +@pytest.fixture(scope="module") +def cased_normalizer(shared_pipeline): + return shared_pipeline(InverseNormalizer, cased=True) + + +def test_original_fixture_count(): + assert len(CASES) == 416 + assert len(CASED_CASES) == 70 + assert len(CASES) + len(CASED_CASES) == 486 + + +@pytest.mark.parametrize("spoken, written", CASES) +def test_original(normalizer, spoken, written): + assert normalizer.normalize(spoken) == written + + +@pytest.mark.parametrize("spoken, written", CASED_CASES) +def test_original_cased(cased_normalizer, spoken, written): + assert cased_normalizer.normalize(spoken) == written + + +@pytest.mark.parametrize("spoken, written", CASES) +def test_cased_option_keeps_lower_cased_behaviour(cased_normalizer, spoken, written): + assert cased_normalizer.normalize(spoken) == written + + +def _check_mapping(normalizer, spoken, written): + result = normalizer.normalize_with_mapping(spoken, include_identity=True) + assert result.output_text == written + for mapping in result.mappings: + assert spoken[mapping.input_start:mapping.input_end] == mapping.input_text + assert written[mapping.output_start:mapping.output_end] == mapping.output_text + + +@pytest.mark.parametrize("spoken, written", CASES) +def test_original_mapping(normalizer, spoken, written): + _check_mapping(normalizer, spoken, written) + + +@pytest.mark.parametrize("spoken, written", CASED_CASES) +def test_original_cased_mapping(cased_normalizer, spoken, written): + _check_mapping(cased_normalizer, spoken, written) + + +def test_tag_then_verbalize_matches_normalize(normalizer, cased_normalizer): + for pipeline, cases in ((normalizer, CASES), (cased_normalizer, CASED_CASES)): + for param in cases: + spoken, written = param.values + assert pipeline.verbalize(pipeline.tag(spoken)) == written + + +def _tokens(pipeline, spoken): + parser = pipeline.token_parser() + parser.parse(pipeline.tag(spoken)) + return [(token.name, token.members) for token in parser.tokens] + + +@pytest.mark.parametrize("spoken, tokens, written", [ + # Hindi rules. + ("एक लाख", [("cardinal", {"integer": "एक लाख"})], "१०००००"), + ("दसवां", [("ordinal", {"integer": "दसवां"})], "१०वां"), + ("बारह रुपये पचास पैसे", + [("money", {"value": "बारह", "currency": "रुपये", "fractional_part": "पचास", "minor_currency": "पैसे"})], + "₹१२.५०"), + ("दो सौ मीटर", [("measure", {"value": "दो सौ मीटर"})], "२०० m"), + # English rules. + ("twenty three", [("cardinal", {"integer": "twenty three"})], "23"), + ("twenty dollars", [("money", {"value": "twenty", "currency": "dollars"})], "$20"), + ("minus sixty six kilograms", [("measure", {"value": "minus sixty six", "units": "kilograms"})], "-66 kg"), + ("doctor dao", [("whitelist", {"value": "doctor"}), ("word", {"value": "dao"})], "dr. dao"), + # hi_en glue: Indian numbering, plural temperature units, comma before the year. + ("twenty one crore ninety eight lakh thirty six thousand five hundred and ninety three", + [("cardinal", {"integer": "twenty one crore ninety eight lakh thirty six thousand five hundred and ninety three"})], + "219836593"), + ("five degrees celsius", [("measure", {"value": "five", "units": "degrees celsius"})], "5 °C"), + ("the twenty fifth of july twenty twelve", + [("date", {"day": "the twenty fifth of", "month": "july", "year": "twenty twelve", "preserve_order": "true"})], + "25 july, 2012"), + ("july two thousand twelve", + [("date", {"month": "july", "year": "two thousand twelve", "preserve_order": "true"})], "july 2012"), + # Code-mixed sentences: each token is tagged by one language. + ("दस march twenty twenty", + [("date", {"value": "दस"}), ("date", {"month": "march", "year": "twenty twenty", "preserve_order": "true"})], + "१० march 2020"), + ("मैं twenty three साल का हूँ", + [("word", {"value": "मैं"}), ("cardinal", {"integer": "twenty three"}), ("word", {"value": "साल"}), + ("word", {"value": "का"}), ("word", {"value": "हूँ"})], "मैं 23 साल का हूँ"), +]) +def test_raw_field_tags(normalizer, spoken, tokens, written): + assert _tokens(normalizer, spoken) == tokens + assert normalizer.verbalize(normalizer.tag(spoken)) == written + assert normalizer.normalize(spoken) == written + + +@pytest.mark.parametrize("spoken, tokens, written", [ + ("July twenty fifth twenty twelve", + [("date", {"month": "July", "day": "twenty fifth", "year": "twenty twelve", "preserve_order": "true"})], + "July 25, 2012"), + ("The fifteenth of January", + [("date", {"day": "The fifteenth of", "month": "January", "preserve_order": "true"})], "15 January"), + ("Twenty three", [("cardinal", {"integer": "Twenty three"})], "23"), + ("Minus twenty", [("cardinal", {"negative": "Minus", "integer": "twenty"})], "-20"), + ("Twenty dollars", [("money", {"value": "Twenty", "currency": "dollars"})], "$20"), + ("Five degrees celsius", [("measure", {"value": "Five", "units": "degrees celsius"})], "5 °C"), + ("Twenty one crore", [("cardinal", {"integer": "Twenty one crore"})], "210000000"), + ("Doctor dao", [("whitelist", {"value": "Doctor"}), ("word", {"value": "dao"})], "Dr. dao"), + # Numbers up to twelve stay words, as in lower-cased input. + ("Five", [("word", {"value": "Five"})], "Five"), +]) +def test_cased_raw_field_tags(cased_normalizer, spoken, tokens, written): + assert _tokens(cased_normalizer, spoken) == tokens + assert cased_normalizer.normalize(spoken) == written + + +def test_lower_cased_pipeline_ignores_capitalized_words(normalizer): + assert normalizer.normalize("Twenty three") == "Twenty three" + assert normalizer.normalize("Twenty dollars") == "Twenty dollars" + assert normalizer.normalize("July twenty fifth twenty twelve") == "July 25th 2012" + + +@pytest.mark.parametrize("spoken", ["तेईस", "दसवां", "twenty three", "july twenty fifth twenty twelve", "दस march"]) +def test_nbest_mapping_synchronization(normalizer, spoken): + outputs = normalizer.normalize(spoken, nbest=3) + mapped = normalizer.normalize_with_mapping(spoken, nbest=3, include_identity=True) + assert [result.output_text for result in mapped] == outputs + assert len(outputs) == len(set(outputs)) + assert outputs[0] == normalizer.normalize(spoken) + for result in mapped: + for mapping in result.mappings: + assert spoken[mapping.input_start:mapping.input_end] == mapping.input_text + assert result.output_text[mapping.output_start:mapping.output_end] == mapping.output_text + + +@pytest.mark.parametrize("spoken", ['पथ\\a"b', 'path\\a"b']) +def test_escaped_word_round_trips(normalizer, cased_normalizer, spoken): + for pipeline in (normalizer, cased_normalizer): + assert [members for _, members in _tokens(pipeline, spoken)] == [{"value": spoken}] + assert pipeline.normalize(spoken) == spoken + + +def test_empty_input(normalizer, cased_normalizer): + for pipeline in (normalizer, cased_normalizer): + assert pipeline.normalize("") == "" + assert pipeline.normalize("", nbest=3) == [""] + assert pipeline.normalize_with_mapping("").output_text == "" + + +@pytest.mark.parametrize("spoken", [" ", "\t", "\n", " \t\n ", "\u00a0"]) +def test_whitespace_only_input(normalizer, spoken): + tagged = normalizer.tag(spoken) + assert _tokens(normalizer, spoken) == [("word", {"value": spoken})] + assert normalizer.verbalize(tagged) == "" + assert normalizer.normalize(spoken, nbest=3) == [""] + mapped = normalizer.normalize_with_mapping(spoken, nbest=3, include_identity=True) + assert [result.output_text for result in mapped] == [""] + assert all(mapping.output_text == "" for mapping in mapped[0].mappings) + + +def test_standalone_from_tn(): + tn_import = re.compile(r"^\s*(from|import)\s+tn\.(hindi|english|hindi_english)\b", re.MULTILINE) + for path in PACKAGE.rglob("*.py"): + assert not tn_import.search(path.read_text(encoding="utf-8")), path From fdd58c9df32bae67fbfbffb368b9fd54f164df34 Mon Sep 17 00:00:00 2001 From: pengzhendong <275331498@qq.com> Date: Sat, 31 Oct 2026 12:43:42 +0800 Subject: [PATCH 2/2] feat: register Hindi-English pipelines in CLI, packaging and README --- README.md | 7 ++++--- pyproject.toml | 1 + tn/chinese/test/cache_test.py | 3 +++ tn/cli.py | 4 ++++ tn/test/cli_test.py | 24 +++++++++++++----------- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 20fc20d2..6f9537a0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ WeTextProcessing converts between written and spoken text using weighted finite-state transducers (WFSTs), built with [Pynini](https://www.openfst.org/twiki/bin/view/GRM/Pynini) and [OpenFst](https://www.openfst.org/twiki/bin/view/FST/WebHome). - **TN and ITN** — normalize numbers, dates, times, currencies, units, and other structured expressions. -- **Multilingual rules** — Arabic, Armenian, Chinese, English, French, German, Hindi, Japanese, Korean, Portuguese, Russian, Spanish, Swedish, and Vietnamese pipelines with language-specific grammars. +- **Multilingual rules** — language-specific grammars for every language in [Supported languages](#supported-languages). - **Explainable output** — inspect raw tagged fields, trace input/output spans, and request unique n-best results. - **Reusable graphs** — automatic, configuration-aware FST caching and a separate C++ deployment runtime. @@ -44,6 +44,7 @@ ITN converts spoken expressions into written forms, for example `十二` → `12 | French | `fr` | Yes | Yes | `french` | | German | `de` | Yes | Yes | `german` | | Hindi | `hi` | Yes | Yes | `hindi` | +| Hindi–English (code-mixed) | `hi_en` | No | Yes | `hindi_english` | | Japanese | `ja` | Yes | Yes | `japanese` | | Korean | `ko` | Yes | Yes | `korean` | | Portuguese | `pt` | Yes | Yes | `portuguese` | @@ -54,7 +55,7 @@ ITN converts spoken expressions into written forms, for example `十二` → `12 Chinese is the CLI default. Rule coverage and configurable options vary by language and direction. -Arabic, Armenian, French, German, Hindi, Korean, Portuguese, Russian, Spanish, Swedish, and Vietnamese support are included in this source checkout. Install from source to use them before the next package release. +The published package includes Chinese, English, and Japanese. The other languages are included in this source checkout; install from source to use them before the next package release. ## Quick start @@ -66,7 +67,7 @@ Install the published package: pip install WeTextProcessing ``` -For the latest rules, including Arabic, Armenian, French, German, Hindi, Korean, Portuguese, Russian, Spanish, Swedish, and Vietnamese, install from source: +For the latest rules, including every language beyond Chinese, English, and Japanese, install from source: ```bash git clone https://github.com/wenet-e2e/WeTextProcessing.git diff --git a/pyproject.toml b/pyproject.toml index c2efca0a..5dd25f5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,4 +102,5 @@ itn = [ "spanish/data/*.tsv", "spanish/data/*/*.tsv", "hindi/data/*/*.tsv", + "hindi_english/data/*.tsv", ] diff --git a/tn/chinese/test/cache_test.py b/tn/chinese/test/cache_test.py index 2d7ded86..b0001c58 100644 --- a/tn/chinese/test/cache_test.py +++ b/tn/chinese/test/cache_test.py @@ -10,6 +10,7 @@ from itn.french.inverse_normalizer import InverseNormalizer as FrInverseNormalizer from itn.german.inverse_normalizer import InverseNormalizer as DeInverseNormalizer from itn.hindi.inverse_normalizer import InverseNormalizer as HiInverseNormalizer +from itn.hindi_english.inverse_normalizer import InverseNormalizer as HiEnInverseNormalizer from itn.japanese.inverse_normalizer import InverseNormalizer as JaInverseNormalizer from itn.korean.inverse_normalizer import InverseNormalizer as KoInverseNormalizer from itn.portuguese.inverse_normalizer import InverseNormalizer as PtInverseNormalizer @@ -143,6 +144,8 @@ def _build_small_graphs(processor): (EsInverseNormalizer, "es_itn", "itn", {}, {"cased": False}), (EsInverseNormalizer, "es_itn", "itn", {"cased": True}, {"cased": True}), (HiInverseNormalizer, "hi_itn", "itn", {}, {}), + (HiEnInverseNormalizer, "hi_en_itn", "itn", {}, {"cased": False}), + (HiEnInverseNormalizer, "hi_en_itn", "itn", {"cased": True}, {"cased": True}), ], ) def test_all_pipeline_configs_are_part_of_manifest( diff --git a/tn/cli.py b/tn/cli.py index 6f7be9a4..29ec3f0d 100644 --- a/tn/cli.py +++ b/tn/cli.py @@ -107,6 +107,10 @@ class PipelineSpec: (BooleanOption("cased", False, 'also accept capitalized spoken words, e.g. "Doce Dólares"'),), ), "hi": PipelineSpec("itn.hindi.inverse_normalizer", "InverseNormalizer"), + "hi_en": PipelineSpec( + "itn.hindi_english.inverse_normalizer", "InverseNormalizer", + (BooleanOption("cased", False, 'also accept capitalized English words, e.g. "July"'),), + ), "ja": PipelineSpec( "itn.japanese.inverse_normalizer", "InverseNormalizer", diff --git a/tn/test/cli_test.py b/tn/test/cli_test.py index 9acb99d5..55e1f649 100644 --- a/tn/test/cli_test.py +++ b/tn/test/cli_test.py @@ -94,6 +94,7 @@ def verbalize(self, tagged): ("itn", "sv", "itn.swedish.inverse_normalizer", "InverseNormalizer", [], {}), ("itn", "es", "itn.spanish.inverse_normalizer", "InverseNormalizer", ["--cased"], {"cased": True}), ("itn", "hi", "itn.hindi.inverse_normalizer", "InverseNormalizer", [], {}), + ("itn", "hi_en", "itn.hindi_english.inverse_normalizer", "InverseNormalizer", ["--cased"], {"cased": True}), ], ) def test_create_processor_uses_language_capabilities( @@ -163,20 +164,14 @@ def import_module(name): parse_args(other_direction, ["--language", "xx"]) -@pytest.mark.parametrize("direction", ["tn", "itn"]) -@pytest.mark.parametrize("language", [ - "zh", "en", "ja", "ko", "de", "hy", "ar", "fr", "pt", "vi", "ru", "sv", "es", "hi", +@pytest.mark.parametrize("direction, language", [ + (direction, language) for direction, languages in cli.PIPELINES.items() for language in languages ]) def test_cli_defaults_and_forwarding_match_processor_signature(monkeypatch, direction, language): args = parse_args(direction, ["--language", language]) - package = { - "zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean", "de": "german", "hy": "armenian", - "ar": "arabic", "fr": "french", "pt": "portuguese", "vi": "vietnamese", "ru": "russian", - "sv": "swedish", "es": "spanish", "hi": "hindi", - }[language] - module_suffix, class_name = ("normalizer", "Normalizer") if direction == "tn" else ( - "inverse_normalizer", "InverseNormalizer") - module = __import__("{}.{}.{}".format(direction, package, module_suffix), fromlist=[class_name]) + spec = cli.PIPELINES[direction][language] + module = __import__(spec.module, fromlist=[spec.class_name]) + class_name = spec.class_name parameters = inspect.signature(getattr(module, class_name)).parameters expected = {name: parameter.default for name, parameter in parameters.items()} assert {name: getattr(args, name) for name in expected} == expected @@ -192,6 +187,12 @@ def __init__(self, **kwargs): assert captured == expected +@pytest.mark.parametrize("language", sorted(set(cli.PIPELINES["itn"]) - set(cli.PIPELINES["tn"]))) +def test_itn_only_languages_are_rejected_for_tn(language): + with pytest.raises(SystemExit): + parse_args("tn", ["--language", language]) + + @pytest.mark.parametrize( "direction,language,present,absent", [ @@ -223,6 +224,7 @@ def __init__(self, **kwargs): ("itn", "es", "--cased", "--enable-million"), ("tn", "hi", "--cache-dir", "--deterministic"), ("itn", "hi", "--cache-dir", "--enable-million"), + ("itn", "hi_en", "--cased", "--enable-million"), ], ) def test_help_only_exposes_selected_pipeline_options(direction, language, present, absent):