Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ wheels/
.vscode/
.idea/
.cursor/
.claude/
.claude/
/site
6 changes: 2 additions & 4 deletions docs/contributing-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ Pick the narrowest one that fits your step.
class MyWordStep(WordStep):
name = "my_word_step"

def __call__(self, word: str, operators: LanguageOperators) -> str:
...
def __call__(self, word: str, operators: LanguageOperators) -> str: ...
```

**`TextStep`** — the general-purpose base for Stage 1 and Stage 3. Use it when your transformation needs to see the full string, or when none of the more specific bases below fit.
Expand All @@ -73,8 +72,7 @@ class MyWordStep(WordStep):
class MyTextStep(TextStep):
name = "my_text_step"

def __call__(self, text: str, operators: LanguageOperators) -> str:
...
def __call__(self, text: str, operators: LanguageOperators) -> str: ...
```

**`ProtectStep`** — a specialization of `TextStep` for replacing a character with a placeholder token. Implement `_pattern`, which returns a compiled regex with **exactly two capture groups** (what comes before and after the character being replaced). The `__call__` is fixed: it applies the pattern as `\1{placeholder}\2`.
Expand Down
80 changes: 79 additions & 1 deletion normalization/languages/english/number_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,69 @@ def output(result: str | int):
next_lower = next.lower() if next is not None else None

next_is_numeric = next is not None and re.match(r"^\d+(\.\d+)?$", next)
next_next = words[i + 2] if i + 2 < len(words) else None
next_next_lower = next_next.lower() if next_next is not None else None

# ASR homophone: "40 for point 5" → "40 four point 5" → 44.5
if (
current_lower == "for"
and next_lower == "point"
and (
value is not None or (prev is not None and re.match(r"^\d+$", prev))
)
):
current = "four"
current_lower = "four"

look_next = next_lower
if next_lower == "for" and next_next_lower == "point":
look_next = "four"

def next_continues_number() -> bool:
return bool(
next_is_numeric
or next_lower in self.ones
or next_lower in self.tens
or next_lower in self.zeros
or next_lower in self.multipliers
or next_lower in self.ones_suffixed
or next_lower in self.tens_suffixed
or next_lower == "and"
or (next_lower == "for" and next_next_lower == "point")
)

if re.match(r"^\d+$", current):
if value is not None:
# "44." + "5" → "44.5" (digit after spoken/decimal point)
if isinstance(value, str) and value.endswith("."):
value = str(value) + current
continue
yield output(value)
value = None
# "10 thousand" / "25 hundred" → 10000 / 2500, not bare digits
# then a leftover multiplier.
if look_next in self.multipliers:
value = int(current)
continue
# "44 point 5" → keep 44 in value so "point" can append "."
if look_next == "point":
value = current
continue
# "2000 and twelve" → keep digits for the following connector.
if look_next == "and":
value = int(current)
continue
# Ones 1–9 after a digit: keep as int so "40 four" → 44.
# Teens / tens / zeros: keep as string so "20 thirteen" → 2013.
if look_next in self.ones:
if self.ones[look_next] < 10:
value = int(current)
else:
value = current
continue
if look_next in self.tens or look_next in self.zeros:
value = current
continue
yield output(current)
continue

Expand Down Expand Up @@ -250,6 +309,11 @@ def output(result: str | int):
value = str(value) + str(ones)
elif current_lower in self.ones_suffixed:
ones, suffix = self.ones_suffixed[current_lower]
# "eleventh 2000 and twelve" → keep cardinal 11 in-phrase so
# later digit collapsing can form IDs like 112012.
if value is None and next_continues_number():
value = ones
continue
if value is None:
yield output(str(ones) + suffix)
elif isinstance(value, str) or prev_lower in self.ones:
Expand Down Expand Up @@ -358,7 +422,19 @@ def output(result: str | int):
yield output(value)
yield output(current)
elif current_lower == "and":
if prev_lower not in self.multipliers:
# Keep "and" inside a number phrase after multipliers
# ("two thousand and twelve") or bare digits
# ("2000 and twelve" → 2012).
keeps_number_phrase = prev_lower in self.multipliers or (
value is not None
and isinstance(value, int)
and (
next_lower in self.ones
or next_lower in self.tens
or next_lower in self.zeros
)
)
if not keeps_number_phrase:
if value is not None:
yield output(value)
yield output(current)
Expand Down Expand Up @@ -400,6 +476,8 @@ def preprocess(self, s: str) -> str:
results.append("and a half")

s = " ".join(results)
# Common ASR misspelling of "thousand".
s = re.sub(r"\bthousaond\b", "thousand", s, flags=re.IGNORECASE)
s = re.sub(r"([a-z])([0-9])", r"\1 \2", s)
s = re.sub(r"([0-9])([a-z])", r"\1 \2", s)
s = re.sub(r"([0-9])\s+(st|nd|rd|th|s)\b", r"\1\2", s)
Expand Down
11 changes: 11 additions & 0 deletions normalization/languages/english/replacements.py
Original file line number Diff line number Diff line change
Expand Up @@ -1771,4 +1771,15 @@
"woulda": "would have",
"coulda": "could have",
"shoulda": "should have",
# ASR / WER canonicalizations
"itchys": "itches",
"cannot": "could not",
"peoples": "people",
"telephone": "cell phone",
"talk": "talked",
"rudolph": "rudolf",
"pound": "pounds",
"because": "cuz",
"solution": "solutions",
"motherfuka": "motherfucker",
}
5 changes: 5 additions & 0 deletions normalization/languages/english/sentence_replacements.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
ENGLISH_SENTENCE_REPLACEMENTS: dict[str, str] = {
"good bye": "goodbye",
# ASR / WER canonicalizations
"m a c": "mac",
"all right": "alright",
"fire fire": "firefires",
"paul s": "pauls",
}
2 changes: 2 additions & 0 deletions normalization/languages/spanish/replacements.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@
"vds": "ustedes",
"versus": "versus",
"vs": "versus",
# ASR / WER canonicalizations
"ahorita": "ahora",
}
26 changes: 26 additions & 0 deletions tests/e2e/files/gladia-3/en.csv
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,29 @@ four hundred,400
five thousand dollars,5000 dollars
three thousand five hundred,3500
two billion people,2000000000 people
itchys,itches
cannot,could not
peoples,people
telephone,cell phone
talk,talked
rudolph,rudolf
pound,pounds
because,cuz
solution,solutions
motherfuka,motherfucker
all right,alright
fire fire,firefires
paul s,pauls
40 for point 5,44 point 5
40 four point 5,44 point 5
eleventh 2000 and twelve,112012
Comment thread
egenthon-cmd marked this conversation as resolved.
eleventh 2 thousand and twelve,112012
Comment thread
egenthon-cmd marked this conversation as resolved.
eleventh 2 thousaond and twelve,112012
Comment thread
egenthon-cmd marked this conversation as resolved.
25 hundred,2500
Comment thread
egenthon-cmd marked this conversation as resolved.
35 hundred,3500
Comment thread
egenthon-cmd marked this conversation as resolved.
20 hundred,2000
20 thirteen,2013
Comment thread
egenthon-cmd marked this conversation as resolved.
30 two,32
20 one,21
20 twenty,2020
10 thousand,10000
1 change: 1 addition & 0 deletions tests/e2e/files/gladia-3/es.csv
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ cuarenta y cinco,45
setenta y ocho,78
quinientos,500
quince mil,15000
ahorita,ahora
165 changes: 165 additions & 0 deletions tests/unit/languages/english_number_normalizer_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import pytest

from normalization.languages.english.number_normalizer import EnglishNumberNormalizer
from normalization.pipeline.loader import load_pipeline

_ONES_UNITS: dict[str, int] = {
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
}

_TEENS: dict[str, int] = {
"ten": 10,
"eleven": 11,
"twelve": 12,
"thirteen": 13,
"fourteen": 14,
"fifteen": 15,
"sixteen": 16,
"seventeen": 17,
"eighteen": 18,
"nineteen": 19,
}

_TENS: dict[str, int] = {
"twenty": 20,
"thirty": 30,
"forty": 40,
"fifty": 50,
"sixty": 60,
"seventy": 70,
"eighty": 80,
"ninety": 90,
}

_ROUND_TENS = (10, 20, 30, 40, 50, 60, 70, 80, 90)
_LEADING_DIGITS = (2, 10, 20, 30, 99)


@pytest.fixture
def normalizer() -> EnglishNumberNormalizer:
return EnglishNumberNormalizer()


@pytest.mark.parametrize(
("digit", "word", "word_value"),
[
(digit, word, value)
for digit in _ROUND_TENS
for word, value in _ONES_UNITS.items()
],
)
def test_round_digit_plus_unit_adds(
normalizer: EnglishNumberNormalizer,
digit: int,
word: str,
word_value: int,
) -> None:
"""'40 four' → '44', '30 two' → '32' (tens + ones), not concatenation."""
assert normalizer(f"{digit} {word}") == str(digit + word_value)


@pytest.mark.parametrize(
("digit", "word", "word_value"),
[
(digit, word, value)
for digit in _LEADING_DIGITS
for word, value in {**_TEENS, **_TENS}.items()
],
)
def test_digit_plus_teen_or_tens_concatenates(
normalizer: EnglishNumberNormalizer,
digit: int,
word: str,
word_value: int,
) -> None:
"""'20 thirteen' → '2013', '20 twenty' → '2020'."""
assert normalizer(f"{digit} {word}") == f"{digit}{word_value}"


@pytest.mark.parametrize("digit", range(1, 100))
def test_digit_plus_hundred_multiplies(
normalizer: EnglishNumberNormalizer, digit: int
) -> None:
"""'20 hundred' → '2000', '25 hundred' → '2500', … for every 1–99."""
assert normalizer(f"{digit} hundred") == str(digit * 100)


@pytest.mark.parametrize(
("text", "expected"),
[
("1 thousand", "1000"),
("10 thousand", "10000"),
("25 thousand", "25000"),
("100 thousand", "100000"),
("1 million", "1000000"),
("20 million", "20000000"),
("25 million", "25000000"),
],
)
def test_digit_plus_large_multiplier(
normalizer: EnglishNumberNormalizer, text: str, expected: str
) -> None:
assert normalizer(text) == expected


@pytest.mark.parametrize(
("text", "expected"),
[
# "for" is ASR for "four": 40 + 4 + .5
("40 for point 5", "44.5"),
("40 four point 5", "44.5"),
("44 point 5", "44.5"),
("eleventh 2000 and twelve", "11 2012"),
("eleventh 2 thousand and twelve", "11 2012"),
("eleventh 2 thousaond and twelve", "11 2012"),
],
)
def test_asr_number_phrase_forms(
normalizer: EnglishNumberNormalizer, text: str, expected: str
) -> None:
assert normalizer(text) == expected


@pytest.mark.parametrize(
("text", "expected"),
[
("40 for point 5", "44 point 5"),
("40 four point 5", "44 point 5"),
("eleventh 2000 and twelve", "112012"),
("eleventh 2 thousand and twelve", "112012"),
("eleventh 2 thousaond and twelve", "112012"),
("25 hundred", "2500"),
("35 hundred", "3500"),
("20 hundred", "2000"),
("20 thirteen", "2013"),
("30 two", "32"),
("20 one", "21"),
("20 twenty", "2020"),
("10 thousand", "10000"),
],
)
def test_words_to_num_through_gladia_3_pipeline(text: str, expected: str) -> None:
pipeline = load_pipeline("gladia-3", "en")
assert pipeline.normalize(text) == expected


def test_standalone_ordinal_still_keeps_suffix(
normalizer: EnglishNumberNormalizer,
) -> None:
assert normalizer("eleventh") == "11th"
assert normalizer("first") == "1st"


def test_for_not_rewritten_outside_number_point_context(
normalizer: EnglishNumberNormalizer,
) -> None:
assert normalizer("for example") == "for example"
assert normalizer("40 for example") == "40 for example"
Loading