jayron 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jayron/__init__.py +7 -0
- jayron/normalizers/__init__.py +14 -0
- jayron/normalizers/uz/__init__.py +28 -0
- jayron/normalizers/uz/__main__.py +75 -0
- jayron/normalizers/uz/converter.py +139 -0
- jayron/normalizers/uz/normalizer.py +484 -0
- jayron/normalizers/uz/numbers.py +133 -0
- jayron/normalizers/uz/transliterate.py +95 -0
- jayron/py.typed +0 -0
- jayron-0.1.0.dist-info/METADATA +170 -0
- jayron-0.1.0.dist-info/RECORD +15 -0
- jayron-0.1.0.dist-info/WHEEL +5 -0
- jayron-0.1.0.dist-info/entry_points.txt +2 -0
- jayron-0.1.0.dist-info/licenses/LICENSE +71 -0
- jayron-0.1.0.dist-info/top_level.txt +1 -0
jayron/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
2
|
+
# Free for non-commercial use only; modification and redistribution are not allowed.
|
|
3
|
+
# Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
|
|
4
|
+
|
|
5
|
+
"""jayron — tools for Uzbek speech recognition."""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
2
|
+
# Free for non-commercial use only; modification and redistribution are not allowed.
|
|
3
|
+
# Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
|
|
4
|
+
|
|
5
|
+
"""Text normalizers.
|
|
6
|
+
|
|
7
|
+
from jayron.normalizers import uz_normilizer, converter
|
|
8
|
+
uz_normilizer("2024-yil 5 ta kitob") # 'ikki ming yigirma toʻrtinchi yil beshta kitob'
|
|
9
|
+
converter("Bog'chaga bordingmi?") # 'Boğçaga bordingmi?' (new 2026 alphabet)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .uz import AlphabetConverter, UzbekNormalizer, converter, uz_normalizer, uz_normilizer
|
|
13
|
+
|
|
14
|
+
__all__ = ["AlphabetConverter", "UzbekNormalizer", "converter", "uz_normalizer", "uz_normilizer"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
2
|
+
# Free for non-commercial use only; modification and redistribution are not allowed.
|
|
3
|
+
# Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
|
|
4
|
+
|
|
5
|
+
"""Uzbek text normalization for ASR."""
|
|
6
|
+
|
|
7
|
+
from .converter import AlphabetConverter, converter, to_new, to_old
|
|
8
|
+
from .normalizer import OKINA, UzbekNormalizer, expand_numbers
|
|
9
|
+
from .numbers import number_to_ordinal, number_to_words
|
|
10
|
+
from .transliterate import cyrillic_to_latin
|
|
11
|
+
|
|
12
|
+
uz_normalizer = UzbekNormalizer()
|
|
13
|
+
uz_normilizer = uz_normalizer # same object, under the spelling used in the project
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"OKINA",
|
|
17
|
+
"AlphabetConverter",
|
|
18
|
+
"UzbekNormalizer",
|
|
19
|
+
"converter",
|
|
20
|
+
"cyrillic_to_latin",
|
|
21
|
+
"to_new",
|
|
22
|
+
"to_old",
|
|
23
|
+
"expand_numbers",
|
|
24
|
+
"number_to_ordinal",
|
|
25
|
+
"number_to_words",
|
|
26
|
+
"uz_normalizer",
|
|
27
|
+
"uz_normilizer",
|
|
28
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
2
|
+
# Free for non-commercial use only; modification and redistribution are not allowed.
|
|
3
|
+
# Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
|
|
4
|
+
|
|
5
|
+
"""Command line: normalize text, text files, or JSONL manifests.
|
|
6
|
+
|
|
7
|
+
python -m jayron.normalizers.uz "2024-yil 5 ta kitob"
|
|
8
|
+
python -m jayron.normalizers.uz -i corpus.txt -o corpus.norm.txt
|
|
9
|
+
python -m jayron.normalizers.uz -i train.jsonl -o train.norm.jsonl --jsonl --field text
|
|
10
|
+
cat corpus.txt | python -m jayron.normalizers.uz
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from .normalizer import OKINA, UzbekNormalizer
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
24
|
+
p = argparse.ArgumentParser(prog="jayron-uz-norm", description="Uzbek text normalizer for ASR")
|
|
25
|
+
p.add_argument("text", nargs="*", help="text to normalize (otherwise reads --input or stdin)")
|
|
26
|
+
p.add_argument("-i", "--input", help="input file (one sentence per line, or JSONL with --jsonl)")
|
|
27
|
+
p.add_argument("-o", "--output", help="output file (default: stdout)")
|
|
28
|
+
p.add_argument("--jsonl", action="store_true", help="input is JSON lines (e.g. a NeMo manifest)")
|
|
29
|
+
p.add_argument("--field", default="text", help="JSONL field to normalize (default: text)")
|
|
30
|
+
p.add_argument("--out-field", help="write the result to this field instead of overwriting --field")
|
|
31
|
+
p.add_argument("--alphabet", choices=("old", "new"), default="old",
|
|
32
|
+
help="old: oʻ gʻ sh ch (default); new: ö ğ ş ç (2026 reform)")
|
|
33
|
+
p.add_argument("--apostrophe", default=OKINA, help="character for oʻ/gʻ/tutuq (default: ʻ U+02BB)")
|
|
34
|
+
p.add_argument("--keep-case", action="store_true")
|
|
35
|
+
p.add_argument("--keep-punctuation", action="store_true")
|
|
36
|
+
p.add_argument("--no-transliterate", action="store_true", help="leave Cyrillic text as is")
|
|
37
|
+
p.add_argument("--no-numbers", action="store_true", help="leave digits as is")
|
|
38
|
+
args = p.parse_args(argv)
|
|
39
|
+
|
|
40
|
+
norm = UzbekNormalizer(
|
|
41
|
+
alphabet=args.alphabet,
|
|
42
|
+
apostrophe=args.apostrophe,
|
|
43
|
+
lowercase=not args.keep_case,
|
|
44
|
+
remove_punctuation=not args.keep_punctuation,
|
|
45
|
+
transliterate=not args.no_transliterate,
|
|
46
|
+
expand_numbers=not args.no_numbers,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if args.text:
|
|
50
|
+
print(norm(" ".join(args.text)))
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
src = open(args.input, encoding="utf-8") if args.input else sys.stdin
|
|
54
|
+
dst = open(args.output, "w", encoding="utf-8") if args.output else sys.stdout
|
|
55
|
+
try:
|
|
56
|
+
for line in src:
|
|
57
|
+
line = line.rstrip("\n")
|
|
58
|
+
if args.jsonl:
|
|
59
|
+
if not line.strip():
|
|
60
|
+
continue
|
|
61
|
+
record = json.loads(line)
|
|
62
|
+
record[args.out_field or args.field] = norm(record.get(args.field) or "")
|
|
63
|
+
dst.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
64
|
+
else:
|
|
65
|
+
dst.write(norm(line) + "\n")
|
|
66
|
+
finally:
|
|
67
|
+
if src is not sys.stdin:
|
|
68
|
+
src.close()
|
|
69
|
+
if dst is not sys.stdout:
|
|
70
|
+
dst.close()
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
sys.exit(main())
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
2
|
+
# Free for non-commercial use only; modification and redistribution are not allowed.
|
|
3
|
+
# Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
|
|
4
|
+
|
|
5
|
+
"""Convert Uzbek text between the 1995 Latin alphabet and the 2026 reform.
|
|
6
|
+
|
|
7
|
+
The reform (law signed 21 Oct 2026, in force from 22 Apr 2027) replaces four
|
|
8
|
+
letters and drops Ng as a separate letter:
|
|
9
|
+
|
|
10
|
+
Oʻ -> Ö Gʻ -> Ğ Sh -> Ş Ch -> Ç (ng stays written as n + g)
|
|
11
|
+
|
|
12
|
+
The apostrophe (tutuq belgisi, as in maʼno) is kept, and so is the s'h spelling
|
|
13
|
+
of a separate s + h (Is'hoq stays Is'hoq).
|
|
14
|
+
|
|
15
|
+
>>> from jayron.normalizers import converter
|
|
16
|
+
>>> converter("Bog'chaga bordingmi?")
|
|
17
|
+
'Boğçaga bordingmi?'
|
|
18
|
+
>>> converter.to_old("Boğçaga bordingmi?")
|
|
19
|
+
'Bogʻchaga bordingmi?'
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
import unicodedata
|
|
26
|
+
|
|
27
|
+
from .numbers import OKINA
|
|
28
|
+
from .transliterate import cyrillic_to_latin
|
|
29
|
+
|
|
30
|
+
__all__ = ["APOSTROPHES", "AlphabetConverter", "NEW_LETTERS", "converter", "to_new", "to_old"]
|
|
31
|
+
|
|
32
|
+
# ' ` ´ ʹ ʻ ʼ ʽ ‘ ’ ‛ ′ — all the ways people type the oʻ / gʻ mark
|
|
33
|
+
APOSTROPHES = "['`´ʹʻʼʽ‘’‛′]"
|
|
34
|
+
NEW_LETTERS = "öğşçÖĞŞÇ"
|
|
35
|
+
|
|
36
|
+
_OG = re.compile("([oOgG])" + APOSTROPHES)
|
|
37
|
+
_DIGRAPH = re.compile("([sScC])[hH]")
|
|
38
|
+
_TO_NEW = {"o": "ö", "O": "Ö", "g": "ğ", "G": "Ğ", "s": "ş", "S": "Ş", "c": "ç", "C": "Ç"}
|
|
39
|
+
|
|
40
|
+
# New letters, plus look-alikes from drafts and other keyboards (ō from the 2021 draft, ḡ, ș, ǧ ...).
|
|
41
|
+
_OLD_OG = {"ö": "o", "ō": "o", "ğ": "g", "ḡ": "g", "ǵ": "g", "ǧ": "g"}
|
|
42
|
+
_OLD_OG.update({k.upper(): v.upper() for k, v in list(_OLD_OG.items())})
|
|
43
|
+
_OLD_DIGRAPH = {"ş": "s", "ș": "s", "ç": "c"}
|
|
44
|
+
_OLD_DIGRAPH.update({k.upper(): v.upper() for k, v in list(_OLD_DIGRAPH.items())})
|
|
45
|
+
_NEW_LETTER = re.compile("[" + "".join(_OLD_OG) + "".join(_OLD_DIGRAPH) + "]")
|
|
46
|
+
|
|
47
|
+
# Links and e-mail addresses are left untouched.
|
|
48
|
+
_PROTECTED = re.compile(r"(?:https?://|www\.)\S+|[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _letters_to_old(text: str, apostrophe: str = OKINA) -> str:
|
|
52
|
+
"""ö -> oʻ, ğ -> gʻ, ş -> sh, ç -> ch (also accepts ō, ḡ, ș, ...)."""
|
|
53
|
+
if text.isascii():
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
def repl(m: "re.Match[str]") -> str:
|
|
57
|
+
ch = m.group()
|
|
58
|
+
if ch in _OLD_OG:
|
|
59
|
+
return _OLD_OG[ch] + apostrophe
|
|
60
|
+
base = _OLD_DIGRAPH[ch]
|
|
61
|
+
if base.islower():
|
|
62
|
+
return base + "h"
|
|
63
|
+
i, s = m.start(), m.string
|
|
64
|
+
prev = s[i - 1] if i else ""
|
|
65
|
+
nxt = s[i + 1] if i + 1 < len(s) else ""
|
|
66
|
+
all_caps = nxt.isupper() or (prev.isupper() and not nxt.isalpha())
|
|
67
|
+
return base + ("H" if all_caps else "h")
|
|
68
|
+
|
|
69
|
+
return _NEW_LETTER.sub(repl, text)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _letters_to_new(text: str) -> str:
|
|
73
|
+
text = _OG.sub(lambda m: _TO_NEW[m.group(1)], text)
|
|
74
|
+
return _DIGRAPH.sub(lambda m: _TO_NEW[m.group(1)], text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _outside_links(text: str, fn) -> str:
|
|
78
|
+
parts, last = [], 0
|
|
79
|
+
for m in _PROTECTED.finditer(text):
|
|
80
|
+
parts.append(fn(text[last:m.start()]))
|
|
81
|
+
parts.append(m.group())
|
|
82
|
+
last = m.end()
|
|
83
|
+
parts.append(fn(text[last:]))
|
|
84
|
+
return "".join(parts)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def to_new(text: str, *, transliterate: bool = True) -> str:
|
|
88
|
+
"""Convert to the new alphabet: "Bog'chaga" -> "Boğçaga".
|
|
89
|
+
|
|
90
|
+
Accepts the old Latin alphabet (any apostrophe variant), Cyrillic (when
|
|
91
|
+
``transliterate`` is on), or text already partly in the new alphabet.
|
|
92
|
+
Everything else (case, punctuation, digits, ng, tutuq) is kept as is.
|
|
93
|
+
"""
|
|
94
|
+
if not text:
|
|
95
|
+
return text
|
|
96
|
+
text = unicodedata.normalize("NFC", text)
|
|
97
|
+
if transliterate:
|
|
98
|
+
text = cyrillic_to_latin(text)
|
|
99
|
+
return _outside_links(text, lambda part: _letters_to_new(_letters_to_old(part)))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def to_old(text: str, *, apostrophe: str = OKINA, transliterate: bool = True) -> str:
|
|
103
|
+
"""Convert to the 1995 alphabet: "Boğçaga" -> "Bogʻchaga" (``apostrophe`` marks oʻ/gʻ)."""
|
|
104
|
+
if not text:
|
|
105
|
+
return text
|
|
106
|
+
text = unicodedata.normalize("NFC", text)
|
|
107
|
+
if transliterate:
|
|
108
|
+
text = cyrillic_to_latin(text)
|
|
109
|
+
return _letters_to_old(text, apostrophe)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AlphabetConverter:
|
|
113
|
+
"""Old (1995) <-> new (2026) Uzbek Latin alphabet. Calling it converts to the new one.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
transliterate: also accept Uzbek Cyrillic input.
|
|
117
|
+
apostrophe: character for oʻ/gʻ when converting back to the old alphabet.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, *, transliterate: bool = True, apostrophe: str = OKINA) -> None:
|
|
121
|
+
self.transliterate = transliterate
|
|
122
|
+
self.apostrophe = apostrophe
|
|
123
|
+
|
|
124
|
+
def to_new(self, text: str) -> str:
|
|
125
|
+
return to_new(text, transliterate=self.transliterate)
|
|
126
|
+
|
|
127
|
+
def to_old(self, text: str) -> str:
|
|
128
|
+
return to_old(text, apostrophe=self.apostrophe, transliterate=self.transliterate)
|
|
129
|
+
|
|
130
|
+
def __call__(self, text):
|
|
131
|
+
if isinstance(text, str):
|
|
132
|
+
return self.to_new(text)
|
|
133
|
+
return [self.to_new(t) for t in text]
|
|
134
|
+
|
|
135
|
+
def __repr__(self) -> str:
|
|
136
|
+
return f"AlphabetConverter(transliterate={self.transliterate!r}, apostrophe={self.apostrophe!r})"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
converter = AlphabetConverter()
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
2
|
+
# Free for non-commercial use only; modification and redistribution are not allowed.
|
|
3
|
+
# Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
|
|
4
|
+
|
|
5
|
+
"""Uzbek text normalizer for speech recognition (ASR).
|
|
6
|
+
|
|
7
|
+
Turns written Uzbek into the form a speaker would say it: Latin script,
|
|
8
|
+
numbers/dates/times/units spelled out, one apostrophe character, no
|
|
9
|
+
punctuation, lowercase. Every step can be switched off.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
from typing import Iterable, List, Optional, Sequence, Tuple, Union
|
|
17
|
+
|
|
18
|
+
from .numbers import (
|
|
19
|
+
MONTHS,
|
|
20
|
+
OKINA,
|
|
21
|
+
attach_suffix,
|
|
22
|
+
decimal_to_words,
|
|
23
|
+
fraction_to_words,
|
|
24
|
+
number_to_ordinal,
|
|
25
|
+
read_integer,
|
|
26
|
+
roman_to_int,
|
|
27
|
+
)
|
|
28
|
+
from .converter import APOSTROPHES, _letters_to_old, to_new
|
|
29
|
+
from .transliterate import cyrillic_to_latin, fix_mixed_script
|
|
30
|
+
|
|
31
|
+
__all__ = ["UzbekNormalizer", "expand_numbers", "OKINA"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ok(text: str) -> str:
|
|
35
|
+
return text.replace("'", OKINA)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- unicode clean-up
|
|
39
|
+
|
|
40
|
+
_INVISIBLE = re.compile("[-]")
|
|
41
|
+
_SPACES = re.compile("[\t\n\r\f\v -
]")
|
|
42
|
+
_COMPAT = re.compile("[ff-st!-~]") # ligatures, full-width ASCII
|
|
43
|
+
_NON_ASCII_DIGIT = re.compile(r"(?![0-9])\d")
|
|
44
|
+
_HYPHENS = re.compile("[‐-‒−﹣]")
|
|
45
|
+
_DASH_IN_RANGE = re.compile(r"(?<=\d) ?[–—] ?(?=\d)")
|
|
46
|
+
_DASH_AS_MINUS = re.compile(r"(?<!\S)[–—](?=\d)")
|
|
47
|
+
_ANNOTATION = re.compile(r"\[[^\[\]]{1,50}\]|</?[A-Za-z_][\w-]{0,30}>")
|
|
48
|
+
_SUPERSCRIPTS = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹", "0123456789")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _pre_clean(text: str) -> str:
|
|
52
|
+
text = unicodedata.normalize("NFC", text)
|
|
53
|
+
text = _INVISIBLE.sub("", text)
|
|
54
|
+
text = _SPACES.sub(" ", text)
|
|
55
|
+
if not text.isascii():
|
|
56
|
+
text = _COMPAT.sub(lambda m: unicodedata.normalize("NFKC", m.group()), text)
|
|
57
|
+
text = _NON_ASCII_DIGIT.sub(lambda m: str(unicodedata.decimal(m.group())), text)
|
|
58
|
+
text = _HYPHENS.sub("-", text)
|
|
59
|
+
text = _DASH_IN_RANGE.sub("-", text)
|
|
60
|
+
text = _DASH_AS_MINUS.sub("-", text)
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- apostrophes
|
|
65
|
+
|
|
66
|
+
_APOS = APOSTROPHES
|
|
67
|
+
_OG_APOS = re.compile("([oOgG])" + _APOS)
|
|
68
|
+
_INNER_APOS = re.compile(r"(?<=[^\W\d_])" + _APOS + r"(?=[^\W\d_])")
|
|
69
|
+
_STRAY_APOS = re.compile("(?!" + OKINA + ")" + _APOS)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _canonical_apostrophes(text: str) -> str:
|
|
73
|
+
"""oʻ/gʻ and the tutuq belgisi all become ʻ; anything else apostrophe-like is a quote mark."""
|
|
74
|
+
text = _OG_APOS.sub(lambda m: m.group(1) + OKINA, text)
|
|
75
|
+
text = _INNER_APOS.sub(OKINA, text)
|
|
76
|
+
return _STRAY_APOS.sub("'", text)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- abbreviations & symbols
|
|
80
|
+
|
|
81
|
+
_NOT_AFTER_LETTER = r"(?<![^\W\d_])"
|
|
82
|
+
_ABBREVIATIONS = [
|
|
83
|
+
(re.compile(r"(?<!\w)va\s+h\.\s?k\.?(?!\w)"), "va hokazo"),
|
|
84
|
+
(re.compile(r"(?<!\w)h\.\s?k\.?(?!\w)"), "hokazo"),
|
|
85
|
+
(re.compile(r"(?<!\w)va\s+b\.(?!\w)"), "va boshqalar"),
|
|
86
|
+
(re.compile(_NOT_AFTER_LETTER + r"mlrd\.?(?!\w)"), "milliard"),
|
|
87
|
+
(re.compile(_NOT_AFTER_LETTER + r"mln\.?(?!\w)"), "million"),
|
|
88
|
+
(re.compile(_NOT_AFTER_LETTER + r"trln\.?(?!\w)"), "trillion"),
|
|
89
|
+
(re.compile(r"(?<!\w)prof\.(?!\w)"), "professor"),
|
|
90
|
+
(re.compile(r"(?<!\w)dots\.(?!\w)"), "dotsent"),
|
|
91
|
+
(re.compile(r"(?<!\w)akad\.(?!\w)"), "akademik"),
|
|
92
|
+
(re.compile(r"(?<!\w)mas\.(?!\w)"), "masalan"),
|
|
93
|
+
(re.compile(_ok(r"(?<!\w)ko'ch\.(?!\w)")), _ok("ko'chasi")),
|
|
94
|
+
# "Toshkent sh.", "Samarqand v.", "Chilonzor t."
|
|
95
|
+
(re.compile(r"(?<!\w)([A-Z]\w+)\s+sh\.(?!\w)"), r"\1 shahri"),
|
|
96
|
+
(re.compile(r"(?<!\w)([A-Z]\w+)\s+v\.(?!\w)"), r"\1 viloyati"),
|
|
97
|
+
(re.compile(r"(?<!\w)([A-Z]\w+)\s+t\.(?!\w)"), r"\1 tumani"),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
_SYMBOLS = {
|
|
101
|
+
"%": "foiz", "‰": "promille", "№": "raqam", "&": "va", "§": "paragraf", "°": "gradus",
|
|
102
|
+
"$": "dollar", "€": "yevro", "£": "funt sterling", "₽": "rubl", "¥": "iyena", "₸": "tenge",
|
|
103
|
+
}
|
|
104
|
+
_SYMBOL_RE = re.compile("[" + re.escape("".join(_SYMBOLS)) + "]")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _expand_abbreviations(text: str) -> str:
|
|
108
|
+
for pattern, replacement in _ABBREVIATIONS:
|
|
109
|
+
text = pattern.sub(replacement, text)
|
|
110
|
+
return text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _expand_symbols(text: str) -> str:
|
|
114
|
+
return _SYMBOL_RE.sub(lambda m: " " + _SYMBOLS[m.group()] + " ", text)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------- number helpers
|
|
118
|
+
|
|
119
|
+
# A number as written: 25 | 1 000 000 | 1,000,000 | 1.000.000 | 3,5 | 2.75
|
|
120
|
+
_NUM = (
|
|
121
|
+
r"(?<!\d)(?:"
|
|
122
|
+
r"[1-9]\d{0,2}(?:[ ,]\d{3})+(?!\d)"
|
|
123
|
+
r"|[1-9]\d{0,2}(?:\.\d{3}){2,}(?!\d)"
|
|
124
|
+
r"|\d+"
|
|
125
|
+
r")(?:[.,]\d+(?![.,]?\d))?"
|
|
126
|
+
)
|
|
127
|
+
_GROUPED = re.compile(r"([1-9]\d{0,2}(?:([ ,.])\d{3})+)(?:[.,](\d+))?")
|
|
128
|
+
_PLAIN = re.compile(r"(\d+)(?:[.,](\d+))?")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _split_number(token: str) -> Tuple[str, Optional[str]]:
|
|
132
|
+
"""'1 000,5' -> ('1000', '5'); '3.5' -> ('3', '5'); '1,500' -> ('1500', None)."""
|
|
133
|
+
m = _GROUPED.fullmatch(token)
|
|
134
|
+
# a single dot group ("1.500") is a decimal, not thousands
|
|
135
|
+
if m and (m.group(2) != "." or m.group(1).count(".") >= 2):
|
|
136
|
+
return re.sub(r"\D", "", m.group(1)), m.group(3)
|
|
137
|
+
m = _PLAIN.fullmatch(token)
|
|
138
|
+
if m:
|
|
139
|
+
return m.group(1), m.group(2)
|
|
140
|
+
return re.sub(r"\D", "", token), None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _say(token: str, ordinal: bool = False) -> str:
|
|
144
|
+
integer, fraction = _split_number(token)
|
|
145
|
+
if fraction is not None:
|
|
146
|
+
return decimal_to_words(integer, fraction)
|
|
147
|
+
if ordinal:
|
|
148
|
+
return number_to_ordinal(int(integer))
|
|
149
|
+
return read_integer(integer)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _say_range(first: Optional[str], second: str, ordinal: bool = False) -> str:
|
|
153
|
+
words = _say(second, ordinal)
|
|
154
|
+
return _say(first, ordinal) + " " + words if first else words
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_CASE = r"(?:dan|da|gacha|qacha|kacha|ga|qa|ka|ning|ni|dagi|dek|day|cha)"
|
|
158
|
+
# glued to a cardinal: 5ta, 5-ta, 10dan, 40ga, 100lab, 5tasi
|
|
159
|
+
_ATTACH = re.compile(rf"(?:ta|tasi|lar|lari|lik|lab)?{_CASE}?")
|
|
160
|
+
# written as a separate word but still a suffix: "5 ta", "10 dan"
|
|
161
|
+
_SPACED_ATTACH = re.compile(rf"ta(?:si)?{_CASE}?|{_CASE}")
|
|
162
|
+
# ordinal markers: 5-chi, 2-inchi, 1-nchisi, may oyining 5-si
|
|
163
|
+
_ORD_MARKER = re.compile(rf"(?:i?n)?chi((?:lar)?(?:i|si)?{_CASE}?)|(si{_CASE}?)")
|
|
164
|
+
# after a hyphen these words take a cardinal: 3-xonali, 100-yillik, 30-yilligi
|
|
165
|
+
_CARDINAL_HINT = re.compile(r"(?:li|lik|liq)$|lig")
|
|
166
|
+
_MONTH = re.compile(r"(?:%s)(?:da|dan|ga|gacha|ning|ni|dagi|gi)?" % "|".join(MONTHS[1:]))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _verbalize(token: str, sep: Optional[str], word: Optional[str]) -> Tuple[str, bool]:
|
|
170
|
+
"""Spell a number given the word that follows it. Returns (text, became_ordinal)."""
|
|
171
|
+
integer, fraction = _split_number(token)
|
|
172
|
+
if word is None:
|
|
173
|
+
return _say(token), False
|
|
174
|
+
low = word.lower()
|
|
175
|
+
if fraction is None:
|
|
176
|
+
n = int(integer)
|
|
177
|
+
if sep in ("", "-"):
|
|
178
|
+
m = _ORD_MARKER.fullmatch(low)
|
|
179
|
+
if m:
|
|
180
|
+
rest = m.group(1) if m.group(1) is not None else m.group(2)
|
|
181
|
+
return number_to_ordinal(n) + word[len(word) - len(rest):], True
|
|
182
|
+
if sep == " " and (
|
|
183
|
+
(1 <= n <= 31 and _MONTH.fullmatch(low)) # "15 mart"
|
|
184
|
+
or (1000 <= n <= 2999 and low.startswith("yil") and not _CARDINAL_HINT.search(low)) # "2024 yil"
|
|
185
|
+
):
|
|
186
|
+
return number_to_ordinal(n) + " " + word, True
|
|
187
|
+
cardinal = _say(token)
|
|
188
|
+
if (sep in ("", "-") and _ATTACH.fullmatch(low)) or (sep == " " and _SPACED_ATTACH.fullmatch(low)):
|
|
189
|
+
return attach_suffix(cardinal, word), False
|
|
190
|
+
if sep == "-" and fraction is None and not _CARDINAL_HINT.search(low):
|
|
191
|
+
return number_to_ordinal(int(integer)) + " " + word, True # 2024-yil, 5-sinf, 1-may
|
|
192
|
+
return cardinal + " " + word, False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --------------------------------------------------------------------------- number passes
|
|
196
|
+
|
|
197
|
+
_PHONES = (
|
|
198
|
+
re.compile(r"(?<![\w+])\+?(998)[ -]?\(?(\d{2})\)?[ -]?(\d{3})[ -]?(\d{2})[ -]?(\d{2})(?!\d)"),
|
|
199
|
+
re.compile(r"(?<![\w+])\+(\d{1,3})[ -]?\(?(\d{2,4})\)?[ -]?(\d{2,4})[ -]?(\d{2})[ -]?(\d{2})(?!\d)"),
|
|
200
|
+
re.compile(r"(?<![\w+])\(?(\d{2})\)?[ -](\d{3})[ -](\d{2})[ -](\d{2})(?!\d)"),
|
|
201
|
+
)
|
|
202
|
+
_DATE_DMY = re.compile(
|
|
203
|
+
r"(?<![\d.,])(0?[1-9]|[12]\d|3[01])([./-])(0?[1-9]|1[0-2])\2((?:1[6-9]|20|21)\d{2})(?!\d)"
|
|
204
|
+
r"(?: ?-? ?(y\.|yil\w*))?"
|
|
205
|
+
)
|
|
206
|
+
_DATE_YMD = re.compile(r"(?<![\d.,-])((?:1[6-9]|20|21)\d{2})([-./])(0[1-9]|1[0-2])\2(0[1-9]|[12]\d|3[01])(?!\d)")
|
|
207
|
+
_TIME_SUFFIX = r"(?:[- ](?=(?:da|dan|ga|gacha|cha|ni|ning|dagi)(?!\w)))?"
|
|
208
|
+
_TIME = re.compile(r"(?<![\d:.,])([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?(?![\d:])" + _TIME_SUFFIX)
|
|
209
|
+
_SOAT_TIME = re.compile(r"(?<!\w)([Ss]oat\s+)([01]?\d|2[0-3])\.([0-5]\d)(?![\d.])" + _TIME_SUFFIX)
|
|
210
|
+
_ROMAN_RE = re.compile(
|
|
211
|
+
r"(?<![\w-])(?=[MDCLXVI])(M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3}))(?: ?- ?| +)"
|
|
212
|
+
+ _ok(r"(?=(?i:asr|bob|jild|qism|kurs|chorak|bosqich|modda|bo'lim|sinf|toifa|guruh|qurultoy))")
|
|
213
|
+
)
|
|
214
|
+
_VULGAR = {
|
|
215
|
+
"½": (1, 2), "⅓": (1, 3), "⅔": (2, 3), "¼": (1, 4), "¾": (3, 4), "⅕": (1, 5), "⅖": (2, 5),
|
|
216
|
+
"⅗": (3, 5), "⅘": (4, 5), "⅙": (1, 6), "⅚": (5, 6), "⅛": (1, 8), "⅜": (3, 8), "⅝": (5, 8), "⅞": (7, 8),
|
|
217
|
+
}
|
|
218
|
+
_VULGAR_RE = re.compile(r"(?<![\w.,])(?:(\d+) ?)?([" + "".join(_VULGAR) + "])")
|
|
219
|
+
_FRACTION = re.compile(r"(?<![\w.,/])(\d{1,3})/(\d{1,3})(?![\d/])")
|
|
220
|
+
_PLUS = re.compile(r"(?<=\d) ?\+ ?(?=\d)")
|
|
221
|
+
_EQUALS = re.compile(r"(?<=\d) ?= ?(?=[-+]?\d)")
|
|
222
|
+
_TIMES = re.compile(r"(?<=\d) ?[x×] ?(?=\d)")
|
|
223
|
+
_MINUS_SIGN = re.compile(r"(?<![\w.,])-(?=\d)")
|
|
224
|
+
_PLUS_SIGN = re.compile(r"(?<![\w.,])\+(?=\d)")
|
|
225
|
+
|
|
226
|
+
_RANGE_PREFIX = rf"(?:({_NUM})-)?" # "5-10%" -> both numbers belong to the unit
|
|
227
|
+
# lets a unit word take a following suffix: "25%-ga", "25% ga" -> "foizga"
|
|
228
|
+
_GLUE = rf"(?:-(?=[^\W\d_])| (?={_CASE}(?!\w)))?"
|
|
229
|
+
_SCALE = r"(?: ?(ming|million|milliard|trillion)(?!\w))?"
|
|
230
|
+
_CURRENCY_SYMBOLS = {"$": "dollar", "€": "yevro", "£": "funt sterling", "₽": "rubl", "¥": "iyena", "₸": "tenge"}
|
|
231
|
+
_CURRENCY_CODES = {
|
|
232
|
+
"USD": "dollar", "EUR": "yevro", "RUB": "rubl", "UZS": _ok("so'm"), "KZT": "tenge",
|
|
233
|
+
"GBP": "funt sterling", "CNY": "yuan",
|
|
234
|
+
}
|
|
235
|
+
_SYM_CLASS = "[" + re.escape("".join(_CURRENCY_SYMBOLS)) + "]"
|
|
236
|
+
_CURRENCY_BEFORE = re.compile(rf"({_SYM_CLASS}) ?({_NUM})(?:-({_NUM}))?{_SCALE}")
|
|
237
|
+
_CURRENCY_AFTER = re.compile(rf"{_RANGE_PREFIX}({_NUM}){_SCALE} ?({_SYM_CLASS}){_GLUE}")
|
|
238
|
+
_CURRENCY_CODE = re.compile(rf"{_RANGE_PREFIX}({_NUM}){_SCALE} ?({'|'.join(_CURRENCY_CODES)})(?![A-Za-z]){_GLUE}")
|
|
239
|
+
_PERCENT = re.compile(rf"{_RANGE_PREFIX}({_NUM}) ?([%‰]){_GLUE}")
|
|
240
|
+
_DEGREES = re.compile(rf"{_RANGE_PREFIX}({_NUM}) ?° ?([CSF](?![A-Za-z]))?{_GLUE}")
|
|
241
|
+
|
|
242
|
+
_UNITS = {
|
|
243
|
+
"km²": "kvadrat kilometr", "m²": "kvadrat metr", "sm²": "kvadrat santimetr", "mm²": "kvadrat millimetr",
|
|
244
|
+
"km³": "kub kilometr", "m³": "kub metr", "sm³": "kub santimetr",
|
|
245
|
+
"km": "kilometr", "m": "metr", "sm": "santimetr", "cm": "santimetr", "mm": "millimetr",
|
|
246
|
+
"kg": "kilogramm", "g": "gramm", "gr": "gramm", "mg": "milligramm", "t": "tonna",
|
|
247
|
+
"l": "litr", "ml": "millilitr",
|
|
248
|
+
"kVt": "kilovatt", "MVt": "megavatt", "Vt": "vatt",
|
|
249
|
+
"KB": "kilobayt", "MB": "megabayt", "GB": "gigabayt", "TB": "terabayt",
|
|
250
|
+
"min": "daqiqa", "sek": "soniya",
|
|
251
|
+
}
|
|
252
|
+
_UNIT_RE = re.compile(
|
|
253
|
+
rf"{_RANGE_PREFIX}({_NUM}) ?({'|'.join(sorted(map(re.escape, _UNITS), key=len, reverse=True))})(?!\w){_GLUE}"
|
|
254
|
+
)
|
|
255
|
+
_YEAR_ABBR = re.compile(r"(?<![\w.,])(?:(\d{3,4})-)?(\d{3,4}) ?-? ?(yy|y)\.(?!\w)")
|
|
256
|
+
_RANGE = re.compile(rf"(?<![\w.,])({_NUM})-({_NUM})(?:(-| ?)([^\W\d_]\w*))?")
|
|
257
|
+
_NUMBER_WORD = re.compile(rf"(?<![\w.,])({_NUM})(?:(-| ?)([^\W\d_]\w*))?")
|
|
258
|
+
_ANY_DIGITS = re.compile(r"\d+")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _date_dmy(m: "re.Match[str]") -> str:
|
|
262
|
+
day, _, month, year, year_word = m.groups()
|
|
263
|
+
year_word = "yil" if year_word in (None, "y.") else year_word
|
|
264
|
+
return f"{number_to_ordinal(int(day))} {MONTHS[int(month)]} {number_to_ordinal(int(year))} {year_word}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _date_ymd(m: "re.Match[str]") -> str:
|
|
268
|
+
year, _, month, day = m.groups()
|
|
269
|
+
return f"{number_to_ordinal(int(year))} yil {number_to_ordinal(int(day))} {MONTHS[int(month)]}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _time(hours: str, minutes: str, seconds: Optional[str] = None) -> str:
|
|
273
|
+
parts = [read_integer(hours.lstrip("0") or "0"), read_integer(minutes)]
|
|
274
|
+
if seconds:
|
|
275
|
+
parts.append(read_integer(seconds))
|
|
276
|
+
return " ".join(parts)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _vulgar(m: "re.Match[str]") -> str:
|
|
280
|
+
numerator, denominator = _VULGAR[m.group(2)]
|
|
281
|
+
words = fraction_to_words(numerator, denominator)
|
|
282
|
+
return (_say(m.group(1)) + " butun " + words) if m.group(1) else words
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _fraction(m: "re.Match[str]") -> str:
|
|
286
|
+
numerator, denominator = int(m.group(1)), int(m.group(2))
|
|
287
|
+
if 0 < numerator < denominator:
|
|
288
|
+
return fraction_to_words(numerator, denominator)
|
|
289
|
+
return m.group()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _with_scale(text: str, scale: Optional[str]) -> str:
|
|
293
|
+
return f"{text} {scale}" if scale else text
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _range(m: "re.Match[str]") -> str:
|
|
297
|
+
first, second, sep, word = m.groups()
|
|
298
|
+
second_text, ordinal = _verbalize(second, sep, word)
|
|
299
|
+
integer, fraction = _split_number(first)
|
|
300
|
+
first_text = number_to_ordinal(int(integer)) if ordinal and fraction is None else _say(first)
|
|
301
|
+
return first_text + " " + second_text
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def expand_numbers(text: str) -> str:
|
|
305
|
+
"""Spell out every number in ``text`` (expects canonical apostrophes)."""
|
|
306
|
+
text = _ROMAN_RE.sub(lambda m: number_to_ordinal(roman_to_int(m.group(1))) + " ", text)
|
|
307
|
+
if not any(c.isdigit() or c in _VULGAR for c in text):
|
|
308
|
+
return text
|
|
309
|
+
for pattern in _PHONES:
|
|
310
|
+
text = pattern.sub(lambda m: " ".join(read_integer(g) for g in m.groups()), text)
|
|
311
|
+
text = _DATE_DMY.sub(_date_dmy, text)
|
|
312
|
+
text = _DATE_YMD.sub(_date_ymd, text)
|
|
313
|
+
text = _TIME.sub(lambda m: _time(*m.groups()), text)
|
|
314
|
+
text = _SOAT_TIME.sub(lambda m: m.group(1) + _time(m.group(2), m.group(3)), text)
|
|
315
|
+
text = _VULGAR_RE.sub(_vulgar, text)
|
|
316
|
+
text = _FRACTION.sub(_fraction, text)
|
|
317
|
+
text = _PLUS.sub(" plyus ", text)
|
|
318
|
+
text = _EQUALS.sub(" teng ", text)
|
|
319
|
+
text = _TIMES.sub(" karra ", text)
|
|
320
|
+
text = _MINUS_SIGN.sub("minus ", text)
|
|
321
|
+
text = _PLUS_SIGN.sub("plyus ", text)
|
|
322
|
+
text = _CURRENCY_BEFORE.sub(
|
|
323
|
+
lambda m: _with_scale(_say_range(m.group(2), m.group(3)) if m.group(3) else _say(m.group(2)), m.group(4))
|
|
324
|
+
+ " " + _CURRENCY_SYMBOLS[m.group(1)],
|
|
325
|
+
text,
|
|
326
|
+
)
|
|
327
|
+
text = _CURRENCY_AFTER.sub(
|
|
328
|
+
lambda m: _with_scale(_say_range(m.group(1), m.group(2)), m.group(3)) + " " + _CURRENCY_SYMBOLS[m.group(4)],
|
|
329
|
+
text,
|
|
330
|
+
)
|
|
331
|
+
text = _CURRENCY_CODE.sub(
|
|
332
|
+
lambda m: _with_scale(_say_range(m.group(1), m.group(2)), m.group(3)) + " " + _CURRENCY_CODES[m.group(4)],
|
|
333
|
+
text,
|
|
334
|
+
)
|
|
335
|
+
text = _PERCENT.sub(
|
|
336
|
+
lambda m: _say_range(m.group(1), m.group(2)) + (" foiz" if m.group(3) == "%" else " promille"), text
|
|
337
|
+
)
|
|
338
|
+
text = _DEGREES.sub(
|
|
339
|
+
lambda m: _say_range(m.group(1), m.group(2)) + " gradus" + (" farengeyt" if m.group(3) == "F" else ""),
|
|
340
|
+
text,
|
|
341
|
+
)
|
|
342
|
+
text = _UNIT_RE.sub(lambda m: _say_range(m.group(1), m.group(2)) + " " + _UNITS[m.group(3)], text)
|
|
343
|
+
text = _YEAR_ABBR.sub(
|
|
344
|
+
lambda m: _say_range(m.group(1), m.group(2), ordinal=True) + (" yillar" if m.group(3) == "yy" else " yil"),
|
|
345
|
+
text,
|
|
346
|
+
)
|
|
347
|
+
text = _RANGE.sub(_range, text)
|
|
348
|
+
text = _NUMBER_WORD.sub(lambda m: _verbalize(*m.groups())[0], text)
|
|
349
|
+
# anything left (digits glued to letters: "A5", superscripts) is read plainly
|
|
350
|
+
text = text.translate(_SUPERSCRIPTS)
|
|
351
|
+
return _ANY_DIGITS.sub(lambda m: " " + read_integer(m.group()) + " ", text)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --------------------------------------------------------------------------- final clean-up
|
|
355
|
+
|
|
356
|
+
_LATIN_SPECIAL = str.maketrans({"ı": "i", "ø": "o", "Ø": "O", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D", "ß": "ss"})
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _strip_latin_accents(text: str) -> str:
|
|
360
|
+
"""é -> e, ö -> o, ç -> c. Cyrillic letters (й, ё) are left alone."""
|
|
361
|
+
if text.isascii():
|
|
362
|
+
return text
|
|
363
|
+
text = text.translate(_LATIN_SPECIAL)
|
|
364
|
+
out = []
|
|
365
|
+
after_latin = False
|
|
366
|
+
for ch in unicodedata.normalize("NFD", text):
|
|
367
|
+
if unicodedata.combining(ch):
|
|
368
|
+
if after_latin:
|
|
369
|
+
continue
|
|
370
|
+
else:
|
|
371
|
+
after_latin = ch.isascii() and ch.isalpha()
|
|
372
|
+
out.append(ch)
|
|
373
|
+
return unicodedata.normalize("NFC", "".join(out))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
_NON_WORD = re.compile(r"[^\w\s]|_")
|
|
377
|
+
_LONE_OKINA = re.compile(r"(?<![^\W\d_])" + OKINA)
|
|
378
|
+
_SPACE_BEFORE_PUNCT = re.compile(r" +([.,!?;:)\]»])")
|
|
379
|
+
_MULTISPACE = re.compile(r"\s+")
|
|
380
|
+
|
|
381
|
+
Rule = Tuple[Union[str, "re.Pattern[str]"], str]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class UzbekNormalizer:
|
|
385
|
+
"""Normalize Uzbek text for ASR training and evaluation.
|
|
386
|
+
|
|
387
|
+
>>> norm = UzbekNormalizer()
|
|
388
|
+
>>> norm("2024-yil 15-mart kuni soat 14:30 da 1,5 mln so'm to'landi.")
|
|
389
|
+
'ikki ming yigirma toʻrtinchi yil oʻn beshinchi mart kuni soat oʻn toʻrt oʻttizda bir butun oʻndan besh million soʻm toʻlandi'
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
transliterate: convert Cyrillic Uzbek to Latin.
|
|
393
|
+
expand_numbers: spell out numbers, dates, times, money, percent, units.
|
|
394
|
+
expand_abbreviations: expand abbreviations (mln, h.k., sh.) and symbols (%, №, &).
|
|
395
|
+
lowercase: lowercase the output.
|
|
396
|
+
remove_punctuation: drop punctuation and quote marks (word-internal apostrophes stay).
|
|
397
|
+
strip_accents: é -> e, ü -> u for foreign Latin letters.
|
|
398
|
+
drop_annotations: remove [noise], [1], <unk> style tags.
|
|
399
|
+
apostrophe: the single character used for oʻ, gʻ and the tutuq belgisi.
|
|
400
|
+
Default is ʻ (U+02BB); pass "'" for plain ASCII.
|
|
401
|
+
custom_rules: extra (regex, replacement) pairs applied first, for domain terms.
|
|
402
|
+
alphabet: "old" (1995: oʻ gʻ sh ch) or "new" (2026 reform: ö ğ ş ç).
|
|
403
|
+
Input in either alphabet is accepted.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
def __init__(
|
|
407
|
+
self,
|
|
408
|
+
*,
|
|
409
|
+
transliterate: bool = True,
|
|
410
|
+
expand_numbers: bool = True,
|
|
411
|
+
expand_abbreviations: bool = True,
|
|
412
|
+
lowercase: bool = True,
|
|
413
|
+
remove_punctuation: bool = True,
|
|
414
|
+
strip_accents: bool = True,
|
|
415
|
+
drop_annotations: bool = True,
|
|
416
|
+
apostrophe: str = OKINA,
|
|
417
|
+
custom_rules: Sequence[Rule] = (),
|
|
418
|
+
alphabet: str = "old",
|
|
419
|
+
) -> None:
|
|
420
|
+
if alphabet not in ("old", "new"):
|
|
421
|
+
raise ValueError(f'alphabet must be "old" or "new", got {alphabet!r}')
|
|
422
|
+
self.alphabet = alphabet
|
|
423
|
+
self.transliterate = transliterate
|
|
424
|
+
self.expand_numbers = expand_numbers
|
|
425
|
+
self.expand_abbreviations = expand_abbreviations
|
|
426
|
+
self.lowercase = lowercase
|
|
427
|
+
self.remove_punctuation = remove_punctuation
|
|
428
|
+
self.strip_accents = strip_accents
|
|
429
|
+
self.drop_annotations = drop_annotations
|
|
430
|
+
self.apostrophe = apostrophe
|
|
431
|
+
self.custom_rules = [(re.compile(p) if isinstance(p, str) else p, r) for p, r in custom_rules]
|
|
432
|
+
|
|
433
|
+
def normalize(self, text: str) -> str:
|
|
434
|
+
if not text:
|
|
435
|
+
return ""
|
|
436
|
+
text = _pre_clean(text)
|
|
437
|
+
for pattern, replacement in self.custom_rules:
|
|
438
|
+
text = pattern.sub(replacement, text)
|
|
439
|
+
if self.drop_annotations:
|
|
440
|
+
text = _ANNOTATION.sub(" ", text)
|
|
441
|
+
text = fix_mixed_script(text)
|
|
442
|
+
if self.transliterate:
|
|
443
|
+
text = cyrillic_to_latin(text)
|
|
444
|
+
text = _canonical_apostrophes(text)
|
|
445
|
+
# work internally in the old alphabet: ö ğ ş ç -> oʻ gʻ sh ch
|
|
446
|
+
text = _letters_to_old(text)
|
|
447
|
+
if self.expand_abbreviations:
|
|
448
|
+
text = _expand_abbreviations(text)
|
|
449
|
+
if self.expand_numbers:
|
|
450
|
+
text = expand_numbers(text)
|
|
451
|
+
if self.expand_abbreviations:
|
|
452
|
+
text = _expand_symbols(text)
|
|
453
|
+
if self.lowercase:
|
|
454
|
+
text = text.lower()
|
|
455
|
+
if self.strip_accents:
|
|
456
|
+
text = _strip_latin_accents(text)
|
|
457
|
+
if self.remove_punctuation:
|
|
458
|
+
text = _NON_WORD.sub(" ", text)
|
|
459
|
+
text = _LONE_OKINA.sub("", text)
|
|
460
|
+
text = _MULTISPACE.sub(" ", text).strip()
|
|
461
|
+
else:
|
|
462
|
+
text = _MULTISPACE.sub(" ", text).strip()
|
|
463
|
+
text = _SPACE_BEFORE_PUNCT.sub(r"\1", text)
|
|
464
|
+
if self.alphabet == "new":
|
|
465
|
+
text = to_new(text, transliterate=False)
|
|
466
|
+
if self.apostrophe != OKINA:
|
|
467
|
+
text = text.replace(OKINA, self.apostrophe)
|
|
468
|
+
return text
|
|
469
|
+
|
|
470
|
+
def normalize_batch(self, texts: Iterable[str]) -> List[str]:
|
|
471
|
+
return [self.normalize(t) for t in texts]
|
|
472
|
+
|
|
473
|
+
def __call__(self, text: Union[str, Iterable[str]]) -> Union[str, List[str]]:
|
|
474
|
+
if isinstance(text, str):
|
|
475
|
+
return self.normalize(text)
|
|
476
|
+
return self.normalize_batch(text)
|
|
477
|
+
|
|
478
|
+
def __repr__(self) -> str:
|
|
479
|
+
opts = ", ".join(
|
|
480
|
+
f"{k}={getattr(self, k)!r}"
|
|
481
|
+
for k in ("alphabet", "transliterate", "expand_numbers", "expand_abbreviations", "lowercase",
|
|
482
|
+
"remove_punctuation", "strip_accents", "drop_annotations", "apostrophe")
|
|
483
|
+
)
|
|
484
|
+
return f"UzbekNormalizer({opts})"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
2
|
+
# Free for non-commercial use only; modification and redistribution are not allowed.
|
|
3
|
+
# Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
|
|
4
|
+
|
|
5
|
+
"""Spell numbers as Uzbek (Latin script) words."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Tuple
|
|
10
|
+
|
|
11
|
+
OKINA = "ʻ" # ʻ (as in oʻ, gʻ)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ok(*words: str) -> Tuple[str, ...]:
|
|
15
|
+
"""Write words with ASCII ' in the source, store them with the okina."""
|
|
16
|
+
return tuple(w.replace("'", OKINA) for w in words)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ONES = _ok("nol", "bir", "ikki", "uch", "to'rt", "besh", "olti", "yetti", "sakkiz", "to'qqiz")
|
|
20
|
+
TENS = _ok("", "o'n", "yigirma", "o'ttiz", "qirq", "ellik", "oltmish", "yetmish", "sakson", "to'qson")
|
|
21
|
+
MONTHS = (
|
|
22
|
+
"", "yanvar", "fevral", "mart", "aprel", "may", "iyun",
|
|
23
|
+
"iyul", "avgust", "sentabr", "oktabr", "noyabr", "dekabr",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_SCALES = ((10 ** 12, "trillion"), (10 ** 9, "milliard"), (10 ** 6, "million"), (10 ** 3, "ming"))
|
|
27
|
+
_LIMIT = 10 ** 15 # from here on, numbers are read digit by digit
|
|
28
|
+
_SCALE_WORDS = frozenset(("yuz", "ming", "million", "milliard", "trillion"))
|
|
29
|
+
_DECIMAL_PLACES = dict(enumerate(_ok("o'ndan", "yuzdan", "mingdan", "o'n mingdan", "yuz mingdan", "milliondan"), 1))
|
|
30
|
+
_VOWELS = frozenset("aeiou")
|
|
31
|
+
_ROMAN = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _below_thousand(n: int, *, bir_yuz: bool) -> str:
|
|
35
|
+
hundreds, rest = divmod(n, 100)
|
|
36
|
+
parts = []
|
|
37
|
+
if hundreds == 1:
|
|
38
|
+
parts.append("bir yuz" if bir_yuz else "yuz")
|
|
39
|
+
elif hundreds:
|
|
40
|
+
parts.append(ONES[hundreds] + " yuz")
|
|
41
|
+
tens, ones = divmod(rest, 10)
|
|
42
|
+
if tens:
|
|
43
|
+
parts.append(TENS[tens])
|
|
44
|
+
if ones:
|
|
45
|
+
parts.append(ONES[ones])
|
|
46
|
+
return " ".join(parts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def digits_to_words(digits: str) -> str:
|
|
50
|
+
"""'007' -> 'nol nol yetti'."""
|
|
51
|
+
return " ".join(ONES[int(d)] for d in digits if d in "0123456789")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def number_to_words(n: int) -> str:
|
|
55
|
+
"""Cardinal number: 2024 -> 'ikki ming yigirma toʻrt'."""
|
|
56
|
+
n = int(n)
|
|
57
|
+
if n < 0:
|
|
58
|
+
return "minus " + number_to_words(-n)
|
|
59
|
+
if n == 0:
|
|
60
|
+
return ONES[0]
|
|
61
|
+
if n >= _LIMIT:
|
|
62
|
+
return digits_to_words(str(n))
|
|
63
|
+
parts = []
|
|
64
|
+
for value, name in _SCALES:
|
|
65
|
+
count, n = divmod(n, value)
|
|
66
|
+
if count == 1:
|
|
67
|
+
parts.append("bir " + name)
|
|
68
|
+
elif count:
|
|
69
|
+
# a hundred that multiplies a scale is bare: 100 000 -> "yuz ming"
|
|
70
|
+
parts.append(_below_thousand(count, bir_yuz=False) + " " + name)
|
|
71
|
+
if n:
|
|
72
|
+
parts.append(_below_thousand(n, bir_yuz=True))
|
|
73
|
+
return " ".join(parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def to_ordinal(words: str) -> str:
|
|
77
|
+
"""Turn spelled cardinal words into the ordinal form (suffix on the last word)."""
|
|
78
|
+
parts = words.split()
|
|
79
|
+
# 100 -> "yuzinchi", 1000 -> "minginchi" (not "bir yuzinchi")
|
|
80
|
+
if len(parts) == 2 and parts[0] == "bir" and parts[1] in _SCALE_WORDS:
|
|
81
|
+
parts = parts[1:]
|
|
82
|
+
last = parts[-1]
|
|
83
|
+
parts[-1] = last + ("nchi" if last[-1] in _VOWELS else "inchi")
|
|
84
|
+
return " ".join(parts)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def number_to_ordinal(n: int) -> str:
|
|
88
|
+
"""Ordinal number: 2 -> 'ikkinchi', 2024 -> 'ikki ming yigirma toʻrtinchi'."""
|
|
89
|
+
return to_ordinal(number_to_words(n))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_integer(digits: str) -> str:
|
|
93
|
+
"""Read a digit string; a leading zero means it is read digit by digit ('05' -> 'nol besh')."""
|
|
94
|
+
if len(digits) > 1 and digits[0] == "0":
|
|
95
|
+
return digits_to_words(digits)
|
|
96
|
+
return number_to_words(int(digits))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def decimal_to_words(integer: str, fraction: str) -> str:
|
|
100
|
+
"""'3', '5' -> 'uch butun oʻndan besh'; '2', '75' -> 'ikki butun yuzdan yetmish besh'."""
|
|
101
|
+
whole = number_to_words(int(integer))
|
|
102
|
+
place = _DECIMAL_PLACES.get(len(fraction))
|
|
103
|
+
if place is None:
|
|
104
|
+
return whole + " butun " + digits_to_words(fraction)
|
|
105
|
+
return whole + " butun " + place + " " + number_to_words(int(fraction))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def attach_suffix(words: str, suffix: str) -> str:
|
|
109
|
+
"""Glue a grammatical suffix to a number word: 'qirq' + 'ga' -> 'qirqqa'."""
|
|
110
|
+
if not suffix:
|
|
111
|
+
return words
|
|
112
|
+
if words.endswith("q") and suffix[0] in "gG":
|
|
113
|
+
suffix = "q" + suffix[1:]
|
|
114
|
+
elif words.endswith("k") and suffix[0] in "gG":
|
|
115
|
+
suffix = "k" + suffix[1:]
|
|
116
|
+
return words + suffix
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def fraction_to_words(numerator: int, denominator: int) -> str:
|
|
120
|
+
"""3/4 -> 'toʻrtdan uch'."""
|
|
121
|
+
return attach_suffix(number_to_words(denominator), "dan") + " " + number_to_words(numerator)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def roman_to_int(roman: str) -> int:
|
|
125
|
+
total = prev = 0
|
|
126
|
+
for ch in reversed(roman.upper()):
|
|
127
|
+
value = _ROMAN[ch]
|
|
128
|
+
if value < prev:
|
|
129
|
+
total -= value
|
|
130
|
+
else:
|
|
131
|
+
total += value
|
|
132
|
+
prev = value
|
|
133
|
+
return total
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
2
|
+
# Free for non-commercial use only; modification and redistribution are not allowed.
|
|
3
|
+
# Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
|
|
4
|
+
|
|
5
|
+
"""Uzbek Cyrillic -> Latin transliteration and mixed-script (homoglyph) repair."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from .numbers import OKINA
|
|
12
|
+
|
|
13
|
+
TUTUQ = "ʼ" # ʼ (tutuq belgisi)
|
|
14
|
+
|
|
15
|
+
_CYR_VOWELS = frozenset("аеёиоуўэюяы")
|
|
16
|
+
|
|
17
|
+
_CYR2LAT = {
|
|
18
|
+
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "ё": "yo", "ж": "j", "з": "z",
|
|
19
|
+
"и": "i", "й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p",
|
|
20
|
+
"р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "x", "ч": "ch", "ш": "sh",
|
|
21
|
+
"щ": "sh", "ъ": TUTUQ, "ы": "i", "ь": "", "э": "e", "ю": "yu", "я": "ya",
|
|
22
|
+
"ў": "o" + OKINA, "қ": "q", "ғ": "g" + OKINA, "ҳ": "h",
|
|
23
|
+
# letters that sneak in from Russian / Kazakh / Tajik keyboards
|
|
24
|
+
"ӯ": "o" + OKINA, "і": "i", "ї": "yi", "є": "ye", "ґ": "g", "ң": "ng",
|
|
25
|
+
"ә": "a", "ө": "o", "ү": "u", "һ": "h", "ҷ": "j",
|
|
26
|
+
}
|
|
27
|
+
_CONTEXT_LETTERS = frozenset("ецҳ")
|
|
28
|
+
_HAS_CYRILLIC = re.compile("[Ѐ-ӿ]")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cyrillic_to_latin(text: str) -> str:
|
|
32
|
+
"""Transliterate Uzbek Cyrillic to the official Latin alphabet, keeping case.
|
|
33
|
+
|
|
34
|
+
Rules beyond one-to-one letters:
|
|
35
|
+
* е -> "ye" at the start of a word or after a vowel/ъ/ь, otherwise "e"
|
|
36
|
+
* ц -> "ts" after a vowel, otherwise "s"
|
|
37
|
+
* сҳ -> "sʼh" (so it is not read as the digraph "sh")
|
|
38
|
+
"""
|
|
39
|
+
if not _HAS_CYRILLIC.search(text):
|
|
40
|
+
return text
|
|
41
|
+
out = []
|
|
42
|
+
length = len(text)
|
|
43
|
+
for i, ch in enumerate(text):
|
|
44
|
+
low = ch.lower()
|
|
45
|
+
if low not in _CYR2LAT and low not in _CONTEXT_LETTERS:
|
|
46
|
+
out.append(ch)
|
|
47
|
+
continue
|
|
48
|
+
prev = text[i - 1] if i else ""
|
|
49
|
+
prev_low = prev.lower()
|
|
50
|
+
if low == "е":
|
|
51
|
+
starts_word = not prev.isalpha()
|
|
52
|
+
lat = "ye" if starts_word or prev_low in _CYR_VOWELS or prev_low in "ъь" else "e"
|
|
53
|
+
elif low == "ц":
|
|
54
|
+
lat = "ts" if prev_low in _CYR_VOWELS else "s"
|
|
55
|
+
elif low == "ҳ":
|
|
56
|
+
lat = TUTUQ + "h" if prev_low == "с" else "h"
|
|
57
|
+
else:
|
|
58
|
+
lat = _CYR2LAT[low]
|
|
59
|
+
if lat and ch.isupper():
|
|
60
|
+
nxt = text[i + 1] if i + 1 < length else ""
|
|
61
|
+
all_caps = nxt.isupper() or (prev.isupper() and not nxt.isalpha())
|
|
62
|
+
lat = lat.upper() if all_caps else lat[0].upper() + lat[1:]
|
|
63
|
+
out.append(lat)
|
|
64
|
+
return "".join(out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Letters that look identical in Latin and Cyrillic.
|
|
68
|
+
_CYR_LOOKALIKE = {
|
|
69
|
+
"а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "у": "y", "х": "x", "і": "i",
|
|
70
|
+
"ј": "j", "ԛ": "q", "ѕ": "s", "һ": "h",
|
|
71
|
+
"А": "A", "В": "B", "Е": "E", "К": "K", "М": "M", "Н": "H", "О": "O", "Р": "P",
|
|
72
|
+
"С": "C", "Т": "T", "Х": "X", "У": "Y", "І": "I", "Ј": "J", "Ѕ": "S",
|
|
73
|
+
}
|
|
74
|
+
_TO_LATIN = str.maketrans(_CYR_LOOKALIKE)
|
|
75
|
+
_TO_CYRILLIC = str.maketrans({lat: cyr for cyr, lat in _CYR_LOOKALIKE.items() if cyr not in "іјԛѕһІЈЅ"})
|
|
76
|
+
_WORD = re.compile(r"[^\W\d_]+")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _repair_word(match: "re.Match[str]") -> str:
|
|
80
|
+
word = match.group()
|
|
81
|
+
cyrillic = sum(1 for c in word if "Ѐ" <= c <= "ӿ")
|
|
82
|
+
if not cyrillic or cyrillic == len(word):
|
|
83
|
+
return word
|
|
84
|
+
latin = sum(1 for c in word if "a" <= c.lower() <= "z")
|
|
85
|
+
if not latin:
|
|
86
|
+
return word
|
|
87
|
+
# Majority script wins: fix the look-alike letters of the minority script.
|
|
88
|
+
return word.translate(_TO_LATIN if latin >= cyrillic else _TO_CYRILLIC)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def fix_mixed_script(text: str) -> str:
|
|
92
|
+
"""Repair words that mix Latin and Cyrillic look-alikes, e.g. 'Toshkеnt' with a Cyrillic 'е'."""
|
|
93
|
+
if not _HAS_CYRILLIC.search(text):
|
|
94
|
+
return text
|
|
95
|
+
return _WORD.sub(_repair_word, text)
|
jayron/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jayron
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Uzbek text normalizer for speech recognition (ASR): numbers, dates, Cyrillic, apostrophes, new 2026 alphabet.
|
|
5
|
+
Author: Rakhimberdi Rakhmonberdiev
|
|
6
|
+
License-Expression: LicenseRef-jayron-NonCommercial
|
|
7
|
+
Keywords: uzbek,asr,speech-recognition,text-normalization,wer,transliteration,nlp
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
15
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
Requires-Dist: build; extra == "dev"
|
|
23
|
+
Requires-Dist: twine; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# jayron
|
|
27
|
+
|
|
28
|
+
Tools for Uzbek speech recognition. The first module is a text normalizer that
|
|
29
|
+
turns written Uzbek into the form a speaker says it, for ASR training targets
|
|
30
|
+
and for scoring (WER/CER). No dependencies, Python 3.9+.
|
|
31
|
+
|
|
32
|
+
> Free for non-commercial use. Commercial use requires a license, see [License](#license).
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install jayron
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from jayron.normalizers import uz_normilizer
|
|
40
|
+
|
|
41
|
+
uz_normilizer("2024-yil 15-mart kuni soat 14:30 da 1,5 mln so'm to'landi.")
|
|
42
|
+
# 'ikki ming yigirma toʻrtinchi yil oʻn beshinchi mart kuni soat oʻn toʻrt oʻttizda
|
|
43
|
+
# bir butun oʻndan besh million soʻm toʻlandi'
|
|
44
|
+
|
|
45
|
+
uz_normilizer(["Ўзбекистон", "5 ta kitob"]) # lists work too
|
|
46
|
+
# ['oʻzbekiston', 'beshta kitob']
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`uz_normalizer` is the same object under the standard spelling.
|
|
50
|
+
|
|
51
|
+
## What it does
|
|
52
|
+
|
|
53
|
+
| Input | Output |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `O‘zbekiston`, `O'zbekiston`, `O`zbekiston` | `oʻzbekiston` (one apostrophe character) |
|
|
56
|
+
| `Ўзбекистон Республикаси` | `oʻzbekiston respublikasi` |
|
|
57
|
+
| `Toshkеnt` (Cyrillic `е` inside) | `toshkent` |
|
|
58
|
+
| `137`, `1 000 000`, `1,000,000` | `bir yuz oʻttiz yetti`, `bir million` |
|
|
59
|
+
| `2024-yil`, `5-sinf`, `2024 yil`, `1991 y.` | `ikki ming yigirma toʻrtinchi yil`, `beshinchi sinf`, ... |
|
|
60
|
+
| `5ta`, `5 ta`, `40ga`, `10dan` | `beshta`, `beshta`, `qirqqa`, `oʻndan` |
|
|
61
|
+
| `3-xonali`, `30-yillik` | `uch xonali`, `oʻttiz yillik` (cardinal, not ordinal) |
|
|
62
|
+
| `3,5`, `2.75` | `uch butun oʻndan besh`, `ikki butun yuzdan yetmish besh` |
|
|
63
|
+
| `1/2`, `½`, `2½` | `ikkidan bir`, `ikkidan bir`, `ikki butun ikkidan bir` |
|
|
64
|
+
| `25%`, `12,5%ga` | `yigirma besh foiz`, `oʻn ikki butun oʻndan besh foizga` |
|
|
65
|
+
| `$100`, `50 €`, `100 USD`, `$1,5 mln` | `bir yuz dollar`, `ellik yevro`, ..., `bir butun oʻndan besh million dollar` |
|
|
66
|
+
| `5 kg`, `50 m²`, `-5°C` | `besh kilogramm`, `ellik kvadrat metr`, `minus besh gradus` |
|
|
67
|
+
| `14:30`, `soat 9:05da` | `oʻn toʻrt oʻttiz`, `soat toʻqqiz nol beshda` |
|
|
68
|
+
| `12.05.2024`, `2024-05-12`, `15 mart` | `oʻn ikkinchi may ikki ming yigirma toʻrtinchi yil`, ..., `oʻn beshinchi mart` |
|
|
69
|
+
| `5-10 kishi`, `1990-2000-yillar` | `besh oʻn kishi`, `... toʻqsoninchi ikki minginchi yillar` |
|
|
70
|
+
| `XXI asr`, `IV bob` | `yigirma birinchi asr`, `toʻrtinchi bob` |
|
|
71
|
+
| `+998 90 123 45 67` | read group by group |
|
|
72
|
+
| `va h.k.`, `Toshkent sh.`, `№5`, `&` | `va hokazo`, `toshkent shahri`, `raqam besh`, `va` |
|
|
73
|
+
| `[noise]`, `<unk>`, `[1]` | removed |
|
|
74
|
+
| `Café`, `«Salom!»` | `cafe`, `salom` |
|
|
75
|
+
|
|
76
|
+
With the defaults, output contains only `a-z`, `ʻ` and single spaces.
|
|
77
|
+
|
|
78
|
+
## New alphabet (2026 reform)
|
|
79
|
+
|
|
80
|
+
The reform law (signed 21 Oct 2026, in force from 22 Apr 2027) replaces
|
|
81
|
+
`Oʻ Gʻ Sh Ch` with `Ö Ğ Ş Ç` and drops `Ng` as a separate letter (it stays
|
|
82
|
+
written as n + g). The apostrophe is kept.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from jayron.normalizers import converter
|
|
86
|
+
|
|
87
|
+
converter("Bog'chaga bordingmi?") # 'Boğçaga bordingmi?'
|
|
88
|
+
converter("Ўзбекистон шаҳри") # 'Özbekiston şahri' (Cyrillic works too)
|
|
89
|
+
converter.to_old("Boğçaga bordingmi?") # 'Bogʻchaga bordingmi?'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Only those four letters change; case, punctuation, digits, `ng`, the tutuq
|
|
93
|
+
(`maʼno`) and `s'h` (`Is'hoq`) are left as they are, and so are links and
|
|
94
|
+
e-mail addresses.
|
|
95
|
+
|
|
96
|
+
To get normalized ASR text in the new alphabet, use `UzbekNormalizer(alphabet="new")`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
UzbekNormalizer(alphabet="new")("Bog'chaga 5 ta o'quvchi bordi.")
|
|
100
|
+
# 'boğçaga beşta öquvçi bordi'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The normalizer reads either alphabet as input, so a corpus that mixes old
|
|
104
|
+
and new spellings comes out consistent.
|
|
105
|
+
|
|
106
|
+
## Options
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from jayron.normalizers import UzbekNormalizer
|
|
110
|
+
|
|
111
|
+
norm = UzbekNormalizer(
|
|
112
|
+
alphabet="old", # "new" -> ö ğ ş ç
|
|
113
|
+
apostrophe="'", # default ʻ (U+02BB); "'" if your tokenizer prefers ASCII
|
|
114
|
+
lowercase=True,
|
|
115
|
+
remove_punctuation=True, # False keeps punctuation (for punctuation-aware models)
|
|
116
|
+
transliterate=True, # Cyrillic -> Latin
|
|
117
|
+
expand_numbers=True,
|
|
118
|
+
expand_abbreviations=True, # mln, h.k., sh., %, №, &
|
|
119
|
+
strip_accents=True, # é -> e
|
|
120
|
+
drop_annotations=True, # [noise], <unk>
|
|
121
|
+
custom_rules=[(r"\bAI\b", "sun'iy intellekt")], # your own regex rules, applied first
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Use the same settings for training transcripts and for scoring (both references
|
|
126
|
+
and model output), or WER will count formatting differences as errors.
|
|
127
|
+
|
|
128
|
+
Lower-level helpers live in `jayron.normalizers.uz`: `number_to_words`,
|
|
129
|
+
`number_to_ordinal`, `cyrillic_to_latin`, `expand_numbers`.
|
|
130
|
+
|
|
131
|
+
## Command line
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
jayron-uz-norm "2024-yil 5 ta kitob"
|
|
135
|
+
jayron-uz-norm -i corpus.txt -o corpus.norm.txt
|
|
136
|
+
jayron-uz-norm -i train.jsonl -o train.norm.jsonl --jsonl --field text # NeMo manifest
|
|
137
|
+
jayron-uz-norm --help
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Conventions and known ambiguities
|
|
141
|
+
|
|
142
|
+
- Hundreds and thousands keep `bir` (`100` → `bir yuz`, `1000` → `bir ming`),
|
|
143
|
+
except when multiplying a larger scale (`100 000` → `yuz ming`) or in ordinals
|
|
144
|
+
(`100-yil` → `yuzinchi yil`).
|
|
145
|
+
- `ʻ` is used for oʻ, gʻ and the tutuq belgisi alike, so the model has one
|
|
146
|
+
apostrophe token.
|
|
147
|
+
- Space-grouped thousands are joined: `5 100` → `besh ming bir yuz`. In plain
|
|
148
|
+
text this is right almost always, but a list such as "5 100" would be merged.
|
|
149
|
+
- A single dot group is a decimal: `1.500` → `bir butun mingdan besh yuz`;
|
|
150
|
+
a single comma group of three digits is thousands: `1,500` → `bir ming besh yuz`.
|
|
151
|
+
- Times are read digit-pair by digit-pair: `14:00` → `oʻn toʻrt nol nol`.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pip install -e '.[dev]'
|
|
157
|
+
pytest
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
163
|
+
|
|
164
|
+
jayron is **free for non-commercial use**: personal projects, study, teaching
|
|
165
|
+
and non-profit research. **Commercial use** (companies, paid products or
|
|
166
|
+
services, commercial ASR models or datasets, paid contract work) **needs a
|
|
167
|
+
paid license**. Contact r.raxmonberdiyev10@gmail.com.
|
|
168
|
+
|
|
169
|
+
The source code may not be modified, copied into other projects, or
|
|
170
|
+
redistributed. See [LICENSE](LICENSE) for the full terms.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
jayron/__init__.py,sha256=Od36H2Mn2ID2xUx7X9qsHx-DWspv7jkAQI5I3EnX61Q,331
|
|
2
|
+
jayron/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
jayron/normalizers/__init__.py,sha256=LKq8zV2qhD1KoMqR-UvepenHINErhwSsMHnMaapV1QM,716
|
|
4
|
+
jayron/normalizers/uz/__init__.py,sha256=CrstU7GY7BnUfpsJqhYS3pwxfAhbGOpfr45YcdU-SXU,906
|
|
5
|
+
jayron/normalizers/uz/__main__.py,sha256=Basn_7a9oA-5RzTo2nyKLq7kIHhf_jhM6T86lAWr8D0,3223
|
|
6
|
+
jayron/normalizers/uz/converter.py,sha256=5czB07ruKQEVIKlKAlAfWCE_VI6ZcXm8sfn6ED-u4mg,5135
|
|
7
|
+
jayron/normalizers/uz/normalizer.py,sha256=XAujRt4hxp5qLrMuSGU_x8LHImObr2EJE_ckLPclTUI,20825
|
|
8
|
+
jayron/normalizers/uz/numbers.py,sha256=U_p2VQ2nPDdMKM3YFc7THcImnpQ_icRNHMZgyhFuwNs,4714
|
|
9
|
+
jayron/normalizers/uz/transliterate.py,sha256=mQoEJfhCu3gtFQbZ_tY2E2CiI7YR05eo0LN-2WhWYII,3873
|
|
10
|
+
jayron-0.1.0.dist-info/licenses/LICENSE,sha256=TVwRhjWpCFjFSm90IaYGekUKHszqyjqhyb9umDt1T5A,3171
|
|
11
|
+
jayron-0.1.0.dist-info/METADATA,sha256=W0WMZWMQl9WcFIMZ0tmQHwi4y5KQVXqkz6yrEhiuMhI,7051
|
|
12
|
+
jayron-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
jayron-0.1.0.dist-info/entry_points.txt,sha256=SEj-GuxyMgc2A32LJQp3DxHGO1-7FKle760YGETFqu0,71
|
|
14
|
+
jayron-0.1.0.dist-info/top_level.txt,sha256=E4YI7kcJnOO71QCTKVP6uZYBK6qTUrc7oxygax08iyQ,7
|
|
15
|
+
jayron-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
jayron License (Non-Commercial, No Derivatives)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
|
|
4
|
+
|
|
5
|
+
The jayron software, including its source code, word lists, rules, regular
|
|
6
|
+
expressions and documentation (the "Software"), is source-available but is
|
|
7
|
+
NOT open source. You may read the source code, but you may use it only as
|
|
8
|
+
described below.
|
|
9
|
+
|
|
10
|
+
1. FREE NON-COMMERCIAL USE
|
|
11
|
+
|
|
12
|
+
You may download, install and run the unmodified Software, and use the
|
|
13
|
+
text it produces (the "Output"), free of charge, for non-commercial
|
|
14
|
+
purposes only. Non-commercial purposes are:
|
|
15
|
+
(a) personal projects that earn no money;
|
|
16
|
+
(b) study and teaching;
|
|
17
|
+
(c) research at universities, schools or non-profit research
|
|
18
|
+
organizations, including research published in papers.
|
|
19
|
+
|
|
20
|
+
If you publish work that uses the Software, please credit
|
|
21
|
+
"jayron by Rakhimberdi Rakhmonberdiev".
|
|
22
|
+
|
|
23
|
+
2. COMMERCIAL USE REQUIRES A PAID LICENSE
|
|
24
|
+
|
|
25
|
+
Any commercial use requires a separate written license from the
|
|
26
|
+
copyright holder. Commercial use includes, for example:
|
|
27
|
+
(a) any use by or for a company or other for-profit organization;
|
|
28
|
+
(b) use in, or to build, a product or service that is sold, licensed,
|
|
29
|
+
offered for a fee or funded by advertising;
|
|
30
|
+
(c) use to prepare, normalize, train, evaluate or improve data or
|
|
31
|
+
models (for example speech recognition models) that are used
|
|
32
|
+
commercially;
|
|
33
|
+
(d) use in paid consulting, freelance or contract work.
|
|
34
|
+
|
|
35
|
+
To get a commercial license, contact Rakhimberdi Rakhmonberdiev at
|
|
36
|
+
r.raxmonberdiyev10@gmail.com.
|
|
37
|
+
|
|
38
|
+
3. WHAT YOU MAY NOT DO
|
|
39
|
+
|
|
40
|
+
Unless the copyright holder has given you written permission, you may not:
|
|
41
|
+
(a) modify, translate, adapt or create derivative works of the
|
|
42
|
+
Software;
|
|
43
|
+
(b) copy the Software or any part of its source code (including its
|
|
44
|
+
rules, word lists and regular expressions) into other software,
|
|
45
|
+
documents or datasets, or re-create it from the source code;
|
|
46
|
+
(c) sell, rent, sublicense or distribute the Software, except that it
|
|
47
|
+
may be distributed unmodified, exactly as published by the
|
|
48
|
+
copyright holder, through the Python Package Index (PyPI) and its
|
|
49
|
+
mirrors;
|
|
50
|
+
(d) remove or change this license or any copyright notice.
|
|
51
|
+
|
|
52
|
+
Copies made automatically when you install or run the Software as
|
|
53
|
+
allowed in section 1 or under a commercial license are permitted.
|
|
54
|
+
|
|
55
|
+
4. TERMINATION
|
|
56
|
+
|
|
57
|
+
If you break any of these terms, your permission to use the Software
|
|
58
|
+
ends automatically, and you must stop using it and delete all copies.
|
|
59
|
+
|
|
60
|
+
5. NO WARRANTY
|
|
61
|
+
|
|
62
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
63
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
64
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
|
65
|
+
NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
66
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
67
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
68
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
69
|
+
|
|
70
|
+
All rights not expressly granted in this license are reserved by the
|
|
71
|
+
copyright holder.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jayron
|