diff --git a/README.md b/README.md index 951bad7..f4d3943 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ ITN converts spoken expressions into written forms, for example `十二` → `12 | English | `en` | Yes | Yes | `english` | | French | `fr` | Yes | Yes | `french` | | German | `de` | Yes | Yes | `german` | +| Hebrew | `he` | Yes | Yes | `hebrew` | | Hindi | `hi` | Yes | Yes | `hindi` | | Hindi–English (code-mixed) | `hi_en` | No | Yes | `hindi_english` | | Italian | `it` | Yes | Yes | `italian` | diff --git a/itn/hebrew/__init__.py b/itn/hebrew/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itn/hebrew/data/decimals/minutes_exception.tsv b/itn/hebrew/data/decimals/minutes_exception.tsv new file mode 100644 index 0000000..5626b71 --- /dev/null +++ b/itn/hebrew/data/decimals/minutes_exception.tsv @@ -0,0 +1,2 @@ +חצי +רבע diff --git a/itn/hebrew/data/measurements.tsv b/itn/hebrew/data/measurements.tsv new file mode 100644 index 0000000..fbd061b --- /dev/null +++ b/itn/hebrew/data/measurements.tsv @@ -0,0 +1,45 @@ +°F פרנהייט +°C צלסיוס +° מעלות +°F מעלות פרנהייט +°C מעלות צלסיוס +K קלווין +% אחוז +% אחוזים +Hz הרץ +kW קילוואט +kW קילו ואט +kW קילו וואט +kWh קילו ואט לשעה +kWh קילוואט לשעה +Wh ואט לשעה +W ואט +ghz ג׳יגה הרץ +ghz גיגה הרץ +khz קילו הרץ +mhz מגה הרץ +v וולט +nm ננומטר +mA מילי אמפר +tW טרה ואט +mv מילי וולט +mW מגה ואט +μm מיקרומטר +" אינץ׳ +cc סי סי +ω אוהם +db דציבל +db דציבלים +kb קילו ביט +mb מגה ביט +gb ג׳יגה ביט +gb גיגה ביט +tb טרה ביט +pb פטה ביט +mb מגה בייט +kb קילו בייט +gb ג׳יגה בייט +gb גיגה בייט +tb טרה בייט +pb פטה בייט +A אמפר \ No newline at end of file diff --git a/itn/hebrew/data/months.tsv b/itn/hebrew/data/months.tsv new file mode 100644 index 0000000..05415cc --- /dev/null +++ b/itn/hebrew/data/months.tsv @@ -0,0 +1,13 @@ +ינואר +פברואר +מרץ +מרס +אפריל +מאי +יוני +יולי +אוגוסט +ספטמבר +אוקטובר +נובמבר +דצמבר \ No newline at end of file diff --git a/itn/hebrew/data/months_name2number.tsv b/itn/hebrew/data/months_name2number.tsv new file mode 100644 index 0000000..651118c --- /dev/null +++ b/itn/hebrew/data/months_name2number.tsv @@ -0,0 +1,12 @@ +ינואר 1 +פברואר 2 +מרץ 3 +אפריל 4 +מאי 5 +יוני 6 +יולי 7 +אוגוסט 8 +ספטמבר 9 +אוקטובר 10 +נובמבר 11 +דצמבר 12 \ No newline at end of file diff --git a/itn/hebrew/data/months_ordinal2number.tsv b/itn/hebrew/data/months_ordinal2number.tsv new file mode 100644 index 0000000..e75a452 --- /dev/null +++ b/itn/hebrew/data/months_ordinal2number.tsv @@ -0,0 +1,12 @@ +ראשון 1 +שני 2 +שלישי 3 +רביעי 4 +חמישי 5 +שישי 6 +שביעי 7 +שמיני 8 +תשיעי 9 +עשירי 10 +אחת עשרה 11 +שתיים עשרה 12 \ No newline at end of file diff --git a/itn/hebrew/data/numbers/decimal_fractions.tsv b/itn/hebrew/data/numbers/decimal_fractions.tsv new file mode 100644 index 0000000..d883164 --- /dev/null +++ b/itn/hebrew/data/numbers/decimal_fractions.tsv @@ -0,0 +1,6 @@ +חצי 5 +רבע 25 +שלושת רבעי 75 +עשירית 1 +שתי עשיריות 2 +חמישית 2 \ No newline at end of file diff --git a/itn/hebrew/data/numbers/digit.tsv b/itn/hebrew/data/numbers/digit.tsv new file mode 100644 index 0000000..68c02dd --- /dev/null +++ b/itn/hebrew/data/numbers/digit.tsv @@ -0,0 +1,20 @@ +אחד 1 +שניים 2 +שני 2 +שלושה 3 +ארבעה 4 +חמישה 5 +שישה 6 +שבעה 7 +שמונה 8 +תשעה 9 +אחת 1 +שתיים 2 +שתים 2 +שתי 2 +שלוש 3 +ארבע 4 +חמש 5 +שש 6 +שבע 7 +תשע 9 diff --git a/itn/hebrew/data/numbers/hundreds_exception.tsv b/itn/hebrew/data/numbers/hundreds_exception.tsv new file mode 100644 index 0000000..88e54ab --- /dev/null +++ b/itn/hebrew/data/numbers/hundreds_exception.tsv @@ -0,0 +1,2 @@ +מאה 1 +מאתיים 2 \ No newline at end of file diff --git a/itn/hebrew/data/numbers/millions_exception.tsv b/itn/hebrew/data/numbers/millions_exception.tsv new file mode 100644 index 0000000..1443e5d --- /dev/null +++ b/itn/hebrew/data/numbers/millions_exception.tsv @@ -0,0 +1 @@ +מיליון 1 \ No newline at end of file diff --git a/itn/hebrew/data/numbers/teen.tsv b/itn/hebrew/data/numbers/teen.tsv new file mode 100644 index 0000000..26f1a5a --- /dev/null +++ b/itn/hebrew/data/numbers/teen.tsv @@ -0,0 +1,21 @@ +עשר 10 +אחד עשר 11 +שניים עשר 12 +שלושה עשר 13 +ארבעה עשר 14 +חמישה עשר 15 +שישה עשר 16 +שבעה עשר 17 +שמונה עשר 18 +תשעה עשר 19 +עשרה 10 +אחת עשרה 11 +שתיים עשרה 12 +שתים עשרה 12 +שלוש עשרה 13 +ארבע עשרה 14 +חמש עשרה 15 +שש עשרה 16 +שבע עשרה 17 +שמונה עשרה 18 +תשע עשרה 19 diff --git a/itn/hebrew/data/numbers/thousands.tsv b/itn/hebrew/data/numbers/thousands.tsv new file mode 100644 index 0000000..5347895 --- /dev/null +++ b/itn/hebrew/data/numbers/thousands.tsv @@ -0,0 +1,8 @@ +שלושת 3 +ארבעת 4 +חמשת 5 +ששת 6 +שבעת 7 +שמונת 8 +תשעת 9 +עשרת 10 diff --git a/itn/hebrew/data/numbers/thousands_exception.tsv b/itn/hebrew/data/numbers/thousands_exception.tsv new file mode 100644 index 0000000..dd0c71c --- /dev/null +++ b/itn/hebrew/data/numbers/thousands_exception.tsv @@ -0,0 +1,2 @@ +אלף 1 +אלפיים 2 \ No newline at end of file diff --git a/itn/hebrew/data/numbers/ties.tsv b/itn/hebrew/data/numbers/ties.tsv new file mode 100644 index 0000000..b6dd59c --- /dev/null +++ b/itn/hebrew/data/numbers/ties.tsv @@ -0,0 +1,8 @@ +עשרים 2 +שלושים 3 +ארבעים 4 +חמישים 5 +שישים 6 +שבעים 7 +שמונים 8 +תשעים 9 diff --git a/itn/hebrew/data/numbers/viable_hours.tsv b/itn/hebrew/data/numbers/viable_hours.tsv new file mode 100644 index 0000000..6a2cb13 --- /dev/null +++ b/itn/hebrew/data/numbers/viable_hours.tsv @@ -0,0 +1,15 @@ +אחד 1 +אחת 1 +שתיים 2 +שתים 2 +שלוש 3 +ארבע 4 +חמש 5 +שש 6 +שבע 7 +שמונה 8 +תשע 9 +עשר 10 +אחת עשרה 11 +שתיים עשרה 12 +שתים עשרה 12 diff --git a/itn/hebrew/data/numbers/zero.tsv b/itn/hebrew/data/numbers/zero.tsv new file mode 100644 index 0000000..a0b033c --- /dev/null +++ b/itn/hebrew/data/numbers/zero.tsv @@ -0,0 +1 @@ +אפס 0 \ No newline at end of file diff --git a/itn/hebrew/data/ordinals/digit.tsv b/itn/hebrew/data/ordinals/digit.tsv new file mode 100644 index 0000000..036e143 --- /dev/null +++ b/itn/hebrew/data/ordinals/digit.tsv @@ -0,0 +1,10 @@ +ראשון אחד +שני שניים +שלישי שלושה +רביעי ארבעה +חמישי חמישה +שישי שישה +שביעי שבעה +שמיני שמונה +תשיעי תשעה +עשירי עשרה \ No newline at end of file diff --git a/itn/hebrew/data/prefix.tsv b/itn/hebrew/data/prefix.tsv new file mode 100644 index 0000000..988d6ae --- /dev/null +++ b/itn/hebrew/data/prefix.tsv @@ -0,0 +1,17 @@ +וה +שה +ב +כ +ל +מ +ה +ו +וב +ול +ש +מה +ומ +שכ +שב +בכ +לכ \ No newline at end of file diff --git a/itn/hebrew/data/spaced_measurements.tsv b/itn/hebrew/data/spaced_measurements.tsv new file mode 100644 index 0000000..a97b034 --- /dev/null +++ b/itn/hebrew/data/spaced_measurements.tsv @@ -0,0 +1,17 @@ +ק״מ קילומטר +ק״מ קילומטרים +מ׳ מטר +מ׳ מטרים +ס״מ סנטימטר +ס״מ סנטימטרים +מ״מ מילימטר +מ״מ מילימטרים +מ״ג מיליגרם +מ״ג מיליגרמים +מ״ל מיליליטר +ק״ג קילוגרם +ק״ג קילוגרמים +קמ״ש קילומטר לשעה +קמ״ש קילומטרים לשעה +ג׳ גרם +ג׳ גרמים \ No newline at end of file diff --git a/itn/hebrew/data/time/day_suffix.tsv b/itn/hebrew/data/time/day_suffix.tsv new file mode 100644 index 0000000..a4f9d2d --- /dev/null +++ b/itn/hebrew/data/time/day_suffix.tsv @@ -0,0 +1,2 @@ +בבוקר +לפנות בוקר \ No newline at end of file diff --git a/itn/hebrew/data/time/evening_suffix.tsv b/itn/hebrew/data/time/evening_suffix.tsv new file mode 100644 index 0000000..583470a --- /dev/null +++ b/itn/hebrew/data/time/evening_suffix.tsv @@ -0,0 +1,2 @@ +בערב +לפנות ערב \ No newline at end of file diff --git a/itn/hebrew/data/time/hour_to_evening.tsv b/itn/hebrew/data/time/hour_to_evening.tsv new file mode 100644 index 0000000..4fd47d1 --- /dev/null +++ b/itn/hebrew/data/time/hour_to_evening.tsv @@ -0,0 +1,7 @@ +5 17 +6 18 +7 19 +8 20 +9 21 +10 22 +11 23 \ No newline at end of file diff --git a/itn/hebrew/data/time/hour_to_night.tsv b/itn/hebrew/data/time/hour_to_night.tsv new file mode 100644 index 0000000..d5d3646 --- /dev/null +++ b/itn/hebrew/data/time/hour_to_night.tsv @@ -0,0 +1,9 @@ +8 20 +9 21 +10 22 +11 23 +12 0 +1 1 +2 2 +3 3 +4 4 \ No newline at end of file diff --git a/itn/hebrew/data/time/hour_to_noon.tsv b/itn/hebrew/data/time/hour_to_noon.tsv new file mode 100644 index 0000000..8d0de90 --- /dev/null +++ b/itn/hebrew/data/time/hour_to_noon.tsv @@ -0,0 +1,7 @@ +12 12 +1 13 +2 14 +3 15 +4 16 +5 17 +6 18 \ No newline at end of file diff --git a/itn/hebrew/data/time/midnight_to_hour.tsv b/itn/hebrew/data/time/midnight_to_hour.tsv new file mode 100644 index 0000000..5b86a39 --- /dev/null +++ b/itn/hebrew/data/time/midnight_to_hour.tsv @@ -0,0 +1 @@ +חצות 0 \ No newline at end of file diff --git a/itn/hebrew/data/time/minute_to.tsv b/itn/hebrew/data/time/minute_to.tsv new file mode 100644 index 0000000..b7078fb --- /dev/null +++ b/itn/hebrew/data/time/minute_to.tsv @@ -0,0 +1,58 @@ +02 58 +03 57 +04 56 +05 55 +06 54 +07 53 +08 52 +09 51 +10 50 +11 49 +12 48 +13 47 +14 46 +15 45 +16 44 +17 43 +18 42 +19 41 +20 40 +21 39 +22 38 +23 37 +24 36 +25 35 +26 34 +27 33 +28 32 +29 31 +30 30 +31 29 +32 28 +33 27 +34 26 +35 25 +36 24 +37 23 +38 22 +39 21 +40 20 +41 19 +42 18 +43 17 +44 16 +45 15 +46 14 +47 13 +48 12 +49 11 +50 10 +51 09 +52 08 +53 07 +54 06 +55 05 +56 04 +57 03 +58 02 +59 01 diff --git a/itn/hebrew/data/time/minute_to_verbose.tsv b/itn/hebrew/data/time/minute_to_verbose.tsv new file mode 100644 index 0000000..8f62ae4 --- /dev/null +++ b/itn/hebrew/data/time/minute_to_verbose.tsv @@ -0,0 +1,6 @@ +רבע 45 +עשרה 50 +חמישה 55 +עשרים 40 +עשרים וחמישה 35 +דקה 59 \ No newline at end of file diff --git a/itn/hebrew/data/time/minute_verbose.tsv b/itn/hebrew/data/time/minute_verbose.tsv new file mode 100644 index 0000000..efa2207 --- /dev/null +++ b/itn/hebrew/data/time/minute_verbose.tsv @@ -0,0 +1,8 @@ +שלושת רבעי 45 +חצי 30 +רבע 15 +עשרים 20 +עשרה 10 +חמישה 05 +דקה 01 +שתי 02 \ No newline at end of file diff --git a/itn/hebrew/data/time/night_suffix.tsv b/itn/hebrew/data/time/night_suffix.tsv new file mode 100644 index 0000000..464aa81 --- /dev/null +++ b/itn/hebrew/data/time/night_suffix.tsv @@ -0,0 +1 @@ +בלילה \ No newline at end of file diff --git a/itn/hebrew/data/time/noon_suffix.tsv b/itn/hebrew/data/time/noon_suffix.tsv new file mode 100644 index 0000000..963d810 --- /dev/null +++ b/itn/hebrew/data/time/noon_suffix.tsv @@ -0,0 +1,3 @@ +בצהריים +אחרי הצהריים +אחר הצהריים \ No newline at end of file diff --git a/itn/hebrew/data/time/to_hour.tsv b/itn/hebrew/data/time/to_hour.tsv new file mode 100644 index 0000000..5689943 --- /dev/null +++ b/itn/hebrew/data/time/to_hour.tsv @@ -0,0 +1,13 @@ +אחת 12 +שתיים 1 +שלוש 2 +ארבע 3 +חמש 4 +שש 5 +שבע 6 +שמונה 7 +תשע 8 +עשר 9 +אחת עשרה 10 +שתיים עשרה 11 +חצות 23 \ No newline at end of file diff --git a/itn/hebrew/data/whitelist.tsv b/itn/hebrew/data/whitelist.tsv new file mode 100644 index 0000000..9844685 --- /dev/null +++ b/itn/hebrew/data/whitelist.tsv @@ -0,0 +1,20 @@ +אח״כ אחר כך +וכו׳ וכולי +בריה״מ ברית המועצות +ארה״ב ארצות הברית +עו״ד עורך דין +עו״ד עורכת דין +עו״ד עורכי דין +עו״ד עורכות דין +רו״ח רואה חשבון +רו״ח רואת חשבון +רו״ח רואי חשבון +רו״ח רואות חשבון +לפנה״ס לפני הספירה +ד״ר דוקטור +פרופ׳ פרופסור +אמא אימא +כל כול +מאיתנו מאתנו +ישארו יישארו +יתכן ייתכן \ No newline at end of file diff --git a/itn/hebrew/inverse_normalizer.py b/itn/hebrew/inverse_normalizer.py new file mode 100644 index 0000000..709023d --- /dev/null +++ b/itn/hebrew/inverse_normalizer.py @@ -0,0 +1,64 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 + +from itn.hebrew.rules import decimal, measure, time +from itn.hebrew.rules.cardinal import Cardinal +from itn.hebrew.rules.date import Date +from itn.hebrew.rules.decimal import Decimal +from itn.hebrew.rules.measure import Measure +from itn.hebrew.rules.punctuation import Punctuation +from itn.hebrew.rules.time import Time +from itn.hebrew.rules.whitelist import WhiteList +from itn.hebrew.rules.word import Word +from tn.processor import Processor, RuleSpec + +# Fields are tagged in spoken order; decimals, measures and times restore the +# written order before verbalization (e.g. ``רבע לשש`` -> hours, minutes). +TOKEN_ORDERS = {} +for _module in (decimal, measure, time): + TOKEN_ORDERS.update(_module.TOKEN_ORDER) + + +class InverseNormalizer(Processor): + + def __init__(self, cache_dir=None, overwrite_cache=False): + super().__init__(name="he_inverse_normalizer", ordertype="itn", token_orders=TOKEN_ORDERS) + self.build_fst("he_itn", cache_dir, overwrite_cache, {}) + + def build_tagger_and_verbalizer(self): + cardinal = Cardinal() + decimal = Decimal(cardinal) + punctuation = Punctuation() + word = Word() + rules = ( + RuleSpec(WhiteList(), 1.01), + RuleSpec(Time(cardinal), 1.1), + RuleSpec(Date(cardinal), 1.09), + RuleSpec(decimal, 1.1), + RuleSpec(Measure(cardinal, decimal), 1.1), + RuleSpec(cardinal, 1.1), + RuleSpec(word, 100), + RuleSpec(punctuation), + ) + classify = self.tagger_union(rules) + # Punctuation splits off only when attached to another token. + punct = add_weight(punctuation.tagger, 1.1) + token = punct.star + classify + punct.star + self.tagger = self.DELETE_SPACE + token + (self.DELETE_EXTRA_SPACE + token).star + 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/hebrew/rules/__init__.py b/itn/hebrew/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itn/hebrew/rules/cardinal.py b/itn/hebrew/rules/cardinal.py new file mode 100644 index 0000000..9f2753b --- /dev/null +++ b/itn/hebrew/rules/cardinal.py @@ -0,0 +1,94 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 pynini +from pynini.lib.pynutil import add_weight, delete, insert + +from itn.hebrew.rules.utils import (ALPHA_HE, DELETE_SPACE, DIGIT, PREFIX, SPACE, VSIGMA, first_field, load_labels, + string_file, trim) +from tn.processor import Processor + +DELETE_AND = delete("ו") +DELETE_OPTIONAL_AND = DELETE_AND.ques +NONZERO = (DIGIT.star + (DIGIT - "0") + DIGIT.star).optimize() +STRIP_ZEROS = (delete("0").star + (DIGIT - "0") + DIGIT.star | "0").optimize() +GROUP_BY_THREES = (pynini.closure(DIGIT, 1, 3) + (insert(",") + DIGIT**3).star).optimize() + + +class Cardinal(Processor): + """Spoken cardinals, e.g. ``כמאה עשרים וחמש`` -> ``כ-125``. + + The tagger keeps the spoken words (and an attached one-letter prefix in + its own field); the verbalizer assembles the digits in comma-separated + groups of three. + """ + + def __init__(self): + super().__init__(name="cardinal", ordertype="itn", token_orders={}) + self.build_graphs() + negative = self.tag_field("negative", pynini.accep("מינוס")) + delete(SPACE.plus) + insert(" ") + number = (negative + self.tag_field("integer", self.graph) + | negative.ques + self.tag_field("integer", self.graph_wo_small)) + self.tagger = self.add_tokens((self.tag_field("prefix", PREFIX) + insert(" ")).ques + number) + + self.verbalizer = self.delete_tokens( + first_field(self, "prefix", PREFIX + insert("-")).ques + + first_field(self, "negative", pynini.cross("מינוס", "-")).ques + + self.verbalize_field("integer", self.graph @ GROUP_BY_THREES)) + + def build_graphs(self): + zero = string_file("numbers/zero.tsv") + digit = string_file("numbers/digit.tsv") + teen = string_file("numbers/teen.tsv") + ties = string_file("numbers/ties.tsv") + ties += DELETE_SPACE + DELETE_OPTIONAL_AND + digit | insert("0", weight=0.001) + two_digit = teen | ties + self.graph_two_digit = (digit | ties | add_weight(teen, -0.001)).optimize() + + hundred = DELETE_OPTIONAL_AND + ( + string_file("numbers/hundreds_exception.tsv") + | digit + DELETE_SPACE + delete("מאות") + | insert("0", weight=0.001)) + hundred += DELETE_SPACE + hundred += (DELETE_OPTIONAL_AND + two_digit + | insert("0") + DELETE_SPACE + DELETE_AND + digit + | insert("00", weight=0.001)) + hundred = (hundred | insert("0") + two_digit | insert("00") + digit).optimize() + self.graph_hundred = trim(hundred @ NONZERO @ STRIP_ZEROS) + + large_prefix = hundred | insert("0") + two_digit | insert("00") + string_file("numbers/thousands.tsv") + delete_thousand = delete("אלפים") | delete("אלף", weight=0.001) + thousands = DELETE_OPTIONAL_AND + ( + insert("00") + string_file("numbers/thousands_exception.tsv") + | large_prefix + DELETE_SPACE + delete_thousand + | insert("000", weight=0.001)) + graph_thousands = thousands + DELETE_SPACE + hundred | zero + self.graph_thousands = trim(graph_thousands @ STRIP_ZEROS) + + delete_million = delete("מיליונים") | delete("מיליון", weight=0.001) + millions = (large_prefix + DELETE_SPACE + delete_million + | insert("00") + string_file("numbers/millions_exception.tsv") + | insert("000", weight=0.001)) + graph = millions + DELETE_SPACE + thousands + DELETE_SPACE + hundred | zero + # A leading ``ו`` is the conjunction prefix, never part of the number. + graph = ((ALPHA_HE - "ו") + VSIGMA) @ graph @ STRIP_ZEROS + self.graph = trim(graph) + + # Bare digits, ten and zero stay words unless negated or quantified. + small = sorted({row[0] for row in load_labels("numbers/digit.tsv")} | {"אפס", "עשר", "עשרה"}) + small = pynini.union(*small, *["ו" + word for word in small]).optimize() + self.graph_wo_small = ((pynini.project(self.graph, "input") - small) @ self.graph).optimize() + hours = pynini.union(*{row[0] for row in load_labels("numbers/viable_hours.tsv")}).optimize() + self.graph_wo_viable_hours = ((pynini.project(self.graph, "input") - hours) @ self.graph).optimize() diff --git a/itn/hebrew/rules/date.py b/itn/hebrew/rules/date.py new file mode 100644 index 0000000..008d11c --- /dev/null +++ b/itn/hebrew/rules/date.py @@ -0,0 +1,62 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 pynini +from pynini.lib.pynutil import add_weight, delete, insert + +from itn.hebrew.rules.utils import (DELETE_SPACE, PREFIX, SPACE, VSIGMA, accep_file, first_field, next_field, + string_file, trim) +from tn.processor import Processor + + +class Date(Processor): + """Spoken dates, e.g. ``בשבעה עשר באוגוסט עשרים שלושים`` -> ``ב-17.8.2030``. + + ``day`` keeps a cardinal or ordinal day, ``month`` keeps the month word + with its ``ב``/``ל`` prefix, ``year`` keeps the spoken year. A month name + without a day stays a word (``בינואר 2021``), and ``year_prefix`` keeps + ``שנת``/``השנה`` (``בשנת 2005``). + """ + + def __init__(self, cardinal): + super().__init__(name="date", ordertype="itn", token_orders={}) + sep = delete(SPACE.plus) + insert(" ") + ordinal = (VSIGMA + string_file("ordinals/digit.tsv")) @ cardinal.graph + day = add_weight(cardinal.graph_two_digit | ordinal, -0.7) + day = trim(day @ pynini.closure(pynini.union(*"0123456789"), 1, 2)) + delete_prefix = delete(PREFIX).ques + month_name = string_file("months_name2number.tsv") + month_any = (month_name | string_file("months_ordinal2number.tsv")).optimize() + year = (cardinal.graph_two_digit + DELETE_SPACE + cardinal.graph_two_digit | cardinal.graph_thousands) + year = trim(year) + + prefix = (self.tag_field("prefix", PREFIX) + insert(" ")).ques + tag_day = prefix + self.tag_field("day", day) + sep + tag_year = sep + self.tag_field("year", year) + dm = tag_day + self.tag_field("month", delete_prefix + month_name) + dmy = tag_day + self.tag_field("month", delete_prefix + month_any) + tag_year + my = self.tag_field("month", PREFIX.ques + accep_file("months.tsv")) + tag_year + year_prefix = PREFIX.ques + pynini.union("שנה", "שנת") + y_only = self.tag_field("year_prefix", year_prefix) + tag_year + self.tagger = self.add_tokens(dm | dmy | my | y_only) + + verbalize_day = (first_field(self, "prefix", PREFIX + insert("-")).ques + + self.verbalize_field("day", day + insert("."))) + dmy = verbalize_day + next_field(self, "month", delete_prefix + month_any) + next_field( + self, "year", insert(".") + year).ques + my = self.verbalize_field("month", PREFIX.ques + accep_file("months.tsv")) + next_field( + self, "year", insert(" ") + year) + y_only = self.verbalize_field("year_prefix", year_prefix) + next_field(self, "year", insert(" ") + year) + self.verbalizer = self.delete_tokens(dmy | my | y_only) diff --git a/itn/hebrew/rules/decimal.py b/itn/hebrew/rules/decimal.py new file mode 100644 index 0000000..5b2cbc5 --- /dev/null +++ b/itn/hebrew/rules/decimal.py @@ -0,0 +1,74 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 pynini +from pynini.lib.pynutil import add_weight, delete, insert + +from itn.hebrew.rules.cardinal import GROUP_BY_THREES +from itn.hebrew.rules.utils import DELETE_SPACE, PREFIX, SPACE, accep_file, first_field, next_field, string_file, trim +from tn.processor import Processor + +TOKEN_ORDER = {"decimal": ["prefix", "negative", "integer_part", "fractional_part", "fraction", "quantity"]} + + +class Decimal(Processor): + """Spoken decimals, e.g. ``ארבע נקודה שמונים וחמש מיליארד`` -> ``4.85 מיליארד``. + + Digits after ``נקודה`` stay in ``fractional_part``; a spoken fraction + joined with ``ו`` (``וחצי``, ``ושלושת רבעי``) stays in ``fraction``. + """ + + def __init__(self, cardinal): + super().__init__(name="decimal", ordertype="itn", token_orders=TOKEN_ORDER) + sep = delete(SPACE.plus) + insert(" ") + fractions = string_file("numbers/decimal_fractions.tsv") + minutes = accep_file("decimals/minutes_exception.tsv") + fractions_wo_minutes = ((pynini.project(fractions, "input") - minutes) @ fractions).optimize() + self.fraction = (delete("ו") + fractions).optimize() + fraction_wo_minutes = (delete("ו") + fractions_wo_minutes).optimize() + + digits = string_file("numbers/zero.tsv") | cardinal.graph_two_digit + # Fewer spoken groups win: ``שתיים עשרה`` is 12, not 2 and 10. + self.fractional = trim((digits + DELETE_SPACE + add_weight(insert(""), 0.01)).star + digits) + + negative = self.tag_field("negative", pynini.accep("מינוס")) + sep + integer = self.tag_field("integer_part", cardinal.graph) + sep + point = delete("נקודה") + delete(SPACE.plus) + with_point = integer.ques + point + self.tag_field("fractional_part", self.fractional) + # Without ``נקודה`` an hour-like integer with a minute-like fraction + # (``שתיים וחצי``) is left to the time grammar unless negated. + wo_point = (integer + self.tag_field("fraction", fraction_wo_minutes) + | self.tag_field("integer_part", cardinal.graph_wo_viable_hours) + sep + + self.tag_field("fraction", self.fraction)) + any_fraction = integer + self.tag_field("fraction", self.fraction) + self.number = (with_point | any_fraction).optimize() + quantity = pynini.union("מיליון", "מיליארד") + quantified = (self.tag_field("integer_part", cardinal.graph_hundred) + sep + + self.tag_field("quantity", quantity) + | self.number + sep + self.tag_field("quantity", quantity | "אלף")) + tagger = negative.ques + (with_point | wo_point | quantified) | negative + any_fraction + self.tagger = self.add_tokens((self.tag_field("prefix", PREFIX) + insert(" ")).ques + tagger) + + integer = cardinal.graph @ GROUP_BY_THREES + fraction = insert(".") + self.fraction + fractional = insert(".") + self.fractional + number = (first_field(self, "integer_part", integer).ques + self.verbalize_field("fractional_part", fractional) + | first_field(self, "integer_part", integer) + self.verbalize_field("fraction", fraction)) + self.verbalize_number = number.optimize() + number = (number | self.verbalize_field("integer_part", integer)) + next_field( + self, "quantity", insert(" ") + (quantity | "אלף")).ques + self.verbalizer = self.delete_tokens( + first_field(self, "prefix", PREFIX + insert("-")).ques + + first_field(self, "negative", pynini.cross("מינוס", "-")).ques + number) diff --git a/itn/hebrew/rules/measure.py b/itn/hebrew/rules/measure.py new file mode 100644 index 0000000..913daee --- /dev/null +++ b/itn/hebrew/rules/measure.py @@ -0,0 +1,53 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 pynini +from pynini.lib.pynutil import delete, insert + +from itn.hebrew.rules.cardinal import GROUP_BY_THREES +from itn.hebrew.rules.utils import PREFIX, SPACE, first_field, inverse_file, next_field +from tn.processor import Processor + +TOKEN_ORDER = { + "measure": ["prefix", "negative", "integer", "integer_part", "fractional_part", "fraction", "units"], +} + + +class Measure(Processor): + """Spoken measures, e.g. ``שתיים עשרה נקודה חמש מעלות`` -> ``12.5°``. + + Symbols from ``measurements.tsv`` attach to the number; abbreviations + from ``spaced_measurements.tsv`` follow it after a space. ``סנטימטר אחד`` + puts the unit first; the token order restores number-unit order. + """ + + def __init__(self, cardinal, decimal): + super().__init__(name="measure", ordertype="itn", token_orders=TOKEN_ORDER) + sep = delete(SPACE.plus) + insert(" ") + units = inverse_file("measurements.tsv") | insert(" ") + inverse_file("spaced_measurements.tsv") + units = units.optimize() + + negative = self.tag_field("negative", pynini.accep("מינוס")) + sep + number = decimal.number | self.tag_field("integer", cardinal.graph) + tagged_units = self.tag_field("units", units) + one = self.tag_field("integer", cardinal.graph @ "1") + tagger = negative.ques + number + sep + tagged_units | tagged_units + sep + one + self.tagger = self.add_tokens((self.tag_field("prefix", PREFIX) + insert(" ")).ques + tagger) + + number = decimal.verbalize_number | self.verbalize_field("integer", cardinal.graph @ GROUP_BY_THREES) + self.verbalizer = self.delete_tokens( + first_field(self, "prefix", PREFIX + insert("-")).ques + + first_field(self, "negative", pynini.cross("מינוס", "-")).ques + number + + next_field(self, "units", units)) diff --git a/itn/hebrew/rules/punctuation.py b/itn/hebrew/rules/punctuation.py new file mode 100644 index 0000000..d13d524 --- /dev/null +++ b/itn/hebrew/rules/punctuation.py @@ -0,0 +1,28 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 pynini + +from tn.processor import Processor + + +class Punctuation(Processor): + """ASCII punctuation split off a neighbouring token, e.g. ``עשרים,``.""" + + def __init__(self): + super().__init__(name="punctuation", ordertype="itn", token_orders={}) + graph = pynini.union(*[pynini.escape(mark) for mark in "!#$%&'()*+,-./:;<=>?@^_`{|}~"]).optimize() + self.tagger = self.add_tokens(self.tag_field("value", graph)) + self.verbalizer = self.delete_tokens(self.verbalize_field("value", graph)) diff --git a/itn/hebrew/rules/time.py b/itn/hebrew/rules/time.py new file mode 100644 index 0000000..a86bc8a --- /dev/null +++ b/itn/hebrew/rules/time.py @@ -0,0 +1,101 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 pynini +from pynini.lib.pynutil import add_weight, delete, insert + +from itn.hebrew.rules.utils import DIGIT, PREFIX, SPACE, accep_file, first_field, next_field, string_file +from tn.processor import Processor + +TOKEN_ORDER = {"time": ["prefix", "hours", "minutes", "suffix"]} + +FEMININE_UNITS = ["", "אחת", "שתיים", "שלוש", "ארבע", "חמש", "שש", "שבע", "שמונה", "תשע"] +TENS = ["", "", "עשרים", "שלושים", "ארבעים", "חמישים"] + + +def feminine(number): + """The feminine reading NeMo uses for hours and minutes (1-59).""" + + if number < 10: + return FEMININE_UNITS[number] + if number == 10: + return "עשר" + if number < 20: + return FEMININE_UNITS[number - 10] + " עשרה" + tens, units = divmod(number, 10) + return TENS[tens] + (" ו" + FEMININE_UNITS[units] if units else "") + + +class Time(Processor): + """Spoken times, e.g. ``רבע לשש בערב`` -> ``17:45 בערב``. + + ``hours`` keeps the hour word; in the "minutes to hour" form it keeps its + ``ל`` (``לשש``) and ``minutes`` precedes it in the input. ``minutes`` keeps + its ``ו`` in the "hour and minutes" form (``וחצי``) and an optional + ``דקות``. The verbalizer converts the hour to 24-hour time by ``suffix``. + """ + + def __init__(self, cardinal): + super().__init__(name="time", ordertype="itn", token_orders=TOKEN_ORDER) + sep = delete(SPACE.plus) + insert(" ") + number = add_weight(cardinal.graph, -0.7) + two_digits = pynini.closure(DIGIT, 2, 2) + midnight = string_file("time/midnight_to_hour.tsv") + hour = (pynini.union(*[feminine(n) for n in range(1, 13)]) @ number | midnight).optimize() + to_hour = (delete("ל") + string_file("time/to_hour.tsv")).optimize() + single = pynini.union(*[feminine(n) for n in range(2, 10)]) @ number @ (insert("0") + DIGIT) + double = pynini.union(*[feminine(n) for n in range(10, 60)]) @ number @ two_digits + numeric = (single | double).optimize() + minutes_word = (delete(SPACE.plus) + delete("דקות")).ques + and_minutes = delete("ו") + (numeric | string_file("time/minute_verbose.tsv")) + minutes_word + to_minutes = (string_file("time/minute_to_verbose.tsv") + | numeric @ string_file("time/minute_to.tsv") + minutes_word).optimize() + + suffixes = { + "day": (accep_file("time/day_suffix.tsv"), None), + "noon": (accep_file("time/noon_suffix.tsv"), string_file("time/hour_to_noon.tsv")), + "evening": (accep_file("time/evening_suffix.tsv"), string_file("time/hour_to_evening.tsv")), + "night": (accep_file("time/night_suffix.tsv"), string_file("time/hour_to_night.tsv")), + } + prefix = (self.tag_field("prefix", PREFIX) + insert(" ")).ques + tag_and = self.tag_field("minutes", and_minutes) + tag_to = self.tag_field("minutes", to_minutes) + tag_plain = self.tag_field("minutes", numeric) + taggers = [ + # Without a suffix only midnight and "minutes to hour" are times. + self.tag_field("hours", midnight), + self.tag_field("hours", midnight) + sep + tag_and, + tag_to + sep + self.tag_field("hours", to_hour), + ] + verbalize_minutes = (next_field(self, "minutes", insert(":") + and_minutes) + | next_field(self, "minutes", insert(":") + numeric) | insert(":00")) + verbalize_to = next_field(self, "minutes", insert(":") + to_minutes) + verbalizers = [ + self.verbalize_field("hours", hour) + verbalize_minutes, + self.verbalize_field("hours", to_hour) + verbalize_to, + ] + for words, hour_map in suffixes.values(): + plain = hour if hour_map is None else hour @ hour_map + to = to_hour if hour_map is None else to_hour @ hour_map + tag_suffix = sep + self.tag_field("suffix", words) + taggers.append((self.tag_field("hours", plain) + sep + tag_and + | tag_to + sep + self.tag_field("hours", to) + | self.tag_field("hours", plain) + (sep + tag_plain).ques) + tag_suffix) + verbalize_suffix = next_field(self, "suffix", insert(" ") + words) + verbalizers.append(self.verbalize_field("hours", plain) + verbalize_minutes + verbalize_suffix) + verbalizers.append(self.verbalize_field("hours", to) + verbalize_to + verbalize_suffix) + self.tagger = self.add_tokens(prefix + pynini.union(*taggers)) + self.verbalizer = self.delete_tokens( + first_field(self, "prefix", PREFIX + insert("-")).ques + pynini.union(*verbalizers)) diff --git a/itn/hebrew/rules/utils.py b/itn/hebrew/rules/utils.py new file mode 100644 index 0000000..e567b1e --- /dev/null +++ b/itn/hebrew/rules/utils.py @@ -0,0 +1,78 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 pynini +from pynini.lib import byte, utf8 +from pynini.lib.pynutil import delete + +from itn.utils import get_abs_path + +ALPHA_HE = pynini.union(*"אבגדהוזחטיכלמםנןסעפףצץקרשת").optimize() +SPACE = (byte.SPACE | "\u00a0").optimize() +NOT_SPACE = pynini.difference(utf8.VALID_UTF8_CHAR, SPACE).optimize() +VSIGMA = utf8.VALID_UTF8_CHAR.star.optimize() +DIGIT = byte.DIGIT +# Zero or more spaces between the words of one field, as in NeMo. +DELETE_SPACE = delete(SPACE).star +# Field values never start or end with a space: spaces around a field +# belong to the token, not to the field. +TRIMMED = (NOT_SPACE | NOT_SPACE + VSIGMA + NOT_SPACE).optimize() + + +def load_labels(rel_path): + """Loads the rows of a Hebrew data table in file order.""" + + rows = [] + with open(get_abs_path("hebrew/data/" + rel_path), encoding="utf-8") as stream: + for line in stream: + line = line.rstrip("\r\n") + if line: + rows.append(line.split("\t")) + return rows + + +def string_file(rel_path): + return pynini.string_map(load_labels(rel_path)).optimize() + + +def accep_file(rel_path): + """Accepts the first column of a table.""" + + return pynini.union(*[row[0] for row in load_labels(rel_path)]).optimize() + + +def inverse_file(rel_path): + """Maps the second column of a ``writtenspoken`` table to the first.""" + + return pynini.string_map([(row[1], row[0]) for row in load_labels(rel_path)]).optimize() + + +PREFIX = accep_file("prefix.tsv") + + +def trim(graph): + return (TRIMMED @ graph).optimize() + + +def first_field(processor, name, graph): + """Verbalizes a field that is followed by another field.""" + + return processor.verbalize_field(name, graph) + delete(" ") + + +def next_field(processor, name, graph): + """Verbalizes a field that follows another field.""" + + return processor.verbalize_field(name, graph, leading_space=True) diff --git a/itn/hebrew/rules/whitelist.py b/itn/hebrew/rules/whitelist.py new file mode 100644 index 0000000..dea2a8f --- /dev/null +++ b/itn/hebrew/rules/whitelist.py @@ -0,0 +1,30 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 itn.hebrew.rules.utils import PREFIX, inverse_file +from tn.processor import Processor + + +class WhiteList(Processor): + """Spoken phrases written as abbreviations, e.g. ``ארצות הברית`` -> ``ארה״ב``. + + An attached one-letter prefix stays on the abbreviation (``לארה״ב``). + """ + + def __init__(self): + super().__init__(name="whitelist", ordertype="itn", token_orders={}) + graph = (PREFIX.ques + inverse_file("whitelist.tsv")).optimize() + self.tagger = self.add_tokens(self.tag_field("value", graph)) + self.verbalizer = self.delete_tokens(self.verbalize_field("value", graph)) diff --git a/itn/hebrew/rules/word.py b/itn/hebrew/rules/word.py new file mode 100644 index 0000000..a5405c1 --- /dev/null +++ b/itn/hebrew/rules/word.py @@ -0,0 +1,28 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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 delete + +from tn.processor import Processor + + +class Word(Processor): + """Any other non-space run, kept verbatim.""" + + def __init__(self): + super().__init__(name="word", ordertype="itn", token_orders={}) + self.tagger = self.add_tokens(self.tag_field("value", self.NOT_SPACE.plus)) + graph = self.NOT_SPACE.plus | delete(self.SPACE.plus) + self.verbalizer = self.delete_tokens(self.verbalize_field("value", graph)) diff --git a/itn/hebrew/test/__init__.py b/itn/hebrew/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itn/hebrew/test/data/test_cases_cardinal.txt b/itn/hebrew/test/data/test_cases_cardinal.txt new file mode 100644 index 0000000..cfb6f8d --- /dev/null +++ b/itn/hebrew/test/data/test_cases_cardinal.txt @@ -0,0 +1,138 @@ +אפס~אפס +מינוס שלוש~-3 +עשר~עשר +שלוש עשרה~13 +שלושה עשר~13 +עשרים~20 +עשרים ותשע~29 +עשרים ותשעה~29 +ארבעים~40 +מינוס ארבעים ושש~-46 +שבעים ושבעה~77 +מאה~100 +מאה ואחת~101 +מאה ועשר~110 +מאה ושש עשרה~116 +מאה עשרים~120 +מאה ועשרים~120 +כמאה עשרים וחמש~כ-125 +מאתיים~200 +מאתיים ושלוש~203 +מאתיים שלושים~230 +שלוש מאות ושלושים~330 +מינוס מאתיים שישים ושבע~-267 +ארבע מאות~400 +כחמש מאות עובדים~כ-500 עובדים +חמש מאות שבעים ותשע~579 +תשע מאות תשעים~990 +תשע מאות תשעים ותשע~999 +אלף~1,000 +אלף וארבע~1,004 +אלף עשרים ושמונה~1,028 +אלף מאה וחמש~1,105 +אלף מאה שלושים~1,130 +אלף תשע מאות תשעים ואחת~1,991 +אלפיים~2,000 +אלפיים וחמש~2,005 +אלפיים ועשר~2,010 +אלפיים ואחת עשרה~2,011 +אלפיים מאה~2,100 +אלפיים מאתיים~2,200 +מינוס אלפיים מאתיים עשרים ושתיים~-2,222 +אלפיים שלוש מאות~2,300 +אלפיים ארבע מאות ושבע~2,407 +מינוס אלפיים ארבע מאות שבעים~-2,470 +מינוס אלפיים ארבע מאות שבעים וחמש~-2,475 +שלושת אלפים~3,000 +שלושת אלפים וחמש~3,005 +שלושת אלפים ועשר~3,010 +שלושת אלפים וארבע עשרה~3,014 +שלושת אלפים מאה~3,100 +שלושת אלפים מאתיים~3,200 +מינוס שלושת אלפים שבע מאות עשרים ואחת~-3,721 +שלושת אלפים שמונה מאות~3,800 +שלושת אלפים ושמונה מאות~3,800 +שלושת אלפים תשע מאות ושבע~3,907 +מינוס שלושת אלפים מאתיים ועשרים~-3,220 +חמשת אלפים~5,000 +תשעת אלפים תשע מאות תשעים ותשע~9,999 +עשרת אלפים~10,000 +עשרת אלפים ואחת~10,001 +עשרת אלפים וחמש עשרה~10,015 +עשרת אלפים ועשרים~10,020 +עשרת אלפים עשרים ושלוש~10,023 +עשרת אלפים מאתיים~10,200 +עשרת אלפים מאתיים ואחד~10,201 +עשרת אלפים מאתיים ארבעים~10,240 +עשרת אלפים מאתיים וארבעים~10,240 +עשרת אלפים שלוש מאות חמישים~10,350 +עשרת אלפים שלוש מאות וחמישים~10,350 +שתיים עשרה אלף שש מאות~12,600 +שתיים עשרה אלף ושש מאות~12,600 +שתיים עשרה אלף שש מאות ואחת~12,601 +כשמונים ושבע אלף ועשר~כ-87,010 +תשעים ותשע אלף תשע מאות תשעים ותשע~99,999 +מאה אלף~100,000 +כמאה אלף תושבים~כ-100,000 תושבים +מאה אלף ושלוש~100,003 +מאה אלף ושתיים עשרה~100,012 +מאה אלף וארבעים~100,040 +מאה אלף ארבעים ושבע~100,047 +מאה אלף וארבעים ושבע~100,047 +מאה אלף ומאה~100,100 +מאה אלף מאה~100,100 +מאה אלף מאה שלושים ושלוש~100,133 +מאה ואחד אלף~101,000 +מאה ואחד אלף ואחת~101,001 +מאה ואחד אלף ועשר~101,010 +מאה ואחד אלף ואחת עשרה~101,011 +מאה ואחד אלף מאתיים~101,200 +כמאה ואחד אלף ומאתיים~כ-101,200 +מינוס מאה ואחת אלף מאתיים ועשרים~-101,220 +מינוס מאה ואחת אלף מאתיים עשרים~-101,220 +מינוס מאה ואחת אלף מאתיים עשרים ותשע~-101,229 +מינוס מאה ואחת אלף מאתיים עשרים ותשע~-101,229 +מאה ושתיים אלף~102,000 +מאה ושלוש אלף חמש מאות~103,500 +מאה ושלוש אלף וחמש מאות~103,500 +מאה וארבע אלף חמש מאות וארבע~104,504 +מאתיים ארבעים אלף~240,000 +מאתיים וארבעים אלף~240,000 +מאתיים חמישים וחמש אלף ושש~255,006 +מאתיים חמישים וחמש אלף וארבע מאות ושש~255,406 +מאתיים חמישים וחמש אלף ארבע מאות ושש~255,406 +חמש מאות חמישים וחמש אלף~555,000 +תשע מאות תשעים ותשע אלף תשע מאות תשעים ותשע~999,999 +מיליון~1,000,000 +מיליון ואחת~1,000,001 +מיליון ועשר~1,000,010 +מיליון חמש עשרה~1,000,015 +מיליון ושבעים~1,000,070 +מיליון שבעים~1,000,070 +מיליון ארבע מאות~1,000,400 +מיליון וארבע מאות~1,000,400 +מיליון ארבע מאות עשרים~1,000,420 +מיליון ארבע מאות ועשרים~1,000,420 +מיליון אלף~1,001,000 +מיליון שלושת אלפים~1,003,000 +מיליון ואלף~1,001,000 +מיליון אלף ואחת~1,001,001 +שלושה מיליון אלף~3,001,000 +שלושה מיליוןאלף וחמש~3,001,005 +שלושה מיליון ארבעים ושלוש אלף~3,043,000 +שלושה מיליון ארבעים ושלוש אלף ואחת~3,043,001 +שלושה מיליון ארבעים ושלוש אלף ושישים ואחת~3,043,061 +שלושה מיליון ארבעים ושלוש אלף שישים ואחת~3,043,061 +שלושה מיליון חמש מאות ארבעים ושלוש אלף~3,543,000 +שלושה מיליון חמש מאות ארבעים ושלוש אלף ושבע~3,543,007 +מינוס שלושה מיליון חמש מאות ארבעים ושלוש אלף ושבע~-3,543,007 +עשר מיליון~10 מיליון +עשרה מיליון~10 מיליון +עשרים מיליון~20 מיליון +חמש עשרה מיליון~15 מיליון +שלוש עשרה מיליון ארבעים ושלוש אלף~13,043,000 +מאה מיליון~100 מיליון +מאה עשרים ושתיים מיליון~122 מיליון +מאה עשרים ושתיים מיליון ושלוש עשרה~122,000,013 +מאה עשרים ושתיים מיליון חמישים אלף ושלוש עשרה~122,050,013 +שלוש אלף~3,000 diff --git a/itn/hebrew/test/data/test_cases_date.txt b/itn/hebrew/test/data/test_cases_date.txt new file mode 100644 index 0000000..96b745d --- /dev/null +++ b/itn/hebrew/test/data/test_cases_date.txt @@ -0,0 +1,29 @@ +אחד במאי אלף תשע מאות שמונים ושלוש~1.5.1983 +השתיים עשרה לשתיים עשרה אלף תשע מאות תשעים ואחת~ה-12.12.1991 +השתיים עשרה בדצמבר אלף תשע מאות תשעים ואחת~ה-12.12.1991 +בינואר עשרים עשרים ואחת~בינואר 2021 +בשלישי לשלישי אלף תשע מאות תשעים~ב-3.3.1990 +העשירי באפריל~ה-10.4 +אחד במאי~1.5 +הראשון לחמישי~הראשון לחמישי +יוני אלפיים וחמש עשרה~יוני 2015 +ביוני אלפיים וחמש עשרה~ביוני 2015 +מתחיל בספטמבר עשרים עשרים~מתחיל בספטמבר 2020 +בשבעה עשר באוגוסט~ב-17.8 +בשבעה עשר באוגוסט עשרים שלושים~ב-17.8.2030 +בשבעה עשר לשמיני עשרים שלושים~ב-17.8.2030 +עשרים ושישי לרביעי עשרים עשרים וארבע~26.4.2024 +עשרים ושש לרביעי עשרים עשרים וארבע~26.4.2024 +עשרים ושישי לאפריל עשרים עשרים וארבע~26.4.2024 +עשרים ושש באפריל עשרים עשרים וארבע~26.4.2024 +עשרים ושישי לרביעי עשרים וארבע~26.4.24 +עשרים ושש לרביעי עשרים וארבע~26.4.24 +עשרים ושישי לאפריל עשרים וארבע~26.4.24 +עשרים ושש באפריל עשרים וארבע~26.4.24 +עשרים ושישה באפריל עשרים וארבע~26.4.24 +אנשים לא ידעו אחד מהשני~אנשים לא ידעו אחד מהשני +בשבעה באוקטובר~ב-7.10 +בשנת אלפיים וחמש~בשנת 2005 +משנת עשרים עשרים ואחת~משנת 2021 +השנה אלפיים ושלוש~השנה 2003 +שנת אלפיים וארבע~שנת 2004 \ No newline at end of file diff --git a/itn/hebrew/test/data/test_cases_decimal.txt b/itn/hebrew/test/data/test_cases_decimal.txt new file mode 100644 index 0000000..b864e26 --- /dev/null +++ b/itn/hebrew/test/data/test_cases_decimal.txt @@ -0,0 +1,66 @@ +חמש נקודה שתיים מיליון~5.2 מיליון +מאה שישים וארבע נקודה חמישים ושמונה אלף~164.58 אלף +ארבע מאות מיליון~400 מיליון +חמישים מיליארד~50 מיליארד +ארבע מאות וחמש מיליארד~405 מיליארד +ארבע נקודה שמונים וחמש מיליארד~4.85 מיליארד +מאה מיליארד~100 מיליארד +מאה ועשר מיליארד~110 מיליארד +מאה שלושים ושתיים מיליארד~132 מיליארד +אחד נקודה שמונים וארבע מיליארד~1.84 מיליארד +אחד נקודה שמונים ואחת מיליארד~1.81 מיליארד +אחד נקודה חמש תשע מיליארד~1.59 מיליארד +אחד נקודה ארבע חמש שלוש מיליארד~1.453 מיליארד +אחד נקודה שבעים ושתיים מיליארד~1.72 מיליארד +אחד נקודה שתיים חמש מיליארד~1.25 מיליארד +שלוש עשרה מיליארד~13 מיליארד +שלושים מיליארד~30 מיליארד +אלפיים שמונה מאות וחמש נקודה שמונה שבע שלוש מיליון~2,805.873 מיליון +עשרה מיליון~10 מיליון +עשר מיליון~10 מיליון +חמש מיליון~5 מיליון +חמש מאות מיליון~500 מיליון +שתיים עשרה מיליון~12 מיליון +שניים עשר מיליון~12 מיליון +שלוש עשרה מיליון~13 מיליון +ארבע מיליון~4 מיליון +ארבעים וחמש מיליון~45 מיליון +חמש עשרה מיליארד~15 מיליארד +שני מיליון~2 מיליון +שתי מיליון~2 מיליון +שמונה מיליון~8 מיליון +מינוס שישים נקודה שתיים ארבע אפס אפס~-60.2400 +אפס נקודה עשרים ושש~0.26 +אפס נקודה שתיים שש~0.26 +שישים נקודה שתיים~60.2 +שמונה עשרה נקודה שמונים וחמש~18.85 +שמונה עשרה נקודה חמש אפס~18.50 +שמונה עשרה נקודה חמישים ושש~18.56 +שמונה עשרה נקודה תשע~18.9 +שמונה עשרה נקודה אפס חמש~18.05 +שמונה עשרה נקודה שתיים עשרה~18.12 +שמונה עשרה נקודה אפס אחד~18.01 +שמונה עשרה נקודה אפס אפס אפס~18.000 +שמונה עשרה נקודה שש~18.6 +שמונה עשרה נקודה שלוש אפס אפס~18.300 +שמונה עשרה נקודה שלושים ושש~18.36 +שמונה עשרה נקודה שתיים חמש~18.25 +שמונה עשרה נקודה עשרים ושתיים~18.22 +שמונה מאות ושמונה עשרה נקודה שלוש אפס שלוש~818.303 +שמונה מאות ושמונה נקודה שמונה~808.8 +שמונה מאות ושמונה נקודה אפס~808.0 +שמונה מאות שמונים ושמונה נקודה אחד~888.1 +שמונה מאות שמונים וארבע נקודה שלוש~884.3 +שמונה מאות שמונים ושתיים נקודה שמונה~882.8 +שמונה מאות שמונים ושתיים נקודה אפס~882.0 +שמונה מאות ארבעים וחמש נקודה תשעים וארבע~845.94 +שבעים ותשע וחצי~79.5 +שתיים ורבע~שתיים ורבע +שלוש ועשירית~3.1 +מינוס שלוש וחצי~-3.5 +עשר ושתי עשיריות~10.2 +שתיים ושלושת רבעי~2.75 +שתיים עשרה אלף ושתיים עשרה נקודה שתיים עשרה~12,012.12 +שתים עשרה אלף ושתים עשרה נקודה שתים עשרה~12,012.12 +שתיים ועשירית~2.1 +אחת ועשירית~1.1 diff --git a/itn/hebrew/test/data/test_cases_measure.txt b/itn/hebrew/test/data/test_cases_measure.txt new file mode 100644 index 0000000..3d0a40a --- /dev/null +++ b/itn/hebrew/test/data/test_cases_measure.txt @@ -0,0 +1,10 @@ +מינוס חמש עשרה אחוז~-15% +חמש עשרה אחוז~15% +מינוס שתים עשרה נקודה חמש מעלות ~-12.5° +שתיים עשרה נקודה חמש מעלות~12.5° +שתיים עשרה נקודה חמש מעלות צלסיוס~12.5°C +אלף אחוזים~1,000% +אחוז אחד~1% +מאתיים חמישים גרם~250 ג׳ +סנטימטר אחד~1 ס״מ +שלוש מיליגרם~3 מ״ג diff --git a/itn/hebrew/test/data/test_cases_time.txt b/itn/hebrew/test/data/test_cases_time.txt new file mode 100644 index 0000000..0f64644 --- /dev/null +++ b/itn/hebrew/test/data/test_cases_time.txt @@ -0,0 +1,34 @@ +בשעה חמש בצהריים~בשעה 17:00 בצהריים +בחמש בצהריים~ב-17:00 בצהריים +רבע לשש בבוקר~5:45 בבוקר +בתשע בבוקר~ב-9:00 בבוקר +השעה עשרים וחמישה לאחת בצהריים~השעה 12:35 בצהריים +נפגשנו באחת ושתי דקות בצהריים~נפגשנו ב-13:02 בצהריים +נפגשנו באחת ושלוש דקות בצהריים~נפגשנו ב-13:03 בצהריים +נפגשנו באחת וחמישה בצהריים~נפגשנו ב-13:05 בצהריים +שתיים ועשרה בבוקר~2:10 בבוקר +בשעה שתיים ועשרה בצהריים~בשעה 14:10 בצהריים +בשתיים ועשרה אחרי הצהריים~ב-14:10 אחרי הצהריים +שלוש ודקה בצהריים~15:01 בצהריים +ארבע ושלוש דקות אחרי הצהריים~16:03 אחרי הצהריים +שש ועשרים דקות בערב~18:20 בערב +בשש וחצי בערב~ב-18:30 בערב +חמישה לשלוש בבוקר~2:55 בבוקר +רבע לשש בערב~17:45 בערב +שלוש בצהריים~15:00 בצהריים +אחת לפנות בוקר~1:00 לפנות בוקר +אתמול בחמש אחרי הצהריים יצאנו עם אמא למכולת ובדרך ראינו שהגן שלנו סגור~אתמול ב-17:00 אחרי הצהריים יצאנו עם אמא למכולת ובדרך ראינו שהגן שלנו סגור +חמישה לחצות~23:55 +ברבע לחצות~ב-23:45 +בשעה חצות ועשרה~בשעה 0:10 +בחצות ודקה~ב-0:01 +חצות ושתיים עשרה דקות~0:12 +שלוש דקות לחצות~23:57 +חצות ושתי דקות~0:02 +חצות~0:00 +דקה לשלוש בצהריים~14:59 בצהריים +הפגישה זזה משבע בבוקר לשמונה וחצי בבוקר~הפגישה זזה מ-7:00 בבוקר ל-8:30 בבוקר +באחת בלילה~ב-1:00 בלילה +חמש לפנות ערב~17:00 לפנות ערב +בשלוש לפנות בוקר~ב-3:00 לפנות בוקר +עשרים לחמש אחרי הצהריים~16:40 אחרי הצהריים \ No newline at end of file diff --git a/itn/hebrew/test/data/test_cases_whitelist.txt b/itn/hebrew/test/data/test_cases_whitelist.txt new file mode 100644 index 0000000..67e4d65 --- /dev/null +++ b/itn/hebrew/test/data/test_cases_whitelist.txt @@ -0,0 +1,4 @@ +בשנת שבעים לפני הספירה~בשנת 70 לפנה״ס +יש מאתיים חמישים עורכי דין חדשים~יש 250 עו״ד חדשים +ישראל היא המדינה החמישים ואחת של ארצות הברית~ישראל היא המדינה ה-51 של ארה״ב +דוקטור שמילוביץ רשם לי תרופה חדשה~ד״ר שמילוביץ רשם לי תרופה חדשה \ No newline at end of file diff --git a/itn/hebrew/test/data/test_full_sentences.txt b/itn/hebrew/test/data/test_full_sentences.txt new file mode 100644 index 0000000..1bf28b0 --- /dev/null +++ b/itn/hebrew/test/data/test_full_sentences.txt @@ -0,0 +1,56 @@ +אתמול בשעה שבע וחצי בבוקר היה לי תור לרופא~אתמול בשעה 7:30 בבוקר היה לי תור לרופא +הגעתי למרפאה בשבע ורבע בבוקר כדי לא לאחר~הגעתי למרפאה ב-7:15 בבוקר כדי לא לאחר +אמרתי לרופא שאני בן חמישים ושלוש שאני נשוי ויש לי שלושה ילדים.~אמרתי לרופא שאני בן 53 שאני נשוי ויש לי שלושה ילדים. +אמרתי לו שיש לי כאבים ביד, אז הוא בדק אותי ~אמרתי לו שיש לי כאבים ביד, אז הוא בדק אותי +הוא אמר שיש חשד לשבר באמה של בין שני סנטימטר לארבע סנטימטר ושנצטרך לעשות צילום כדי לדעת~הוא אמר שיש חשד לשבר באמה של בין 2 ס״מ ל-4 ס״מ ושנצטרך לעשות צילום כדי לדעת +בינתיים הוא רשם לי עשר מיליגרם של משככי כאבים~בינתיים הוא רשם לי 10 מ״ג של משככי כאבים +הוא אמר שזה מאוד נפוץ ושליותר מעשר אחוז מהאוכלוסיה יש את זה~הוא אמר שזה מאוד נפוץ ושליותר מ-10% מהאוכלוסיה יש את זה +הוא העריך את סיכויי ההחלמה בשמונים ושלוש נקודה שש אחוז~הוא העריך את סיכויי ההחלמה ב-83.6% +בסוף המפגש הוא קבע ביקורת לשמיני באוגוסט~בסוף המפגש הוא קבע ביקורת ל-8.8 +יש לי חמישה תפוחים~יש לי חמישה תפוחים +אף אחד לא רוצה~אף אחד לא רוצה +בכל כיתה יש עשרים, עשרים ושתיים תלמידים~בכל כיתה יש 20 , 22 תלמידים +בכל כיתה יש עשרים - עשרים ושתיים תלמידים~בכל כיתה יש 20 - 22 תלמידים +אחת עשרה אלף שבע מאות חמישים ושש~11,756 +ע"פ הנתונים החדשים שקיבלנו הייתה עלייה של שלושים נקודה שתיים עשרה אחוז במכירות~ע"פ הנתונים החדשים שקיבלנו הייתה עלייה של 30.12% במכירות +אני בטוח בזה במאה אחוז~אני בטוח בזה ב-100% +יש לזה שלושים ותשע נקודה שישים ושבע אחוז הצלחה~יש לזה 39.67% הצלחה +יהי אפסילון אפס ויהי איקס~יהי אפסילון אפס ויהי איקס +לשתינו יש שתי בנות~לשתינו יש שתי בנות +היום יום שני ומחר יום שלישי~היום יום שני ומחר יום שלישי +שלוש וחצי קילוגרם~3.5 ק״ג +חמש ורבע סנטימטר~5.25 ס״מ +שמונה ושלושת רבעי~8.75 +שמונה ורבע מיליון~8.25 מיליון +שתיים וחצי~שתיים וחצי +שתיים וחצי מיליון~2.5 מיליון +בשתיים וחצי מיליון~ב-2.5 מיליון +שתיים וחצי בבוקר~2:30 בבוקר +מינוס שלוש וחצי אחוז~-3.5% +שלוש וחצי~שלוש וחצי +עשרת אלפים ומאתיים ארבעים~10,240 +אפס מאופס~אפס מאופס +הוא מתנהג כמו אפס.... בקיצור כל עניין האפס~הוא מתנהג כמו אפס.... בקיצור כל עניין האפס +מאה שישים וארבע נקודה חמישים ושמונה אלף~164.58 אלף +הפגישה זזה משבע וחצי בבוקר לשמונה~הפגישה זזה מ-7:30 בבוקר לשמונה +על סמך זה יצאנו ביום ראשון~על סמך זה יצאנו ביום ראשון +צעירים היו בגיל שלושים שלושים וחמש~צעירים היו בגיל 30 35 +אולי שניים שלושה~אולי שניים שלושה +בן הראשון שלי שנולד~בן הראשון שלי שנולד +אנחנו היינו איזה חמישה עשר איש~אנחנו היינו איזה 15 איש +התחילו לחזור וחזרו אחד אחד~התחילו לחזור וחזרו אחד אחד +וזה היה כבר אולי שעה תשע~וזה היה כבר אולי שעה תשע +אני מדבר על שמונה עשר באפריל~אני מדבר על 18.4 +שמונה עשר בינואר~18.1 +הייתה נראית כעת שתיים עשרה שלוש עשרה~הייתה נראית כעת 12 13 +היה בערך בעשירי בעשירי למאי~היה בערך בעשירי ב-10.5 +באמצע הלילה שתיים בלילה~באמצע הלילה 2:00 בלילה +למחרת בשעה חמש~למחרת בשעה חמש +בשנת אלף תשע מאות ארבעים ושמונה~בשנת 1948 +באלף תשע מאות ארבעים ושמונה~ב-1,948 +ארבע מאות וחמישים מיליגרם~450 מ״ג +ארבע וחצי~ארבע וחצי +יהי אפסילון אפס ויהי איקס~יהי אפסילון אפס ויהי איקס +לשתינו יש שתי בנות~לשתינו יש שתי בנות +מחר בשש וחצי בבוקר נעלה על האוטובוסים ונצא לטיול השנתי. בשעה שמונה נגיע למצדה ונתחיל לטפס למעלה, נהיה שם עד אחת וחצי בצהריים, אולי רבע לשתיים, ונרד בשביל הנחש~מחר ב-6:30 בבוקר נעלה על האוטובוסים ונצא לטיול השנתי. בשעה שמונה נגיע למצדה ונתחיל לטפס למעלה, נהיה שם עד 13:30 בצהריים , אולי 1:45 , ונרד בשביל הנחש +יש לי חמישה תפוחים~יש לי חמישה תפוחים \ No newline at end of file diff --git a/itn/hebrew/test/normalizer_test.py b/itn/hebrew/test/normalizer_test.py new file mode 100644 index 0000000..e30e701 --- /dev/null +++ b/itn/hebrew/test/normalizer_test.py @@ -0,0 +1,178 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# 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.hebrew.inverse_normalizer import InverseNormalizer + +PACKAGE = Path(__file__).resolve().parent.parent + + +def original_cases(): + cases = [] + for path in sorted((Path(__file__).parent / "data").glob("test_*.txt")): + 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() + + +@pytest.fixture(scope="module") +def normalizer(shared_pipeline): + return shared_pipeline(InverseNormalizer) + + +def test_original_fixture_count(): + assert len(CASES) == 337 + assert CASES[0].values == ("אפס", "אפס") + # NeMo compares exactly; this measure input keeps its trailing space. + assert CASES[235].values == ("מינוס שתים עשרה נקודה חמש מעלות ", "-12.5°") + # Files without a trailing newline still count their last line. + assert CASES[-1].values == ("יש לי חמישה תפוחים", "יש לי חמישה תפוחים") + + +@pytest.mark.parametrize("spoken, written", CASES) +def test_original(normalizer, spoken, written): + assert normalizer.normalize(spoken) == written + + +@pytest.mark.parametrize("spoken, written", CASES) +def test_original_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 + + +def test_tag_then_verbalize_matches_normalize(normalizer): + for param in CASES: + spoken, written = param.values + assert normalizer.verbalize(normalizer.tag(spoken)) == written + + +@pytest.mark.parametrize("spoken, kind, members, written", [ + ("כמאה עשרים וחמש", "cardinal", {"prefix": "כ", "integer": "מאה עשרים וחמש"}, "כ-125"), + ("מינוס שלוש", "cardinal", {"negative": "מינוס", "integer": "שלוש"}, "-3"), + ("שלושה מיליוןאלף וחמש", "cardinal", {"integer": "שלושה מיליוןאלף וחמש"}, "3,001,005"), + ("שבעים ותשע וחצי", "decimal", {"integer_part": "שבעים ותשע", "fraction": "וחצי"}, "79.5"), + ("ארבע נקודה שמונים וחמש מיליארד", "decimal", + {"integer_part": "ארבע", "fractional_part": "שמונים וחמש", "quantity": "מיליארד"}, "4.85 מיליארד"), + ("עשר מיליון", "decimal", {"integer_part": "עשר", "quantity": "מיליון"}, "10 מיליון"), + ("בשתיים וחצי מיליון", "decimal", + {"prefix": "ב", "integer_part": "שתיים", "fraction": "וחצי", "quantity": "מיליון"}, "ב-2.5 מיליון"), + ("סנטימטר אחד", "measure", {"units": "סנטימטר", "integer": "אחד"}, "1 ס״מ"), + ("במאה אחוז", "measure", {"prefix": "ב", "integer": "מאה", "units": "אחוז"}, "ב-100%"), + ("שלוש וחצי קילוגרם", "measure", {"integer_part": "שלוש", "fraction": "וחצי", "units": "קילוגרם"}, "3.5 ק״ג"), + ("השתיים עשרה לשתיים עשרה אלף תשע מאות תשעים ואחת", "date", + {"prefix": "ה", "day": "שתיים עשרה", "month": "לשתיים עשרה", "year": "אלף תשע מאות תשעים ואחת"}, "ה-12.12.1991"), + ("בשבעה באוקטובר", "date", {"prefix": "ב", "day": "שבעה", "month": "באוקטובר"}, "ב-7.10"), + ("בינואר עשרים עשרים ואחת", "date", {"month": "בינואר", "year": "עשרים עשרים ואחת"}, "בינואר 2021"), + ("בשנת אלפיים וחמש", "date", {"year_prefix": "בשנת", "year": "אלפיים וחמש"}, "בשנת 2005"), + ("רבע לשש בערב", "time", {"minutes": "רבע", "hours": "לשש", "suffix": "בערב"}, "17:45 בערב"), + ("באחת ושתי דקות בצהריים", "time", {"prefix": "ב", "hours": "אחת", "minutes": "ושתי דקות", "suffix": "בצהריים"}, + "ב-13:02 בצהריים"), + ("חמש לפנות ערב", "time", {"hours": "חמש", "suffix": "לפנות ערב"}, "17:00 לפנות ערב"), + ("חצות ושתי דקות", "time", {"hours": "חצות", "minutes": "ושתי דקות"}, "0:02"), + ("ארצות הברית", "whitelist", {"value": "ארצות הברית"}, "ארה״ב"), + ("שלום", "word", {"value": "שלום"}, "שלום"), +]) +def test_raw_fields(normalizer, spoken, kind, members, written): + tagged = normalizer.tag(spoken) + parser = normalizer.token_parser() + parser.parse(tagged) + assert len(parser.tokens) == 1 + assert parser.tokens[0].name == kind + assert parser.tokens[0].members == members + assert normalizer.verbalize(tagged) == written + assert normalizer.normalize(spoken) == written + + +@pytest.mark.parametrize("spoken, written", [ + # The one-letter prefix stays on whitelisted abbreviations. + ("לארצות הברית", "לארה״ב"), + # A leading ו is the conjunction, kept as a prefix. + ("ושלושים", "ו-30"), + ("שלושים,", "30 ,"), + # Bare digits stay words; negated or quantified they convert. + ("חמש", "חמש"), + ("מינוס אפס", "-0"), + ("שני מיליון", "2 מיליון"), + # Times convert only when the suffix fits the hour. + ("שמונה בצהריים", "שמונה בצהריים"), + ("שמונה בערב", "20:00 בערב"), + ("שתיים לחמש", "4:58"), + ("עשר בלילה", "22:00 בלילה"), + ("אחת עשרה ועשרים בלילה", "23:20 בלילה"), +]) +def test_additional_semantics(normalizer, spoken, written): + assert normalizer.normalize(spoken) == written + + +@pytest.mark.parametrize("spoken", ["שתיים וחצי", "עשרים ושש באפריל", "רבע לשש", "מאה אלף", "אחד"]) +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 + + +def test_escaped_word_round_trips(normalizer): + spoken = 'ע"פ\\x' + parser = normalizer.token_parser() + parser.parse(normalizer.tag(spoken)) + assert [token.members for token in parser.tokens] == [{"value": spoken}] + assert normalizer.normalize(spoken) == spoken + + +def test_empty_input(normalizer): + assert normalizer.normalize("") == "" + assert normalizer.normalize("", nbest=3) == [""] + assert normalizer.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) + parser = normalizer.token_parser() + parser.parse(tagged) + assert len(parser.tokens) == 1 + assert parser.tokens[0].name == "word" + assert parser.tokens[0].members == {"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\.hebrew\b", re.MULTILINE) + for path in PACKAGE.rglob("*.py"): + assert not tn_import.search(path.read_text(encoding="utf-8")), path diff --git a/pyproject.toml b/pyproject.toml index 4d0dc87..738e8c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,8 @@ tn = [ "spanish/data/*.tsv", "spanish/data/*/*.tsv", "hindi/data/*/*.tsv", + "hebrew/data/*.tsv", + "hebrew/data/*/*.tsv", "italian/data/*.tsv", "italian/data/*/*.tsv", "marathi/data/*/*.tsv", @@ -107,6 +109,8 @@ itn = [ "spanish/data/*.tsv", "spanish/data/*/*.tsv", "hindi/data/*/*.tsv", + "hebrew/data/*.tsv", + "hebrew/data/*/*.tsv", "italian/data/*.tsv", "italian/data/*/*.tsv", "hindi_english/data/*.tsv", diff --git a/tn/chinese/test/cache_test.py b/tn/chinese/test/cache_test.py index 7a09b5b..cf84ef9 100644 --- a/tn/chinese/test/cache_test.py +++ b/tn/chinese/test/cache_test.py @@ -9,6 +9,7 @@ from itn.english.inverse_normalizer import InverseNormalizer as EnInverseNormalizer from itn.french.inverse_normalizer import InverseNormalizer as FrInverseNormalizer from itn.german.inverse_normalizer import InverseNormalizer as DeInverseNormalizer +from itn.hebrew.inverse_normalizer import InverseNormalizer as HeInverseNormalizer from itn.hindi.inverse_normalizer import InverseNormalizer as HiInverseNormalizer from itn.hindi_english.inverse_normalizer import InverseNormalizer as HiEnInverseNormalizer from itn.italian.inverse_normalizer import InverseNormalizer as ItInverseNormalizer @@ -27,6 +28,7 @@ from tn.english.normalizer import Normalizer as EnNormalizer from tn.french.normalizer import Normalizer as FrNormalizer from tn.german.normalizer import Normalizer as DeNormalizer +from tn.hebrew.normalizer import Normalizer as HeNormalizer from tn.hindi.normalizer import Normalizer as HiNormalizer from tn.italian.normalizer import Normalizer as ItNormalizer from tn.kinyarwanda.normalizer import Normalizer as RwNormalizer @@ -103,6 +105,7 @@ def _build_small_graphs(processor): (EsNormalizer, "es_tn", "tn", {}, {"deterministic": True}), (EsNormalizer, "es_tn", "tn", {"deterministic": False}, {"deterministic": False}), (HiNormalizer, "hi_tn", "tn", {}, {}), + (HeNormalizer, "he_tn", "tn", {}, {}), (ItNormalizer, "it_tn", "tn", {}, {}), (MrNormalizer, "mr_tn", "tn", {}, {}), (RwNormalizer, "rw_tn", "tn", {}, {}), @@ -153,6 +156,7 @@ def _build_small_graphs(processor): (EsInverseNormalizer, "es_itn", "itn", {}, {"cased": False}), (EsInverseNormalizer, "es_itn", "itn", {"cased": True}, {"cased": True}), (HiInverseNormalizer, "hi_itn", "itn", {}, {}), + (HeInverseNormalizer, "he_itn", "itn", {}, {}), (ItInverseNormalizer, "it_itn", "itn", {}, {}), (HiEnInverseNormalizer, "hi_en_itn", "itn", {}, {"cased": False}), (HiEnInverseNormalizer, "hi_en_itn", "itn", {"cased": True}, {"cased": True}), diff --git a/tn/cli.py b/tn/cli.py index 92c462d..a9d3a21 100644 --- a/tn/cli.py +++ b/tn/cli.py @@ -69,6 +69,7 @@ class PipelineSpec: (BooleanOption("deterministic", True, "use deterministic Spanish readings"),), ), "hi": PipelineSpec("tn.hindi.normalizer", "Normalizer"), + "he": PipelineSpec("tn.hebrew.normalizer", "Normalizer"), "it": PipelineSpec("tn.italian.normalizer", "Normalizer"), "mr": PipelineSpec("tn.marathi.normalizer", "Normalizer"), "rw": PipelineSpec("tn.kinyarwanda.normalizer", "Normalizer"), @@ -110,6 +111,7 @@ class PipelineSpec: (BooleanOption("cased", False, 'also accept capitalized spoken words, e.g. "Doce Dólares"'),), ), "hi": PipelineSpec("itn.hindi.inverse_normalizer", "InverseNormalizer"), + "he": PipelineSpec("itn.hebrew.inverse_normalizer", "InverseNormalizer"), "it": PipelineSpec("itn.italian.inverse_normalizer", "InverseNormalizer"), "hi_en": PipelineSpec( "itn.hindi_english.inverse_normalizer", "InverseNormalizer", diff --git a/tn/hebrew/__init__.py b/tn/hebrew/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tn/hebrew/data/measurements.tsv b/tn/hebrew/data/measurements.tsv new file mode 100644 index 0000000..d24b138 --- /dev/null +++ b/tn/hebrew/data/measurements.tsv @@ -0,0 +1,29 @@ +°F מעלות פרנהייט +°C מעלות צלסיוס +° מעלות +K קלווין +% אחוז +Hz הרץ +kHz קילו הרץ +MHz מגה הרץ +GHz ג׳יגה הרץ +kWh קילוואט לשעה +kW קילוואט +Wh ואט לשעה +W ואט +V וולט +mV מילי וולט +A אמפר +mA מילי אמפר +nm ננומטר +μm מיקרומטר +cc סי סי +Ω אוהם +dB דציבל +kb קילו ביט +Mb מגה ביט +Gb ג׳יגה ביט +KB קילו בייט +MB מגה בייט +GB ג׳יגה בייט +TB טרה בייט diff --git a/tn/hebrew/data/prefix.tsv b/tn/hebrew/data/prefix.tsv new file mode 100644 index 0000000..689e1f1 --- /dev/null +++ b/tn/hebrew/data/prefix.tsv @@ -0,0 +1,17 @@ +וה +שה +ב +כ +ל +מ +ה +ו +וב +ול +ש +מה +ומ +שכ +שב +בכ +לכ diff --git a/tn/hebrew/data/spaced_measurements.tsv b/tn/hebrew/data/spaced_measurements.tsv new file mode 100644 index 0000000..ade28bc --- /dev/null +++ b/tn/hebrew/data/spaced_measurements.tsv @@ -0,0 +1,16 @@ +ק״מ קילומטר +מ׳ מטר +ס״מ סנטימטר +מ״מ מילימטר +מ״ג מיליגרם +מ״ל מיליליטר +ק״ג קילוגרם +קמ״ש קילומטר לשעה +ג׳ גרם +ק"מ קילומטר +ס"מ סנטימטר +מ"מ מילימטר +מ"ג מיליגרם +מ"ל מיליליטר +ק"ג קילוגרם +קמ"ש קילומטר לשעה diff --git a/tn/hebrew/data/whitelist.tsv b/tn/hebrew/data/whitelist.tsv new file mode 100644 index 0000000..2cfb0e0 --- /dev/null +++ b/tn/hebrew/data/whitelist.tsv @@ -0,0 +1,9 @@ +אח״כ אחר כך +וכו׳ וכולי +בריה״מ ברית המועצות +ארה״ב ארצות הברית +עו״ד עורכי דין +רו״ח רואה חשבון +לפנה״ס לפני הספירה +ד״ר דוקטור +פרופ׳ פרופסור diff --git a/tn/hebrew/normalizer.py b/tn/hebrew/normalizer.py new file mode 100644 index 0000000..d127eb0 --- /dev/null +++ b/tn/hebrew/normalizer.py @@ -0,0 +1,58 @@ +# 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 + +from tn.hebrew.rules.cardinal import Cardinal +from tn.hebrew.rules.date import Date +from tn.hebrew.rules.decimal import Decimal +from tn.hebrew.rules.measure import Measure +from tn.hebrew.rules.punctuation import Punctuation +from tn.hebrew.rules.time import Time +from tn.hebrew.rules.whitelist import WhiteList +from tn.hebrew.rules.word import Word +from tn.processor import Processor, RuleSpec + +# Every Hebrew rule emits its fields in written order, so no token needs +# reordering before verbalization. +TOKEN_ORDERS = {} + + +class Normalizer(Processor): + + def __init__(self, cache_dir=None, overwrite_cache=False): + super().__init__(name="he_normalizer", token_orders=TOKEN_ORDERS) + self.build_fst("he_tn", cache_dir, overwrite_cache, {}) + + def build_tagger_and_verbalizer(self): + cardinal = Cardinal() + decimal = Decimal(cardinal) + punctuation = Punctuation() + word = Word() + rules = ( + RuleSpec(WhiteList(), 1.01), + RuleSpec(Time(cardinal), 1.1), + RuleSpec(Date(cardinal), 1.09), + RuleSpec(decimal, 1.1), + RuleSpec(Measure(cardinal, decimal), 1.1), + RuleSpec(cardinal, 1.1), + RuleSpec(word, 100, verbalize=False), + RuleSpec(punctuation, verbalize=False), + ) + classify = self.tagger_union(rules) + punct = add_weight(punctuation.tagger, 1.1) + token = punct.star + classify + punct.star + self.tagger = self.DELETE_SPACE + token + (self.DELETE_EXTRA_SPACE + token).star + self.DELETE_SPACE + self.tagger |= word.add_tokens(word.tag_field("value", self.SPACE.plus)) + self.verbalizer = self.join_tokens(self.verbalizer_union(rules), (word.verbalizer, punctuation.verbalizer)) diff --git a/tn/hebrew/rules/__init__.py b/tn/hebrew/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tn/hebrew/rules/cardinal.py b/tn/hebrew/rules/cardinal.py new file mode 100644 index 0000000..57e0f83 --- /dev/null +++ b/tn/hebrew/rules/cardinal.py @@ -0,0 +1,43 @@ +# 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 pynini +from pynini.lib.pynutil import delete, insert + +from tn.hebrew.rules import numbers +from tn.hebrew.rules.utils import PREFIX, first_field +from tn.processor import Processor + + +class Cardinal(Processor): + """Written integers, e.g. ``כ-1,000`` -> ``כאלף``. + + The tagger keeps the digits (with their commas), a ``-`` sign and a + one-letter prefix written before a hyphen; the verbalizer reads the + number and glues the prefix to it. + """ + + def __init__(self): + super().__init__(name="cardinal") + self.number = (numbers.grouped_digits() @ numbers.cardinal_graph()).optimize() + # The written side of ``number``, cheap to restrict with differences. + self.digits = pynini.project(numbers.grouped_digits(), "input").optimize() + self.prefix = self.tag_field("prefix", PREFIX) + delete("-") + insert(" ") + self.negative = self.tag_field("negative", pynini.accep("-")) + insert(" ") + self.tagger = self.add_tokens(self.prefix.ques + self.negative.ques + self.tag_field("integer", self.number)) + + self.verbalize_prefix = first_field(self, "prefix", PREFIX) + self.verbalize_negative = first_field(self, "negative", pynini.cross("-", "מינוס ")) + self.verbalizer = self.delete_tokens( + self.verbalize_prefix.ques + self.verbalize_negative.ques + self.verbalize_field("integer", self.number)) diff --git a/tn/hebrew/rules/date.py b/tn/hebrew/rules/date.py new file mode 100644 index 0000000..7af6c7f --- /dev/null +++ b/tn/hebrew/rules/date.py @@ -0,0 +1,84 @@ +# 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 pynini +from pynini.lib.pynutil import delete, insert + +from tn.hebrew.rules import numbers +from tn.hebrew.rules.utils import (DEFINITE_PREFIX, DIGIT, INDEFINITE_PREFIX, PREFIX, SPACE, first_field, next_field) +from tn.processor import Processor + +MONTHS = ["ינואר", "פברואר", "מרץ", "אפריל", "מאי", "יוני", "יולי", "אוגוסט", "ספטמבר", "אוקטובר", "נובמבר", "דצמבר"] + + +def _numbers(limit): + return {number: {str(number), "%02d" % number} for number in range(1, limit + 1)} + + +def _relabel(readings): + """Maps ``1``/``01`` style digits through a per-number reading.""" + + pairs = [(form, reading(number)) for reading, limit in readings for number, forms in _numbers(limit).items() + for form in forms] + return pynini.string_map(pairs).optimize() + + +class Date(Processor): + """Written dates, e.g. ``ב-17.8.2030`` -> ``בשבעה עשר באוגוסט עשרים שלושים``. + + Days are masculine cardinals; after a definite prefix (``ה-10.4``) days + 1-10 are ordinals (``העשירי באפריל``). A day and month without a year + need a prefix, since ``18.6`` is a decimal. Years 2020-2099 are read in + pairs (``עשרים עשרים ואחת``), other years as cardinals. + """ + + def __init__(self, cardinal): + super().__init__(name="date") + sep = delete(SPACE.plus) + insert(" ") + day = pynini.project(_relabel([(str, 31)]), "input") + month = pynini.project(_relabel([(str, 12)]), "input") + pair = pynini.string_map([("20%02d" % year, "עשרים " + numbers.group(year)) for year in range(20, 100)]) + four = (DIGIT - "0") + DIGIT**3 + two = pynini.string_map([("%02d" % year, numbers.cardinal(year) if year >= 10 else "אפס " + + numbers.FEMININE[year]) for year in range(100)]) + short = (DIGIT - "0") + pynini.closure(DIGIT, 0, 2) + year = (pair | (pynini.project(four, "input") - pynini.project(pair, "input")) @ cardinal.number | two + | (pynini.project(short, "input") - pynini.project(two, "input")) @ cardinal.number) + year = year.optimize() + long_year = (four @ year).optimize() + name = pynini.union(*MONTHS) + prefixed_name = (PREFIX.ques + name).optimize() + year_prefix = (PREFIX.ques + pynini.union("שנה", "שנת")).optimize() + + prefix = self.tag_field("prefix", PREFIX) + delete("-") + insert(" ") + numeric = (self.tag_field("day", day) + delete(".") + insert(" ") + self.tag_field("month", month)) + tag_year = self.tag_field("year", year) + dmy = prefix.ques + numeric + delete(".") + insert(" ") + tag_year + dm = prefix + numeric + my = self.tag_field("month", prefixed_name) + sep + self.tag_field("year", long_year) + y_only = self.tag_field("year_prefix", year_prefix) + sep + tag_year + self.tagger = self.add_tokens(dmy | dm | my | y_only) + + masculine = _relabel([(lambda number: numbers.group(number, masculine=True), 31)]) + definite = _relabel([(lambda number: numbers.ORDINALS[number], 10)]) | ( + pynini.project(masculine, "input") - pynini.project(_relabel([(str, 10)]), "input")) @ masculine + month_name = pynini.string_map([(form, " ב" + MONTHS[number - 1]) for number, forms in _numbers(12).items() + for form in forms]) + day = (first_field(self, "prefix", DEFINITE_PREFIX) + self.verbalize_field("day", definite) + | first_field(self, "prefix", INDEFINITE_PREFIX).ques + self.verbalize_field("day", masculine)) + year = next_field(self, "year", insert(" ") + year) + dmy = day + next_field(self, "month", month_name) + year.ques + my = self.verbalize_field("month", prefixed_name) + year + y_only = self.verbalize_field("year_prefix", year_prefix) + year + self.verbalizer = self.delete_tokens(dmy | my | y_only) diff --git a/tn/hebrew/rules/decimal.py b/tn/hebrew/rules/decimal.py new file mode 100644 index 0000000..13034e5 --- /dev/null +++ b/tn/hebrew/rules/decimal.py @@ -0,0 +1,71 @@ +# 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 pynini +from pynini.lib.pynutil import delete, insert + +from tn.hebrew.rules import numbers +from tn.hebrew.rules.utils import DIGIT, NONZERO, SPACE, first_field +from tn.processor import Processor + +# Halves, quarters and tenths of 1-9 are read as fractions. +FRACTIONS = {"5": "חצי", "25": "רבע", "75": "שלושת רבעי", "1": "עשירית"} + + +class Decimal(Processor): + """Written decimals, e.g. ``4.85 מיליארד`` -> ``ארבע נקודה שמונים וחמש מיליארד``. + + The tagger keeps the integer digits, the digits after the point and an + optional quantity word. ``1-9`` with ``.5``/``.25``/``.75``/``.1`` read + as fractions (``שבעים ותשע וחצי``); otherwise the point is ``נקודה`` and a + two-digit fraction without zeros is read as a number. + """ + + def __init__(self, cardinal): + super().__init__(name="decimal") + sep = delete(SPACE.plus) + insert(" ") + integer = cardinal.number + fractional = DIGIT.plus + quantity = pynini.union("מיליון", "מיליארד", "אלף") + self.number = ((self.tag_field("integer_part", integer) + insert(" ")).ques + delete(".") + + self.tag_field("fractional_part", fractional)) + self.number = self.number.optimize() + quantified = (self.number + sep + self.tag_field("quantity", quantity) + | self.tag_field("integer_part", integer) + sep + + self.tag_field("quantity", pynini.union("מיליון", "מיליארד"))) + self.tagger = self.add_tokens(cardinal.prefix.ques + cardinal.negative.ques + (self.number | quantified)) + + small = pynini.union(*[str(number) for number in range(1, 10)]).optimize() + special = pynini.union(*FRACTIONS).optimize() + as_fraction = pynini.string_map([(digits, " ו" + word) for digits, word in FRACTIONS.items()]) + whole = (pynini.cross("1", "אחד") | (cardinal.digits - "1") @ integer) + insert(" ") + large = (cardinal.digits - small) @ whole + two_digits = NONZERO + NONZERO + digits = (two_digits @ numbers.cardinal_graph() + | (pynini.project(fractional, "input") - two_digits) @ numbers.digit_by_digit()) + point = insert("נקודה ") + digits + fraction = self.verbalize_field("fractional_part", as_fraction) + number = (first_field(self, "integer_part", small @ integer) + fraction + | first_field(self, "integer_part", whole).ques + + self.verbalize_field("fractional_part", (pynini.project(fractional, "input") - special) @ point) + | first_field(self, "integer_part", large) + self.verbalize_field("fractional_part", special @ point) + | self.verbalize_field("fractional_part", special @ point)) + self.verbalize_number = number.optimize() + counted = (pynini.cross("1", "") | pynini.cross("2", "שני ") + | (cardinal.digits - "1" - "2") @ integer + insert(" ")) + quantity_field = self.verbalize_field("quantity", quantity) + number = (self.verbalize_number + insert(" ") + delete(" ") + quantity_field + | first_field(self, "integer_part", counted) + quantity_field) + self.verbalizer = self.delete_tokens( + cardinal.verbalize_prefix.ques + cardinal.verbalize_negative.ques + (self.verbalize_number | number)) diff --git a/tn/hebrew/rules/measure.py b/tn/hebrew/rules/measure.py new file mode 100644 index 0000000..f28cad7 --- /dev/null +++ b/tn/hebrew/rules/measure.py @@ -0,0 +1,56 @@ +# 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 pynini +from pynini.lib.pynutil import delete, insert + +from tn.hebrew.rules import numbers +from tn.hebrew.rules.utils import SPACE, next_field, string_file +from tn.processor import Processor + + +class Measure(Processor): + """Written measures, e.g. ``12.5°C`` -> ``שתיים עשרה וחצי מעלות צלסיוס``. + + Symbols such as ``%`` may attach to the number; abbreviations such as + ``ס״מ`` follow it after a space. One unit is ``סנטימטר אחד`` and two is + ``שני סנטימטר``. + """ + + def __init__(self, cardinal, decimal): + super().__init__(name="measure") + joined = string_file("measurements.tsv") + spaced = string_file("spaced_measurements.tsv") + units = (joined | spaced).optimize() + tag_units = (insert(" ") + self.tag_field("units", joined) + | delete(SPACE.plus) + insert(" ") + self.tag_field("units", units)) + number = decimal.number | self.tag_field("integer", cardinal.number) + self.tagger = self.add_tokens(cardinal.prefix.ques + cardinal.negative.ques + number + tag_units) + + integer = cardinal.number + counted = pynini.cross("2", "שני") | (cardinal.digits - "1" - "2") @ integer + unit = next_field(self, "units", insert(" ") + units) + negative = cardinal.verbalize_negative + (decimal.verbalize_number | self.verbalize_field("integer", integer)) + positive = decimal.verbalize_number | self.verbalize_field("integer", counted) + one = (self.verbalize_field("integer", pynini.cross("1", "")) + delete(" ") + + self.verbalize_field("units", units + insert(" אחד"))) + self.verbalizer = self.delete_tokens(cardinal.verbalize_prefix.ques + ((negative | positive) + unit | one)) + + people = (numbers.grouped_digits() @ ((cardinal.digits - "1" - "2") @ numbers.masculine_graph(999))).optimize() + people_t = (self.tag_field("integer", people) + delete(SPACE.plus) + insert(" ") + + self.tag_field("units", pynini.accep("איש"))) + self.tagger |= self.add_tokens(cardinal.prefix.ques + cardinal.negative.ques + people_t) + people_v = self.verbalize_field("integer", people) + next_field(self, "units", insert(" ") + pynini.accep("איש")) + self.verbalizer |= self.delete_tokens( + cardinal.verbalize_prefix.ques + cardinal.verbalize_negative.ques + people_v) diff --git a/tn/hebrew/rules/numbers.py b/tn/hebrew/rules/numbers.py new file mode 100644 index 0000000..498ceaf --- /dev/null +++ b/tn/hebrew/rules/numbers.py @@ -0,0 +1,135 @@ +# 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. +"""Hebrew number words. + +Counting uses the feminine forms (``שלוש``, ``שלוש עשרה``). ``ו`` joins the +last word of a number when that word is a unit, a teen or ten, or round tens +that directly follow a thousand/million word: ``מאה ושש עשרה``, ``אלף וארבע``, +``עשרת אלפים ועשרים``, but ``מאתיים שלושים`` and ``אלפיים מאה``. The thousand +and million words are masculine, so a final ``אחד`` counts them (``מאה ואחד +אלף``) and a single-digit million count is masculine (``שלושה מיליון``). +""" + +import pynini +from pynini.lib.pynutil import delete + +from tn.hebrew.rules.utils import DIGIT, NONZERO + +FEMININE = ["אפס", "אחת", "שתיים", "שלוש", "ארבע", "חמש", "שש", "שבע", "שמונה", "תשע"] +MASCULINE = ["אפס", "אחד", "שניים", "שלושה", "ארבעה", "חמישה", "שישה", "שבעה", "שמונה", "תשעה"] +TENS = ["", "", "עשרים", "שלושים", "ארבעים", "חמישים", "שישים", "שבעים", "שמונים", "תשעים"] +HUNDREDS = ["", "מאה", "מאתיים"] + [FEMININE[digit] + " מאות" for digit in range(3, 10)] +THOUSANDS = {1: "אלף", 2: "אלפיים", 3: "שלושת אלפים", 4: "ארבעת אלפים", 5: "חמשת אלפים", 6: "ששת אלפים", + 7: "שבעת אלפים", 8: "שמונת אלפים", 9: "תשעת אלפים", 10: "עשרת אלפים"} +ORDINALS = ["", "ראשון", "שני", "שלישי", "רביעי", "חמישי", "שישי", "שביעי", "שמיני", "תשיעי", "עשירי"] + + +def _below_hundred(number, masculine=False): + units = MASCULINE if masculine else FEMININE + if number < 10: + return [("unit", units[number])] if number else [] + if number == 10: + return [("unit", "עשרה" if masculine else "עשר")] + if number < 20: + return [("unit", units[number - 10] + (" עשר" if masculine else " עשרה"))] + tens, unit = divmod(number, 10) + return [("tens", TENS[tens])] + ([("unit", units[unit])] if unit else []) + + +def group(number, higher=False, masculine=False, counts_masculine=False): + """Reads 0-999; ``higher`` tells that a thousand/million word precedes.""" + + hundreds, rest = divmod(number, 100) + pieces = [("hundreds", HUNDREDS[hundreds])] if hundreds else [] + pieces += _below_hundred(rest, masculine) + if counts_masculine and rest % 10 == 1 and rest != 11: + pieces[-1] = ("unit", "אחד") + if pieces and (len(pieces) > 1 or higher): + kind, word = pieces[-1] + if kind == "unit" or kind == "tens" and len(pieces) == 1: + pieces[-1] = (kind, "ו" + word) + return " ".join(word for _, word in pieces) + + +def thousands(number): + return THOUSANDS.get(number) or group(number, counts_masculine=True) + " אלף" + + +def millions(number): + if number == 1: + return "מיליון" + if number == 2: + return "שני מיליון" + if number < 10: + return MASCULINE[number] + " מיליון" + return group(number, counts_masculine=True) + " מיליון" + + +def cardinal(number): + """Reads 0-999,999,999.""" + + if number == 0: + return "אפס" + high, rest = divmod(number, 1000000) + middle, low = divmod(rest, 1000) + words = [millions(high)] if high else [] + if middle: + words.append(thousands(middle)) + if low: + words.append(group(low, higher=bool(words))) + return " ".join(words) + + +def _map(pairs): + return pynini.string_map(pairs).optimize() + + +def _padded(reader): + """Maps a zero-padded group to `` ``, or ``000`` to nothing.""" + + return _map([("%03d" % number, " " + reader(number) if number else "") for number in range(1000)]) + + +def cardinal_graph(): + """Maps digits without leading zeros (and ``0``) to the cardinal reading.""" + + small = _map([(str(number), cardinal(number)) for number in range(1000)]) + tail = _padded(lambda number: group(number, higher=True)) + many_thousands = _map([(str(number), thousands(number)) for number in range(1, 1000)]) + many_millions = _map([(str(number), millions(number)) for number in range(1, 1000)]) + graph = small | many_thousands + tail | many_millions + _padded(thousands) + tail + return graph.optimize() + + +def grouped_digits(): + """Accepts ``1,000`` or ``1000`` style integers and deletes the commas.""" + + plain = NONZERO + DIGIT.star | "0" + grouped = NONZERO + pynini.closure(DIGIT, 0, 2) + (delete(",") + DIGIT**3).plus + return (plain | grouped).optimize() + + +def masculine_graph(limit): + return _map([(str(number), group(number, masculine=True)) for number in range(1, limit + 1)]) + + +def ordinal_graph(): + return _map([(str(number), ORDINALS[number]) for number in range(1, 11)]) + + +def digit_by_digit(): + """``0453`` -> ``אפס ארבע חמש שלוש``; a lone one is ``אחד``.""" + + digit = _map([(str(number), "אחד" if number == 1 else FEMININE[number]) for number in range(10)]) + return (digit + (pynini.cross("", " ") + digit).star).optimize() diff --git a/tn/hebrew/rules/punctuation.py b/tn/hebrew/rules/punctuation.py new file mode 100644 index 0000000..29c7d47 --- /dev/null +++ b/tn/hebrew/rules/punctuation.py @@ -0,0 +1,29 @@ +# 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 pynini +from pynini.lib.pynutil import add_weight + +from tn.processor import Processor + + +class Punctuation(Processor): + """ASCII punctuation split off a neighbouring token, e.g. ``20,``.""" + + def __init__(self): + super().__init__(name="punctuation") + graph = pynini.union(*[pynini.escape(mark) for mark in "!#$%&'()*+,-./:;<=>?@^_`{|}~"]).optimize() + space = add_weight(self.SPACE, -0.0001).star + self.tagger = self.add_tokens(self.tag_field("value", space + graph + space)) + self.verbalizer = self.delete_tokens(self.verbalize_field("value", self.SPACE.star + graph + self.SPACE.star)) diff --git a/tn/hebrew/rules/time.py b/tn/hebrew/rules/time.py new file mode 100644 index 0000000..cd56bdb --- /dev/null +++ b/tn/hebrew/rules/time.py @@ -0,0 +1,94 @@ +# 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 pynini +from pynini.lib.pynutil import delete, insert + +from tn.hebrew.rules.numbers import FEMININE, group +from tn.hebrew.rules.utils import SPACE, next_field +from tn.processor import Processor + +# Day-part words and the 24-hour hours each one can describe. +SUFFIXES = { + "day": (("בבוקר", "לפנות בוקר"), range(0, 13)), + "noon": (("בצהריים", "אחרי הצהריים", "אחר הצהריים"), range(12, 19)), + "evening": (("בערב", "לפנות ערב"), range(17, 24)), + "night": (("בלילה", ), (20, 21, 22, 23, 0, 1, 2, 3, 4)), +} +# Minutes read before the next hour: ``רבע לשש``. +MINUTES_TO = {35: "עשרים וחמישה", 40: "עשרים", 45: "רבע", 50: "עשרה", 55: "חמישה", 59: "דקה"} +# Minutes read after the hour: ``שש וחצי``. +MINUTES_PAST = {1: "דקה", 2: "שתי דקות", 5: "חמישה", 10: "עשרה", 15: "רבע", 30: "חצי"} + + +def hour_words(hour, twelve_hour): + if hour % 24 == 0: + return "חצות" + if twelve_hour: + hour = (hour - 1) % 12 + 1 + return group(hour) + + +def time_words(hour, minute, twelve_hour): + if minute in MINUTES_TO or minute > 50: + if minute in MINUTES_TO: + before = MINUTES_TO[minute] + else: + before = ("שתי" if minute == 58 else FEMININE[60 - minute]) + " דקות" + return before + " ל" + hour_words(hour + 1, twelve_hour) + words = hour_words(hour, twelve_hour) + if minute in MINUTES_PAST: + return words + " ו" + MINUTES_PAST[minute] + if minute: + return words + " ו" + group(minute) + " דקות" + return words + + +def time_map(hours, twelve_hour): + pairs = [] + for hour in hours: + for minute in range(60): + words = time_words(hour, minute, twelve_hour) + for written in {str(hour), "%02d" % hour}: + pairs.append(('hours: "{}" minutes: "{:02d}"'.format(written, minute), words)) + return pynini.string_map(pairs).optimize() + + +def hour_field(hours): + return pynini.union(*{form for hour in hours for form in (str(hour), "%02d" % hour)}).optimize() + + +class Time(Processor): + """Written times, e.g. ``17:45 בערב`` -> ``רבע לשש בערב``. + + The tagger keeps the hour and minute digits and the day-part word; the + verbalizer reads a 12-hour clock when a day-part word fits the hour and a + 24-hour clock otherwise, with minutes 35-59 before the next hour. + """ + + def __init__(self, cardinal): + super().__init__(name="time") + minutes = pynini.union(*["%02d" % minute for minute in range(60)]).optimize() + clock = (self.tag_field("hours", hour_field(range(24))) + delete(":") + insert(" ") + + self.tag_field("minutes", minutes)) + taggers = [clock] + verbalizers = [time_map(range(24), twelve_hour=False)] + for words, hours in SUFFIXES.values(): + words = pynini.union(*words) + clock = (self.tag_field("hours", hour_field(hours)) + delete(":") + insert(" ") + + self.tag_field("minutes", minutes)) + taggers.append(clock + delete(SPACE.plus) + insert(" ") + self.tag_field("suffix", words)) + verbalizers.append(time_map(hours, twelve_hour=True) + next_field(self, "suffix", insert(" ") + words)) + self.tagger = self.add_tokens(cardinal.prefix.ques + pynini.union(*taggers)) + self.verbalizer = self.delete_tokens(cardinal.verbalize_prefix.ques + pynini.union(*verbalizers)) diff --git a/tn/hebrew/rules/utils.py b/tn/hebrew/rules/utils.py new file mode 100644 index 0000000..292e087 --- /dev/null +++ b/tn/hebrew/rules/utils.py @@ -0,0 +1,59 @@ +# 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 pynini +from pynini.lib import byte, utf8 +from pynini.lib.pynutil import delete + +from tn.utils import get_abs_path + +SPACE = (byte.SPACE | "\u00a0").optimize() +DIGIT = byte.DIGIT +NONZERO = pynini.difference(DIGIT, "0").optimize() +VSIGMA = utf8.VALID_UTF8_CHAR.star.optimize() + + +def load_labels(rel_path): + """Loads the ``writtenspoken`` rows of a Hebrew data table in file order.""" + + rows = [] + with open(get_abs_path("hebrew/data/" + rel_path), encoding="utf-8") as stream: + for line in stream: + line = line.rstrip("\r\n") + if line: + rows.append(line.split("\t")) + return rows + + +def string_file(rel_path): + return pynini.string_map([(pynini.escape(row[0]), row[1]) for row in load_labels(rel_path)]).optimize() + + +PREFIX = pynini.union(*[row[0] for row in load_labels("prefix.tsv")]).optimize() +# Prefixes that carry (or contract) the definite article read days 1-10 as +# ordinals: ``ה-10.4`` -> ``העשירי באפריל``. +DEFINITE_PREFIX = pynini.union(*[row[0] for row in load_labels("prefix.tsv") if row[0][-1] in "הל"]).optimize() +INDEFINITE_PREFIX = pynini.difference(PREFIX, DEFINITE_PREFIX).optimize() + + +def first_field(processor, name, graph): + """Verbalizes a field that is followed by another field.""" + + return processor.verbalize_field(name, graph) + delete(" ") + + +def next_field(processor, name, graph): + """Verbalizes a field that follows another field.""" + + return processor.verbalize_field(name, graph, leading_space=True) diff --git a/tn/hebrew/rules/whitelist.py b/tn/hebrew/rules/whitelist.py new file mode 100644 index 0000000..e1b4490 --- /dev/null +++ b/tn/hebrew/rules/whitelist.py @@ -0,0 +1,26 @@ +# 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 tn.hebrew.rules.utils import PREFIX, string_file +from tn.processor import Processor + + +class WhiteList(Processor): + """Abbreviations read in full, e.g. ``לארה״ב`` -> ``לארצות הברית``.""" + + def __init__(self): + super().__init__(name="whitelist") + graph = (PREFIX.ques + string_file("whitelist.tsv")).optimize() + self.tagger = self.add_tokens(self.tag_field("value", graph)) + self.verbalizer = self.delete_tokens(self.verbalize_field("value", graph)) diff --git a/tn/hebrew/rules/word.py b/tn/hebrew/rules/word.py new file mode 100644 index 0000000..43a8e1a --- /dev/null +++ b/tn/hebrew/rules/word.py @@ -0,0 +1,27 @@ +# 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 delete + +from tn.processor import Processor + + +class Word(Processor): + """Any other non-space run, kept verbatim.""" + + def __init__(self): + super().__init__(name="word") + self.tagger = self.add_tokens(self.tag_field("value", self.NOT_SPACE.plus)) + graph = self.NOT_SPACE.plus | delete(self.SPACE.plus) + self.verbalizer = self.delete_tokens(self.verbalize_field("value", graph)) diff --git a/tn/hebrew/test/__init__.py b/tn/hebrew/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tn/hebrew/test/normalizer_test.py b/tn/hebrew/test/normalizer_test.py new file mode 100644 index 0000000..f988593 --- /dev/null +++ b/tn/hebrew/test/normalizer_test.py @@ -0,0 +1,245 @@ +# 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 collections import defaultdict +from pathlib import Path + +import pytest + +from tn.hebrew.normalizer import Normalizer + +PACKAGE = Path(__file__).resolve().parent.parent +ITN_DATA = PACKAGE.parent.parent / "itn" / "hebrew" / "test" / "data" + +# Reversed ITN pairs whose spoken side TN cannot produce. The fixtures read +# the same construction in different ways, so one canonical reading must +# miss some of them. +EXCLUDED = { + "77": "masculine units (שבעים ושבעה); counting is feminine elsewhere", + "330": "ו before round tens after hundreds; 230, 990 and 1,130 omit it", + "-3,220": "ו before round tens after hundreds; 230, 990 and 1,130 omit it", + "450 מ״ג": "ו before round tens after hundreds; 230, 990 and 1,130 omit it", + "כ-101,200": "ו before hundreds after thousands; 2,100 and 10,200 omit it", + "1,000,015": "no ו before a final teen; 2,011 and 10,015 use it", + "10,201": "masculine final one (ואחד); counting is feminine elsewhere", + "-101,220": "feminine one before אלף; 101,000 and 101,200 use ואחד אלף", + "-101,229": "feminine one before אלף; 101,000 and 101,200 use ואחד אלף", + "3,001,005": "the only spoken form glues מיליוןאלף without a space", + "1,000%": "plural אחוזים; every other percentage uses אחוז", + "79.5": "fraction word for an integer above 9; 12.5 and 18.25 use נקודה", + "10.2": "fraction word for .2; 5.2 and 60.2 use נקודה", + "1.25 מיליארד": "נקודה for .25 of 1; 3.25-style amounts use ורבע (5.25, 8.25)", + "1.59 מיליארד": "digit-by-digit .59; 1.84 and 18.56 read two digits as a number", + "18.25": "digit-by-digit .25; 18.85 and 18.22 read two digits as a number", + "-12.5°": "spelling שתים and a trailing space in the spoken form", + "ה-12.12.1991": "feminine day (השתיים עשרה); days are masculine (שבעה עשר באוגוסט)", + "26.4.2024": "feminine or ordinal day; days are masculine (26.4.24 accepts עשרים ושישה)", + "ב-3.3.1990": "ordinal day and ordinal month (בשלישי לשלישי)", + "היה בערך בעשירי ב-10.5": "ordinal day after ב and month with ל (בעשירי למאי)", + "1.5": "day.month without year or prefix is a decimal (1.1, 18.6)", + "18.1": "day.month without year or prefix is a decimal (1.1, 18.6)", + "אני מדבר על 18.4": "day.month without year or prefix is a decimal (1.1, 18.6)", + "אמרתי לו שיש לי כאבים ביד, אז הוא בדק אותי": "the spoken form ends with a space", +} + + +def reversed_cases(): + candidates = defaultdict(set) + for path in sorted(ITN_DATA.glob("test_*.txt")): + with path.open(encoding="utf-8", newline="") as stream: + for line in stream: + if line.endswith("\n"): + line = line[:-1] + spoken, written = line.split("~") + candidates[written].add(spoken) + return candidates + + +CANDIDATES = reversed_cases() +CASES = [pytest.param(written, spoken, id=written[:40]) for written, spoken in CANDIDATES.items() + if written not in EXCLUDED] + + +@pytest.fixture(scope="module") +def normalizer(shared_pipeline): + return shared_pipeline(Normalizer) + + +def test_reversed_fixture_count(): + # 337 ITN lines hold 330 distinct pairs. + assert sum(len(spoken) for spoken in CANDIDATES.values()) == 330 + assert len(CANDIDATES) == 296 + assert set(EXCLUDED) <= set(CANDIDATES) + assert len(CASES) == 296 - len(EXCLUDED) == 271 + + +@pytest.mark.parametrize("written, spoken", CASES) +def test_reversed(normalizer, written, spoken): + assert normalizer.normalize(written) in spoken + + +@pytest.mark.parametrize("written", sorted(EXCLUDED)) +def test_exclusions_still_needed(normalizer, written): + assert normalizer.normalize(written) not in CANDIDATES[written] + + +@pytest.mark.parametrize("written, spoken", CASES) +def test_reversed_mapping(normalizer, written, spoken): + result = normalizer.normalize_with_mapping(written, include_identity=True) + assert result.output_text in spoken + for mapping in result.mappings: + assert written[mapping.input_start:mapping.input_end] == mapping.input_text + assert result.output_text[mapping.output_start:mapping.output_end] == mapping.output_text + + +def test_tag_then_verbalize_matches_normalize(normalizer): + for written in CANDIDATES: + assert normalizer.verbalize(normalizer.tag(written)) == normalizer.normalize(written) + + +@pytest.mark.parametrize("written, kind, members, spoken", [ + ("23", "cardinal", {"integer": "23"}, "עשרים ושלוש"), + ("כ-1,000", "cardinal", {"prefix": "כ", "integer": "1,000"}, "כאלף"), + ("-3,721", "cardinal", {"negative": "-", "integer": "3,721"}, "מינוס שלושת אלפים שבע מאות עשרים ואחת"), + ("101000", "cardinal", {"integer": "101000"}, "מאה ואחד אלף"), + ("3043061", "cardinal", {"integer": "3043061"}, "שלושה מיליון ארבעים ושלוש אלף שישים ואחת"), + ("2.75", "decimal", {"integer_part": "2", "fractional_part": "75"}, "שתיים ושלושת רבעי"), + ("18.05", "decimal", {"integer_part": "18", "fractional_part": "05"}, "שמונה עשרה נקודה אפס חמש"), + ("1.84 מיליארד", "decimal", {"integer_part": "1", "fractional_part": "84", "quantity": "מיליארד"}, + "אחד נקודה שמונים וארבע מיליארד"), + ("2 מיליון", "decimal", {"integer_part": "2", "quantity": "מיליון"}, "שני מיליון"), + ("ב-2.5 מיליון", "decimal", {"prefix": "ב", "integer_part": "2", "fractional_part": "5", "quantity": "מיליון"}, + "בשתיים וחצי מיליון"), + ("-15%", "measure", {"negative": "-", "integer": "15", "units": "%"}, "מינוס חמש עשרה אחוז"), + ("1 ס״מ", "measure", {"integer": "1", "units": "ס״מ"}, "סנטימטר אחד"), + ("ל-4 ס״מ", "measure", {"prefix": "ל", "integer": "4", "units": "ס״מ"}, "לארבע סנטימטר"), + ("3.5 ק״ג", "measure", {"integer_part": "3", "fractional_part": "5", "units": "ק״ג"}, "שלוש וחצי קילוגרם"), + ("15 איש", "measure", {"integer": "15", "units": "איש"}, "חמישה עשר איש"), + ("12:35 בצהריים", "time", {"hours": "12", "minutes": "35", "suffix": "בצהריים"}, + "עשרים וחמישה לאחת בצהריים"), + ("ב-18:20 בערב", "time", {"prefix": "ב", "hours": "18", "minutes": "20", "suffix": "בערב"}, + "בשש ועשרים דקות בערב"), + ("0:12", "time", {"hours": "0", "minutes": "12"}, "חצות ושתיים עשרה דקות"), + ("15:30", "time", {"hours": "15", "minutes": "30"}, "חמש עשרה וחצי"), + ("ב-17.8.2030", "date", {"prefix": "ב", "day": "17", "month": "8", "year": "2030"}, + "בשבעה עשר באוגוסט עשרים שלושים"), + ("ה-10.4", "date", {"prefix": "ה", "day": "10", "month": "4"}, "העשירי באפריל"), + ("1.5.1983", "date", {"day": "1", "month": "5", "year": "1983"}, "אחד במאי אלף תשע מאות שמונים ושלוש"), + ("ביוני 2015", "date", {"month": "ביוני", "year": "2015"}, "ביוני אלפיים וחמש עשרה"), + ("בשנת 70", "date", {"year_prefix": "בשנת", "year": "70"}, "בשנת שבעים"), + ("לארה״ב", "whitelist", {"value": "לארה״ב"}, "לארצות הברית"), + ("שלום", "word", {"value": "שלום"}, "שלום"), +]) +def test_raw_fields(normalizer, written, kind, members, spoken): + tagged = normalizer.tag(written) + parser = normalizer.token_parser() + parser.parse(tagged) + assert len(parser.tokens) == 1 + assert parser.tokens[0].name == kind + assert parser.tokens[0].members == members + assert normalizer.verbalize(tagged) == spoken + assert normalizer.normalize(written) == spoken + + +@pytest.mark.parametrize("written, spoken", [ + ("(5)", "(חמש)"), + ("20,", "עשרים,"), + ("5 איש", "חמישה איש"), + ("15 איש", "חמישה עשר איש"), + ("אנחנו היינו איזה 15 איש", "אנחנו היינו איזה חמישה עשר איש"), + ("0", "אפס"), + ("1 מיליון", "מיליון"), + (".5", "נקודה חמש"), + ("23:59", "דקה לחצות"), + ("05:58", "שתי דקות לשש"), + ("17:00 בבוקר", "שבע עשרה בבוקר"), + ("ב-30.6", "בשלושים ביוני"), + ("5 ק\"ג", "חמש קילוגרם"), + ("1,234,567,890", "1,234,567,890"), +]) +def test_additional_semantics(normalizer, written, spoken): + assert normalizer.normalize(written) == spoken + + +@pytest.mark.parametrize("written, spoken, punctuation", [ + ("(5)", "(חמש)", ["(", ")"]), + ("5,", "חמש,", [","]), + ("5 !", "חמש !", [" !"]), + ("( 5 )", "( חמש )", ["( ", " )"]), +]) +def test_cardinal_punctuation_spacing(normalizer, written, spoken, punctuation): + assert normalizer.normalize(written) == spoken + tagged = normalizer.tag(written) + parser = normalizer.token_parser() + parser.parse(tagged) + assert [token.members for token in parser.tokens if token.name == "cardinal"] == [{"integer": "5"}] + assert [token.members for token in parser.tokens if token.name == "punctuation"] == [ + {"value": value} for value in punctuation + ] + assert normalizer.verbalize(tagged) == spoken + mapped = normalizer.normalize_with_mapping(written, include_identity=True) + assert mapped.output_text == spoken + assert [mapping.input_text for mapping in mapped.mappings if mapping.token_type == "punctuation"] == punctuation + for mapping in mapped.mappings: + assert written[mapping.input_start:mapping.input_end] == mapping.input_text + assert spoken[mapping.output_start:mapping.output_end] == mapping.output_text + + +@pytest.mark.parametrize("written", ["2.5", "17:45 בערב", "ה-10.4", "1 ס״מ", "ארה״ב"]) +def test_nbest_mapping_synchronization(normalizer, written): + outputs = normalizer.normalize(written, nbest=3) + mapped = normalizer.normalize_with_mapping(written, 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(written) + for result in mapped: + for mapping in result.mappings: + assert written[mapping.input_start:mapping.input_end] == mapping.input_text + assert result.output_text[mapping.output_start:mapping.output_end] == mapping.output_text + + +def test_escaped_word_round_trips(normalizer): + written = 'ע"פ\\x' + parser = normalizer.token_parser() + parser.parse(normalizer.tag(written)) + assert [token.members for token in parser.tokens] == [{"value": written}] + assert normalizer.normalize(written) == written + + +def test_empty_input(normalizer): + assert normalizer.normalize("") == "" + assert normalizer.normalize("", nbest=3) == [""] + assert normalizer.normalize_with_mapping("").output_text == "" + + +@pytest.mark.parametrize("written", [" ", "\t", "\n", " \t\n ", "\u00a0"]) +def test_whitespace_only_input(normalizer, written): + tagged = normalizer.tag(written) + parser = normalizer.token_parser() + parser.parse(tagged) + assert len(parser.tokens) == 1 + assert parser.tokens[0].name == "word" + assert parser.tokens[0].members == {"value": written} + assert normalizer.verbalize(tagged) == "" + assert normalizer.normalize(written, nbest=3) == [""] + mapped = normalizer.normalize_with_mapping(written, 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_itn(): + itn_import = re.compile(r"^\s*(from|import)\s+itn\.hebrew\b", re.MULTILINE) + for path in PACKAGE.rglob("*.py"): + assert not itn_import.search(path.read_text(encoding="utf-8")), path diff --git a/tn/test/cli_test.py b/tn/test/cli_test.py index dddc571..514677b 100644 --- a/tn/test/cli_test.py +++ b/tn/test/cli_test.py @@ -60,6 +60,7 @@ def verbalize(self, tagged): ("tn", "sv", "tn.swedish.normalizer", "Normalizer", ["--no-deterministic"], {"deterministic": False}), ("tn", "es", "tn.spanish.normalizer", "Normalizer", ["--no-deterministic"], {"deterministic": False}), ("tn", "hi", "tn.hindi.normalizer", "Normalizer", [], {}), + ("tn", "he", "tn.hebrew.normalizer", "Normalizer", [], {}), ("tn", "it", "tn.italian.normalizer", "Normalizer", [], {}), ("tn", "mr", "tn.marathi.normalizer", "Normalizer", [], {}), ("tn", "rw", "tn.kinyarwanda.normalizer", "Normalizer", [], {}), @@ -97,6 +98,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", "he", "itn.hebrew.inverse_normalizer", "InverseNormalizer", [], {}), ("itn", "it", "itn.italian.inverse_normalizer", "InverseNormalizer", [], {}), ("itn", "hi_en", "itn.hindi_english.inverse_normalizer", "InverseNormalizer", ["--cased"], {"cased": True}), ("itn", "mr", "itn.marathi.inverse_normalizer", "InverseNormalizer", [], {}), @@ -230,6 +232,8 @@ def test_itn_only_languages_are_rejected_for_tn(language): ("itn", "es", "--cased", "--enable-million"), ("tn", "hi", "--cache-dir", "--deterministic"), ("itn", "hi", "--cache-dir", "--enable-million"), + ("tn", "he", "--cache-dir", "--deterministic"), + ("itn", "he", "--cache-dir", "--enable-million"), ("tn", "it", "--cache-dir", "--deterministic"), ("itn", "it", "--cache-dir", "--enable-million"), ("itn", "hi_en", "--cased", "--enable-million"),