naijanorm 1.0.0__tar.gz
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.
- naijanorm-1.0.0/.github/workflows/tests.yml +16 -0
- naijanorm-1.0.0/CHANGELOG.md +9 -0
- naijanorm-1.0.0/LICENSE +17 -0
- naijanorm-1.0.0/PKG-INFO +185 -0
- naijanorm-1.0.0/README.md +140 -0
- naijanorm-1.0.0/pyproject.toml +44 -0
- naijanorm-1.0.0/src/naijanorm/__init__.py +10 -0
- naijanorm-1.0.0/src/naijanorm/config.py +27 -0
- naijanorm-1.0.0/src/naijanorm/normalizer.py +156 -0
- naijanorm-1.0.0/src/naijanorm/numbers.py +170 -0
- naijanorm-1.0.0/src/naijanorm/result.py +21 -0
- naijanorm-1.0.0/tests/test_numbers.py +48 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with:
|
|
13
|
+
python-version: ${{ matrix.python-version }}
|
|
14
|
+
- run: python -m pip install --upgrade pip
|
|
15
|
+
- run: pip install -e ".[dev]"
|
|
16
|
+
- run: pytest
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
- Initial five-language cardinal number profiles.
|
|
5
|
+
- Nigerian currency, phone, shortcode, percentage, decimal and unit rules.
|
|
6
|
+
- Batch API and normalization event output.
|
|
7
|
+
- Configurable Yoruba traditional Oyo / modern decimal profile.
|
|
8
|
+
- Test and packaging scaffold.
|
|
9
|
+
- Explicitly documents linguistic and contextual limitations.
|
naijanorm-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NaijaNorm contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
naijanorm-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: naijanorm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Nigerian-context text normalization for speech and language AI
|
|
5
|
+
Project-URL: Homepage, https://github.com/YOUR-ORG/naijanorm
|
|
6
|
+
Project-URL: Issues, https://github.com/YOUR-ORG/naijanorm/issues
|
|
7
|
+
Author: NaijaNorm contributors
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 NaijaNorm contributors
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Keywords: ASR,Hausa,Igbo,Nigerian languages,Pidgin,TTS,Yoruba,text normalization
|
|
27
|
+
Classifier: Development Status :: 3 - Alpha
|
|
28
|
+
Classifier: Intended Audience :: Developers
|
|
29
|
+
Classifier: Operating System :: OS Independent
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
36
|
+
Requires-Python: >=3.9
|
|
37
|
+
Provides-Extra: dev
|
|
38
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
40
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
41
|
+
Provides-Extra: excel
|
|
42
|
+
Requires-Dist: openpyxl>=3.1; extra == 'excel'
|
|
43
|
+
Requires-Dist: pandas>=2.0; extra == 'excel'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# NaijaNorm
|
|
47
|
+
|
|
48
|
+
Nigerian-context text normalization for ASR, TTS, NLP and conversational AI.
|
|
49
|
+
|
|
50
|
+
> **Release note:** NaijaNorm 1.0.0 provides working baseline cardinal number generation
|
|
51
|
+
> for Nigerian English, Nigerian Pidgin, Yoruba, Hausa and Igbo, plus context rules for
|
|
52
|
+
> common numeric forms. It is an alpha-quality linguistic baseline, not a claim that every
|
|
53
|
+
> regional form, ordinal, date convention or spoken variant has been exhaustively validated.
|
|
54
|
+
> Before production dataset generation, run native-speaker review and add gold references.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install naijanorm
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For local development:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install -e ".[dev]"
|
|
66
|
+
pytest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from naijanorm import NaijaNorm
|
|
73
|
+
|
|
74
|
+
normalizer = NaijaNorm(language="en-NG")
|
|
75
|
+
print(normalizer.normalize("Pay ₦5,000 for 250MB and dial *310#"))
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Five language profiles
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from naijanorm import NaijaNorm
|
|
82
|
+
|
|
83
|
+
for language in ("en-NG", "pcm-NG", "yo-NG", "ha-NG", "ig-NG"):
|
|
84
|
+
n = NaijaNorm(language=language)
|
|
85
|
+
print(language, n.normalize("123"))
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Language identifiers:
|
|
89
|
+
- `en-NG`: Nigerian English
|
|
90
|
+
- `pcm-NG`: Nigerian Pidgin (practical baseline; spelling is not fully standardized)
|
|
91
|
+
- `yo-NG`: Yoruba, Standard Oyo traditional counting by default
|
|
92
|
+
- `ha-NG`: Hausa, standard Boko baseline
|
|
93
|
+
- `ig-NG`: Igbo, standard orthographic baseline
|
|
94
|
+
|
|
95
|
+
## Yoruba counting profile
|
|
96
|
+
|
|
97
|
+
Traditional Standard Oyo is selected by default:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
yoruba = NaijaNorm("yo-NG", yoruba_system="oyo-traditional")
|
|
101
|
+
print(yoruba.normalize("16")) # ẹ̀rìndínlógún
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For modern decimal/additive reading:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
yoruba_modern = NaijaNorm("yo-NG", yoruba_system="modern-decimal")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Yoruba numeral forms vary by region, register and context. Traditional forms are not always the form speakers choose when reading a phone number, price, mathematical expression or serial number. Use category-aware speech policy and native-speaker validation.
|
|
111
|
+
|
|
112
|
+
## Context examples
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
n = NaijaNorm("en-NG")
|
|
116
|
+
samples = [
|
|
117
|
+
"250MB",
|
|
118
|
+
"₦5,000",
|
|
119
|
+
"08012345678",
|
|
120
|
+
"25th",
|
|
121
|
+
"3.5GB",
|
|
122
|
+
"*310#",
|
|
123
|
+
"75%",
|
|
124
|
+
]
|
|
125
|
+
for sample in samples:
|
|
126
|
+
print(sample, "=>", n.normalize(sample))
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Events and audit trail
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
result = n.normalize_with_events("₦5,000 and 250MB")
|
|
133
|
+
print(result.text)
|
|
134
|
+
for event in result.events:
|
|
135
|
+
print(event.original, event.normalized, event.category)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Select categories
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
n = NaijaNorm("en-NG", categories=frozenset({"currency", "telephone"}))
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Or:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
n.normalize("₦5,000 and 250MB", categories=["currency"])
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Batch processing
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
sentences = ["I bought 3GB", "Send ₦2,500", "Call 08012345678"]
|
|
154
|
+
normalized = n.normalize_batch(sentences)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Important limitations and safe use
|
|
158
|
+
|
|
159
|
+
1. **Ambiguity:** A bare `2026` may be a year, quantity, identifier or model number. The default treats bare numbers as cardinals. Use protected tokens or category controls when identifiers must remain unchanged.
|
|
160
|
+
2. **Ordinals:** English ordinal suffixes are supported. The other languages currently return a cardinal plus a review note; full ordinal morphology requires language-specific validated rules.
|
|
161
|
+
3. **Yoruba:** The Oyo profile includes a traditional vigesimal strategy and common forms, but compounds above the well-attested table should be reviewed. Do not assume one form is universal across Yoruba dialects.
|
|
162
|
+
4. **Hausa and Igbo:** The included algorithms are practical standard profiles. Regional variation and alternative counting conventions require separate tested lexicons.
|
|
163
|
+
5. **Nigerian Pidgin:** Orthography is not uniformly standardized. The package uses one consistent practical spelling profile; projects should publish their chosen style guide.
|
|
164
|
+
6. **Dates and times:** Automatic date/time expansion is deliberately not enabled in this first baseline because numeric date order is ambiguous (DD/MM vs MM/DD), and speech readings depend on context.
|
|
165
|
+
7. **Phone numbers:** Recognizes common Nigerian 11-digit local numbers and +234 formats. It does not validate allocation, carrier or whether a number is active.
|
|
166
|
+
8. **Currencies:** Currency symbol expansion is supported; currency codes and complex mixed-currency accounting expressions need explicit project rules.
|
|
167
|
+
9. **Units:** Common metric and telecom units are supported. Unit case is normalized for matching; abbreviations embedded in words are not intentionally expanded.
|
|
168
|
+
|
|
169
|
+
Do not use the default pipeline blindly on OTPs, bank account numbers, NINs, BVNs, transaction references, postal codes, coordinates, URLs or product identifiers. These need explicit token classes and project policies.
|
|
170
|
+
|
|
171
|
+
## Contributing linguistic data
|
|
172
|
+
|
|
173
|
+
Every language rule should have:
|
|
174
|
+
- a language and dialect/standard label;
|
|
175
|
+
- a source or native-speaker reviewer;
|
|
176
|
+
- examples with expected output;
|
|
177
|
+
- Unicode NFC tests;
|
|
178
|
+
- tests for numbers around every scale boundary;
|
|
179
|
+
- tests for code-switching and preservation.
|
|
180
|
+
|
|
181
|
+
Please submit corrections with audio or a clear orthographic convention where possible.
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# NaijaNorm
|
|
2
|
+
|
|
3
|
+
Nigerian-context text normalization for ASR, TTS, NLP and conversational AI.
|
|
4
|
+
|
|
5
|
+
> **Release note:** NaijaNorm 1.0.0 provides working baseline cardinal number generation
|
|
6
|
+
> for Nigerian English, Nigerian Pidgin, Yoruba, Hausa and Igbo, plus context rules for
|
|
7
|
+
> common numeric forms. It is an alpha-quality linguistic baseline, not a claim that every
|
|
8
|
+
> regional form, ordinal, date convention or spoken variant has been exhaustively validated.
|
|
9
|
+
> Before production dataset generation, run native-speaker review and add gold references.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install naijanorm
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For local development:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install -e ".[dev]"
|
|
21
|
+
pytest
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from naijanorm import NaijaNorm
|
|
28
|
+
|
|
29
|
+
normalizer = NaijaNorm(language="en-NG")
|
|
30
|
+
print(normalizer.normalize("Pay ₦5,000 for 250MB and dial *310#"))
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Five language profiles
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from naijanorm import NaijaNorm
|
|
37
|
+
|
|
38
|
+
for language in ("en-NG", "pcm-NG", "yo-NG", "ha-NG", "ig-NG"):
|
|
39
|
+
n = NaijaNorm(language=language)
|
|
40
|
+
print(language, n.normalize("123"))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Language identifiers:
|
|
44
|
+
- `en-NG`: Nigerian English
|
|
45
|
+
- `pcm-NG`: Nigerian Pidgin (practical baseline; spelling is not fully standardized)
|
|
46
|
+
- `yo-NG`: Yoruba, Standard Oyo traditional counting by default
|
|
47
|
+
- `ha-NG`: Hausa, standard Boko baseline
|
|
48
|
+
- `ig-NG`: Igbo, standard orthographic baseline
|
|
49
|
+
|
|
50
|
+
## Yoruba counting profile
|
|
51
|
+
|
|
52
|
+
Traditional Standard Oyo is selected by default:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
yoruba = NaijaNorm("yo-NG", yoruba_system="oyo-traditional")
|
|
56
|
+
print(yoruba.normalize("16")) # ẹ̀rìndínlógún
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For modern decimal/additive reading:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
yoruba_modern = NaijaNorm("yo-NG", yoruba_system="modern-decimal")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Yoruba numeral forms vary by region, register and context. Traditional forms are not always the form speakers choose when reading a phone number, price, mathematical expression or serial number. Use category-aware speech policy and native-speaker validation.
|
|
66
|
+
|
|
67
|
+
## Context examples
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
n = NaijaNorm("en-NG")
|
|
71
|
+
samples = [
|
|
72
|
+
"250MB",
|
|
73
|
+
"₦5,000",
|
|
74
|
+
"08012345678",
|
|
75
|
+
"25th",
|
|
76
|
+
"3.5GB",
|
|
77
|
+
"*310#",
|
|
78
|
+
"75%",
|
|
79
|
+
]
|
|
80
|
+
for sample in samples:
|
|
81
|
+
print(sample, "=>", n.normalize(sample))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Events and audit trail
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
result = n.normalize_with_events("₦5,000 and 250MB")
|
|
88
|
+
print(result.text)
|
|
89
|
+
for event in result.events:
|
|
90
|
+
print(event.original, event.normalized, event.category)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Select categories
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
n = NaijaNorm("en-NG", categories=frozenset({"currency", "telephone"}))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
n.normalize("₦5,000 and 250MB", categories=["currency"])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Batch processing
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
sentences = ["I bought 3GB", "Send ₦2,500", "Call 08012345678"]
|
|
109
|
+
normalized = n.normalize_batch(sentences)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Important limitations and safe use
|
|
113
|
+
|
|
114
|
+
1. **Ambiguity:** A bare `2026` may be a year, quantity, identifier or model number. The default treats bare numbers as cardinals. Use protected tokens or category controls when identifiers must remain unchanged.
|
|
115
|
+
2. **Ordinals:** English ordinal suffixes are supported. The other languages currently return a cardinal plus a review note; full ordinal morphology requires language-specific validated rules.
|
|
116
|
+
3. **Yoruba:** The Oyo profile includes a traditional vigesimal strategy and common forms, but compounds above the well-attested table should be reviewed. Do not assume one form is universal across Yoruba dialects.
|
|
117
|
+
4. **Hausa and Igbo:** The included algorithms are practical standard profiles. Regional variation and alternative counting conventions require separate tested lexicons.
|
|
118
|
+
5. **Nigerian Pidgin:** Orthography is not uniformly standardized. The package uses one consistent practical spelling profile; projects should publish their chosen style guide.
|
|
119
|
+
6. **Dates and times:** Automatic date/time expansion is deliberately not enabled in this first baseline because numeric date order is ambiguous (DD/MM vs MM/DD), and speech readings depend on context.
|
|
120
|
+
7. **Phone numbers:** Recognizes common Nigerian 11-digit local numbers and +234 formats. It does not validate allocation, carrier or whether a number is active.
|
|
121
|
+
8. **Currencies:** Currency symbol expansion is supported; currency codes and complex mixed-currency accounting expressions need explicit project rules.
|
|
122
|
+
9. **Units:** Common metric and telecom units are supported. Unit case is normalized for matching; abbreviations embedded in words are not intentionally expanded.
|
|
123
|
+
|
|
124
|
+
Do not use the default pipeline blindly on OTPs, bank account numbers, NINs, BVNs, transaction references, postal codes, coordinates, URLs or product identifiers. These need explicit token classes and project policies.
|
|
125
|
+
|
|
126
|
+
## Contributing linguistic data
|
|
127
|
+
|
|
128
|
+
Every language rule should have:
|
|
129
|
+
- a language and dialect/standard label;
|
|
130
|
+
- a source or native-speaker reviewer;
|
|
131
|
+
- examples with expected output;
|
|
132
|
+
- Unicode NFC tests;
|
|
133
|
+
- tests for numbers around every scale boundary;
|
|
134
|
+
- tests for code-switching and preservation.
|
|
135
|
+
|
|
136
|
+
Please submit corrections with audio or a clear orthographic convention where possible.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "naijanorm"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Nigerian-context text normalization for speech and language AI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "NaijaNorm contributors" }]
|
|
13
|
+
keywords = ["Nigerian languages", "text normalization", "ASR", "TTS", "Yoruba", "Hausa", "Igbo", "Pidgin"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Text Processing :: Linguistic",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
excel = ["pandas>=2.0", "openpyxl>=3.1"]
|
|
29
|
+
dev = ["pytest>=8.0", "build>=1.2", "ruff>=0.5"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/YOUR-ORG/naijanorm"
|
|
33
|
+
Issues = "https://github.com/YOUR-ORG/naijanorm/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/naijanorm"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
addopts = "-q"
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py39"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""NaijaNorm: Nigerian-context text normalization."""
|
|
2
|
+
from .normalizer import NaijaNorm, normalize, normalize_batch
|
|
3
|
+
from .config import NormalizerConfig
|
|
4
|
+
from .result import NormalizationResult, NormalizationEvent
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"NaijaNorm", "normalize", "normalize_batch", "NormalizerConfig",
|
|
9
|
+
"NormalizationResult", "NormalizationEvent", "__version__"
|
|
10
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import FrozenSet, Optional
|
|
3
|
+
|
|
4
|
+
SUPPORTED_LANGUAGES = ("en-NG", "pcm-NG", "yo-NG", "ha-NG", "ig-NG")
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class NormalizerConfig:
|
|
8
|
+
language: str = "en-NG"
|
|
9
|
+
yoruba_system: str = "oyo-traditional" # or "modern-decimal"
|
|
10
|
+
igbo_variant: str = "standard" # regional variants require separate lexicons
|
|
11
|
+
hausa_variant: str = "standard-boko"
|
|
12
|
+
categories: Optional[FrozenSet[str]] = None
|
|
13
|
+
preserve_unknown: bool = True
|
|
14
|
+
expand_units: bool = True
|
|
15
|
+
expand_currency: bool = True
|
|
16
|
+
expand_telephone: bool = True
|
|
17
|
+
decimal_style: str = "point"
|
|
18
|
+
include_events: bool = False
|
|
19
|
+
max_integer: int = 999_999_999_999
|
|
20
|
+
|
|
21
|
+
def __post_init__(self):
|
|
22
|
+
if self.language not in SUPPORTED_LANGUAGES:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"Unsupported language {self.language!r}. Choose from {SUPPORTED_LANGUAGES}."
|
|
25
|
+
)
|
|
26
|
+
if self.yoruba_system not in {"oyo-traditional", "modern-decimal"}:
|
|
27
|
+
raise ValueError("yoruba_system must be 'oyo-traditional' or 'modern-decimal'")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Iterable, Optional, List
|
|
3
|
+
from .config import NormalizerConfig
|
|
4
|
+
from .numbers import spell_integer
|
|
5
|
+
from .result import NormalizationEvent, NormalizationResult
|
|
6
|
+
|
|
7
|
+
UNIT_MAP = {
|
|
8
|
+
"kb": ("kilobyte", "kilobytes"), "mb": ("megabyte", "megabytes"),
|
|
9
|
+
"gb": ("gigabyte", "gigabytes"), "tb": ("terabyte", "terabytes"),
|
|
10
|
+
"kbps": ("kilobit per second", "kilobits per second"),
|
|
11
|
+
"mbps": ("megabit per second", "megabits per second"),
|
|
12
|
+
"gbps": ("gigabit per second", "gigabits per second"),
|
|
13
|
+
"kg": ("kilogram", "kilograms"), "g": ("gram", "grams"),
|
|
14
|
+
"km": ("kilometre", "kilometres"), "cm": ("centimetre", "centimetres"),
|
|
15
|
+
"mm": ("millimetre", "millimetres"), "m": ("metre", "metres"),
|
|
16
|
+
"l": ("litre", "litres"), "ml": ("millilitre", "millilitres"),
|
|
17
|
+
"°c": ("degree Celsius", "degrees Celsius"),
|
|
18
|
+
"hrs": ("hour", "hours"), "hr": ("hour", "hours"),
|
|
19
|
+
"mins": ("minute", "minutes"), "min": ("minute", "minutes"),
|
|
20
|
+
}
|
|
21
|
+
CURRENCY = {"₦": ("naira", "naira"), "$": ("US dollar", "US dollars"),
|
|
22
|
+
"£": ("pound sterling", "pounds sterling"), "€": ("euro", "euros")}
|
|
23
|
+
DIGIT_WORDS = {
|
|
24
|
+
"en-NG": ["zero","one","two","three","four","five","six","seven","eight","nine"],
|
|
25
|
+
"pcm-NG": ["zero","wan","tu","tri","fo","faiv","siks","sevn","et","nain"],
|
|
26
|
+
"yo-NG": ["odo","ọ̀kan","èjì","ẹ̀ta","ẹ̀rin","àrún-ún","ẹ̀fà","èje","ẹ̀jọ","ẹ̀sàn-án"],
|
|
27
|
+
"ha-NG": ["sifili","ɗaya","biyu","uku","huɗu","biyar","shida","bakwai","takwas","tara"],
|
|
28
|
+
"ig-NG": ["efu","otu","abụọ","atọ","anọ","ise","isii","asaa","asatọ","itoolu"],
|
|
29
|
+
}
|
|
30
|
+
SHORTCODE_WORDS = {"*":"star", "#":"hash", "+":"plus"}
|
|
31
|
+
|
|
32
|
+
class NaijaNorm:
|
|
33
|
+
"""Context-aware Nigerian text normalizer.
|
|
34
|
+
|
|
35
|
+
This is a deterministic rule-based baseline. Language profiles are explicit;
|
|
36
|
+
dialect-sensitive forms should be reviewed by native speakers for production.
|
|
37
|
+
"""
|
|
38
|
+
def __init__(self, language: str = "en-NG", **kwargs):
|
|
39
|
+
self.config = NormalizerConfig(language=language, **kwargs)
|
|
40
|
+
|
|
41
|
+
def _num(self, token: str) -> str:
|
|
42
|
+
clean = token.replace(",", "").replace("_", "")
|
|
43
|
+
if "." in clean:
|
|
44
|
+
left, right = clean.split(".", 1)
|
|
45
|
+
return spell_integer(int(left), self.config.language, self.config.yoruba_system) + \
|
|
46
|
+
" point " + " ".join(DIGIT_WORDS[self.config.language][int(x)] for x in right if x.isdigit())
|
|
47
|
+
return spell_integer(int(clean), self.config.language, self.config.yoruba_system)
|
|
48
|
+
|
|
49
|
+
def normalize_with_events(self, text: str) -> NormalizationResult:
|
|
50
|
+
if not isinstance(text, str):
|
|
51
|
+
raise TypeError("text must be a string")
|
|
52
|
+
events: List[NormalizationEvent] = []
|
|
53
|
+
lang = self.config.language
|
|
54
|
+
|
|
55
|
+
def replace(pattern, fn, category):
|
|
56
|
+
nonlocal text
|
|
57
|
+
if self.config.categories is not None and category not in self.config.categories:
|
|
58
|
+
return
|
|
59
|
+
def sub(m):
|
|
60
|
+
original = m.group(0)
|
|
61
|
+
normalized = fn(m)
|
|
62
|
+
events.append(NormalizationEvent(original, normalized, category, lang))
|
|
63
|
+
return normalized
|
|
64
|
+
text = re.sub(pattern, sub, text, flags=re.IGNORECASE)
|
|
65
|
+
|
|
66
|
+
# Telephone numbers: preserve every digit; recognize common Nigerian formats.
|
|
67
|
+
if self.config.expand_telephone:
|
|
68
|
+
def phone(m):
|
|
69
|
+
raw = re.sub(r"\D", "", m.group(0))
|
|
70
|
+
if raw.startswith("234") and len(raw) == 13:
|
|
71
|
+
digits = "0" + raw[3:]
|
|
72
|
+
else:
|
|
73
|
+
digits = raw
|
|
74
|
+
return " ".join(DIGIT_WORDS[lang][int(d)] for d in digits)
|
|
75
|
+
replace(r"(?<!\w)(?:\+?234[\s-]?\d{3}[\s-]?\d{3}[\s-]?\d{4}|0\d{10})(?!\w)", phone, "telephone")
|
|
76
|
+
|
|
77
|
+
# USSD / short codes. Keep symbol semantics explicit.
|
|
78
|
+
replace(r"(?<!\w)\*\d{2,6}#", lambda m: " ".join(
|
|
79
|
+
[SHORTCODE_WORDS["*"]] +
|
|
80
|
+
[DIGIT_WORDS[lang][int(d)] for d in m.group(0)[1:-1]] +
|
|
81
|
+
[SHORTCODE_WORDS["#"]]
|
|
82
|
+
), "shortcode")
|
|
83
|
+
|
|
84
|
+
# Currency values, including decimals and grouped thousands.
|
|
85
|
+
if self.config.expand_currency:
|
|
86
|
+
replace(r"[₦$£€]\s?-?\d[\d,]*(?:\.\d+)?",
|
|
87
|
+
lambda m: self._num(re.search(r"-?\d[\d,]*(?:\.\d+)?", m.group(0)).group(0)) +
|
|
88
|
+
" " + CURRENCY[m.group(0)[0]][1], "currency")
|
|
89
|
+
|
|
90
|
+
# Percentages.
|
|
91
|
+
replace(r"(?<!\w)-?\d[\d,]*(?:\.\d+)?\s*%",
|
|
92
|
+
lambda m: self._num(re.sub(r"\s*%","",m.group(0))) + " percent", "percentage")
|
|
93
|
+
|
|
94
|
+
# Ordinals (English suffix notation). Non-English ordinal morphology is not
|
|
95
|
+
# guessed; use cardinal fallback with a review event for now.
|
|
96
|
+
def ordinal(m):
|
|
97
|
+
n = int(m.group(1))
|
|
98
|
+
if lang == "en-NG":
|
|
99
|
+
if 10 <= n % 100 <= 20: suffix = "th"
|
|
100
|
+
else: suffix = {1:"st",2:"nd",3:"rd"}.get(n % 10,"th")
|
|
101
|
+
words = self._num(str(n))
|
|
102
|
+
if n in (1, 2, 3):
|
|
103
|
+
return {1: "first", 2: "second", 3: "third"}[n]
|
|
104
|
+
if n % 100 in (11, 12, 13):
|
|
105
|
+
return words + "-th"
|
|
106
|
+
last = n % 10
|
|
107
|
+
irregular = {5: "fifth", 8: "eighth", 9: "ninth", 12: "twelfth"}
|
|
108
|
+
if last in irregular and n < 100:
|
|
109
|
+
prefix = words.rsplit("-", 1)[0] if "-" in words else ""
|
|
110
|
+
return (prefix + "-" if prefix else "") + irregular[last]
|
|
111
|
+
if last == 1:
|
|
112
|
+
return words[:-3] + "first" if words.endswith("one") else words + "-first"
|
|
113
|
+
if last == 2:
|
|
114
|
+
return words[:-3] + "second" if words.endswith("two") else words + "-second"
|
|
115
|
+
if last == 3:
|
|
116
|
+
return words[:-5] + "third" if words.endswith("three") else words + "-third"
|
|
117
|
+
return words + "-th"
|
|
118
|
+
return self._num(str(n)) + " (ordinal form requires language-specific review)"
|
|
119
|
+
replace(r"\b(\d+)(st|nd|rd|th)\b", ordinal, "ordinal")
|
|
120
|
+
|
|
121
|
+
# Measurement and data units. Avoid matching substrings in words.
|
|
122
|
+
if self.config.expand_units:
|
|
123
|
+
unit_pattern = r"(?<!\w)(-?\d[\d,]*(?:\.\d+)?)\s*(KBPS|MBPS|GBPS|KB|MB|GB|TB|KG|G|KM|CM|MM|ML|L|M|°C|HRS|HR|MINS|MIN)\b"
|
|
124
|
+
def unit(m):
|
|
125
|
+
key=m.group(2).lower()
|
|
126
|
+
singular, plural=UNIT_MAP[key]
|
|
127
|
+
amount=m.group(1)
|
|
128
|
+
return self._num(amount) + " " + (singular if amount.replace(",","") in ("1","1.0") else plural)
|
|
129
|
+
replace(unit_pattern, unit, "measurement")
|
|
130
|
+
|
|
131
|
+
# Plain decimals before plain integers.
|
|
132
|
+
replace(r"(?<![\w.])-?\d[\d,]*\.\d+(?!\w)", lambda m: self._num(m.group(0)), "decimal")
|
|
133
|
+
|
|
134
|
+
# Remaining cardinal integers. Protect years/IDs only when embedded in letters;
|
|
135
|
+
# bare numbers are treated as cardinals.
|
|
136
|
+
replace(r"(?<![\w])\d{1,12}(?![\w])", lambda m: self._num(m.group(0)), "cardinal")
|
|
137
|
+
|
|
138
|
+
return NormalizationResult(text=text, events=events)
|
|
139
|
+
|
|
140
|
+
def normalize(self, text: str, categories: Optional[Iterable[str]] = None) -> str:
|
|
141
|
+
if categories is None:
|
|
142
|
+
return self.normalize_with_events(text).text
|
|
143
|
+
options = self.config.__dict__.copy()
|
|
144
|
+
options.pop("language", None)
|
|
145
|
+
options["categories"] = frozenset(categories)
|
|
146
|
+
clone = NaijaNorm(self.config.language, **options)
|
|
147
|
+
return clone.normalize_with_events(text).text
|
|
148
|
+
|
|
149
|
+
def normalize_batch(self, texts: Iterable[str]) -> List[str]:
|
|
150
|
+
return [self.normalize(t) for t in texts]
|
|
151
|
+
|
|
152
|
+
def normalize(text: str, language="en-NG", **kwargs) -> str:
|
|
153
|
+
return NaijaNorm(language, **kwargs).normalize(text)
|
|
154
|
+
|
|
155
|
+
def normalize_batch(texts: Iterable[str], language="en-NG", **kwargs) -> List[str]:
|
|
156
|
+
return NaijaNorm(language, **kwargs).normalize_batch(texts)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Language-specific cardinal number spell-out.
|
|
2
|
+
|
|
3
|
+
Important: language numerals have dialectal and register variation. The lexicons
|
|
4
|
+
here are an initial documented standard profile, not a substitute for validation
|
|
5
|
+
by native-speaking linguists before safety-critical or commercial corpus release.
|
|
6
|
+
"""
|
|
7
|
+
from typing import Dict
|
|
8
|
+
|
|
9
|
+
EN_ONES = ["zero","one","two","three","four","five","six","seven","eight","nine"]
|
|
10
|
+
EN_TEENS = ["ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]
|
|
11
|
+
EN_TENS = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"]
|
|
12
|
+
SCALES_EN = [(10**9,"billion"),(10**6,"million"),(1000,"thousand"),(100,"hundred")]
|
|
13
|
+
|
|
14
|
+
def en(n: int) -> str:
|
|
15
|
+
if n < 0: return "minus " + en(-n)
|
|
16
|
+
if n < 10: return EN_ONES[n]
|
|
17
|
+
if n < 20: return EN_TEENS[n-10]
|
|
18
|
+
if n < 100:
|
|
19
|
+
return EN_TENS[n//10] + (("-" + en(n%10)) if n%10 else "")
|
|
20
|
+
if n < 1000:
|
|
21
|
+
return en(n//100) + " hundred" + (" and " + en(n%100) if n%100 else "")
|
|
22
|
+
for scale, name in SCALES_EN:
|
|
23
|
+
if n >= scale:
|
|
24
|
+
q, r = divmod(n, scale)
|
|
25
|
+
join = " and " if scale == 100 and r else (" and " if r < 100 and r else ", ")
|
|
26
|
+
return en(q) + " " + name + ("" if q == 1 and name == "million" else "s" if q != 1 and name in ("million","billion") else "") + (join + en(r) if r else "")
|
|
27
|
+
return str(n)
|
|
28
|
+
|
|
29
|
+
YO_ONES = {
|
|
30
|
+
0:"òdo", 1:"ọ̀kan", 2:"èjì", 3:"ẹ̀ta", 4:"ẹ̀rin", 5:"àrún-ún",
|
|
31
|
+
6:"ẹ̀fà", 7:"èje", 8:"ẹ̀jọ", 9:"ẹ̀sàn-án", 10:"ẹ̀wá"
|
|
32
|
+
}
|
|
33
|
+
YO_TEENS = {
|
|
34
|
+
11:"mọ́kànlá",12:"méjìlá",13:"mẹ́tàlá",14:"mẹ́rìnlá",
|
|
35
|
+
15:"mẹ́ẹ̀dógún",16:"mẹ́rìndínlógún",17:"mẹ́tàdínlógún",
|
|
36
|
+
18:"méjìdínlógún",19:"mọ́kàndínlógún"
|
|
37
|
+
}
|
|
38
|
+
YO_ROUND = {
|
|
39
|
+
20:"ogún",30:"ọgbọ̀n",40:"ogójì",50:"àádọ́ta",60:"ọgọ́ta",
|
|
40
|
+
70:"àádọ́rin",80:"ọgọ́rin",90:"àádọ́rùn-ún",100:"ọgọ́rùn-ún",
|
|
41
|
+
200:"igba",300:"ọ̀dúnrún",400:"irínwó",1000:"ẹgbẹ̀rún"
|
|
42
|
+
}
|
|
43
|
+
YO_MODERN_ONES = ["òdo","ọ̀kan","méjì","mẹ́ta","mẹ́rin","márùn-ún","mẹ́fà","méje","mẹ́jọ","mẹ́sàn-án"]
|
|
44
|
+
YO_MODERN_TENS = {10:"mẹ́wàá",20:"ogún",30:"ọgbọ̀n",40:"ogójì",50:"àádọ́ta",60:"ọgọ́ta",70:"àádọ́rin",80:"ọgọ́rin",90:"àádọ́rùn-ún"}
|
|
45
|
+
|
|
46
|
+
def yo(n: int, system="oyo-traditional") -> str:
|
|
47
|
+
if n < 0: return "òdì " + yo(-n, system)
|
|
48
|
+
if system == "modern-decimal":
|
|
49
|
+
if n < 10: return YO_MODERN_ONES[n]
|
|
50
|
+
if n in YO_MODERN_TENS: return YO_MODERN_TENS[n]
|
|
51
|
+
if n < 100:
|
|
52
|
+
t=(n//10)*10; r=n%10
|
|
53
|
+
return YO_MODERN_TENS[t] + (" ó lé " + YO_MODERN_ONES[r] if r else "")
|
|
54
|
+
# Modern profile composes large quantities using standard Yoruba scale words.
|
|
55
|
+
if n < 1000:
|
|
56
|
+
q,r=divmod(n,100)
|
|
57
|
+
return yo(q,system) + " ọgọ́rùn-ún" + (" àti " + yo(r,system) if r else "")
|
|
58
|
+
q,r=divmod(n,1000)
|
|
59
|
+
return yo(q,system) + " ẹgbẹ̀rún" + (" àti " + yo(r,system) if r else "")
|
|
60
|
+
if n in YO_ONES: return YO_ONES[n]
|
|
61
|
+
if n in YO_TEENS: return YO_TEENS[n]
|
|
62
|
+
if n in YO_ROUND: return YO_ROUND[n]
|
|
63
|
+
if 21 <= n <= 24:
|
|
64
|
+
return YO_ROUND[20] + " lé " + YO_ONES[n-20]
|
|
65
|
+
if 25 <= n <= 29:
|
|
66
|
+
return YO_ONES[30-n] + " dín lọ́gbọ̀n"
|
|
67
|
+
if 31 <= n <= 34:
|
|
68
|
+
return YO_ROUND[30] + " lé " + YO_ONES[n-30]
|
|
69
|
+
if 35 <= n <= 39:
|
|
70
|
+
return YO_ONES[40-n] + " dín ogójì"
|
|
71
|
+
if 41 <= n <= 44:
|
|
72
|
+
return YO_ROUND[40] + " lé " + YO_ONES[n-40]
|
|
73
|
+
if 45 <= n <= 49:
|
|
74
|
+
return YO_ONES[50-n] + " dín àádọ́ta"
|
|
75
|
+
if 51 <= n <= 54:
|
|
76
|
+
return YO_ROUND[50] + " lé " + YO_ONES[n-50]
|
|
77
|
+
if 55 <= n <= 59:
|
|
78
|
+
return YO_ONES[60-n] + " dín ọgọ́ta"
|
|
79
|
+
if 61 <= n <= 64:
|
|
80
|
+
return YO_ROUND[60] + " lé " + YO_ONES[n-60]
|
|
81
|
+
if 65 <= n <= 69:
|
|
82
|
+
return YO_ONES[70-n] + " dín àádọ́rin"
|
|
83
|
+
if 71 <= n <= 74:
|
|
84
|
+
return YO_ROUND[70] + " lé " + YO_ONES[n-70]
|
|
85
|
+
if 75 <= n <= 79:
|
|
86
|
+
return YO_ONES[80-n] + " dín ọgọ́rin"
|
|
87
|
+
if 81 <= n <= 84:
|
|
88
|
+
return YO_ROUND[80] + " lé " + YO_ONES[n-80]
|
|
89
|
+
if 85 <= n <= 89:
|
|
90
|
+
return YO_ONES[90-n] + " dín àádọ́rùn-ún"
|
|
91
|
+
if 91 <= n <= 94:
|
|
92
|
+
return YO_ROUND[90] + " lé " + YO_ONES[n-90]
|
|
93
|
+
if 95 <= n <= 99:
|
|
94
|
+
return YO_ONES[100-n] + " dín ọgọ́rùn-ún"
|
|
95
|
+
if n < 1000:
|
|
96
|
+
q,r=divmod(n,100)
|
|
97
|
+
hundreds = YO_ROUND.get(q*100)
|
|
98
|
+
if hundreds is None: hundreds = yo(q,system) + " ọgọ́rùn-ún"
|
|
99
|
+
return hundreds + (" àti " + yo(r,system) if r else "")
|
|
100
|
+
if n < 1000000:
|
|
101
|
+
q,r=divmod(n,1000)
|
|
102
|
+
return yo(q,system) + " ẹgbẹ̀rún" + (" àti " + yo(r,system) if r else "")
|
|
103
|
+
raise ValueError("Yoruba number conversion currently supports values below 1,000,000")
|
|
104
|
+
|
|
105
|
+
HA_ONES = ["sifili","ɗaya","biyu","uku","huɗu","biyar","shida","bakwai","takwas","tara"]
|
|
106
|
+
HA_TENS = {10:"goma",20:"ashirin",30:"talatin",40:"arba'in",50:"hamsin",60:"sittin",70:"saba'in",80:"tamanin",90:"casa'in"}
|
|
107
|
+
def ha(n: int) -> str:
|
|
108
|
+
if n < 0: return "mara kyau " + ha(-n)
|
|
109
|
+
if n < 10: return HA_ONES[n]
|
|
110
|
+
if n < 20: return "goma sha " + HA_ONES[n-10]
|
|
111
|
+
if n in HA_TENS: return HA_TENS[n]
|
|
112
|
+
if n < 100:
|
|
113
|
+
t=n//10*10; r=n%10
|
|
114
|
+
# Hausa joins units with "da" in common compound forms.
|
|
115
|
+
return HA_TENS[t] + (" da " + HA_ONES[r] if r else "")
|
|
116
|
+
if n < 200:
|
|
117
|
+
return "ɗari" + (" da " + ha(n-100) if n>100 else "")
|
|
118
|
+
if n < 1000:
|
|
119
|
+
q,r=divmod(n,100)
|
|
120
|
+
return ha(q) + " ɗari" + (" da " + ha(r) if r else "")
|
|
121
|
+
if n < 1_000_000:
|
|
122
|
+
q,r=divmod(n,1000)
|
|
123
|
+
return ha(q) + " dubu" + (" da " + ha(r) if r else "")
|
|
124
|
+
q,r=divmod(n,1_000_000)
|
|
125
|
+
return ha(q) + " miliyan" + (" da " + ha(r) if r else "")
|
|
126
|
+
|
|
127
|
+
IG_ONES = ["efu","otu","abụọ","atọ","anọ","ise","isii","asaa","asatọ","itoolu"]
|
|
128
|
+
IG_TEENS = {10:"iri",11:"iri na otu",12:"iri na abụọ",13:"iri na atọ",14:"iri na anọ",15:"iri na ise",16:"iri na isii",17:"iri na asaa",18:"iri na asatọ",19:"iri na itoolu"}
|
|
129
|
+
IG_TENS = {20:"iri abụọ",30:"iri atọ",40:"iri anọ",50:"iri ise",60:"iri isii",70:"iri asaa",80:"iri asatọ",90:"iri itoolu"}
|
|
130
|
+
def ig(n: int) -> str:
|
|
131
|
+
if n < 0: return "mwepu " + ig(-n)
|
|
132
|
+
if n < 10: return IG_ONES[n]
|
|
133
|
+
if n < 20: return IG_TEENS[n]
|
|
134
|
+
if n < 100:
|
|
135
|
+
t=n//10*10; r=n%10
|
|
136
|
+
return IG_TENS[t] + (" na " + IG_ONES[r] if r else "")
|
|
137
|
+
if n < 1000:
|
|
138
|
+
q,r=divmod(n,100)
|
|
139
|
+
return IG_ONES[q] + " narị" + (" na " + ig(r) if r else "")
|
|
140
|
+
if n < 1_000_000:
|
|
141
|
+
q,r=divmod(n,1000)
|
|
142
|
+
return ig(q) + " puku" + (" na " + ig(r) if r else "")
|
|
143
|
+
q,r=divmod(n,1_000_000)
|
|
144
|
+
return ig(q) + " nde" + (" na " + ig(r) if r else "")
|
|
145
|
+
|
|
146
|
+
PCM_ONES = ["zero","wan","tu","tri","fo","faiv","siks","sevn","et","nain"]
|
|
147
|
+
PCM_TEENS = ["ten","ilevn","twelv","tetin","fotin","fiftin","sikstin","seventin","eitin","naintin"]
|
|
148
|
+
PCM_TENS = ["","","twenti","teti","foti","fifti","siksti","seventi","eiti","nainti"]
|
|
149
|
+
def pcm(n: int) -> str:
|
|
150
|
+
if n < 0: return "minus " + pcm(-n)
|
|
151
|
+
if n < 10: return PCM_ONES[n]
|
|
152
|
+
if n < 20: return PCM_TEENS[n-10]
|
|
153
|
+
if n < 100:
|
|
154
|
+
return PCM_TENS[n//10] + (" " + pcm(n%10) if n%10 else "")
|
|
155
|
+
if n < 1000:
|
|
156
|
+
q,r=divmod(n,100)
|
|
157
|
+
return pcm(q) + " hundred" + (" " + pcm(r) if r else "")
|
|
158
|
+
if n < 1_000_000:
|
|
159
|
+
q,r=divmod(n,1000)
|
|
160
|
+
return pcm(q) + " thousand" + (" " + pcm(r) if r else "")
|
|
161
|
+
q,r=divmod(n,1_000_000)
|
|
162
|
+
return pcm(q) + " million" + (" " + pcm(r) if r else "")
|
|
163
|
+
|
|
164
|
+
def spell_integer(n: int, language: str, yoruba_system="oyo-traditional") -> str:
|
|
165
|
+
if language == "en-NG": return en(n)
|
|
166
|
+
if language == "pcm-NG": return pcm(n)
|
|
167
|
+
if language == "yo-NG": return yo(n, yoruba_system)
|
|
168
|
+
if language == "ha-NG": return ha(n)
|
|
169
|
+
if language == "ig-NG": return ig(n)
|
|
170
|
+
raise ValueError(f"Unsupported language: {language}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class NormalizationEvent:
|
|
6
|
+
original: str
|
|
7
|
+
normalized: str
|
|
8
|
+
category: str
|
|
9
|
+
language: str
|
|
10
|
+
confidence: str = "rule-based"
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class NormalizationResult:
|
|
14
|
+
text: str
|
|
15
|
+
events: List[NormalizationEvent] = field(default_factory=list)
|
|
16
|
+
|
|
17
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
18
|
+
return {
|
|
19
|
+
"text": self.text,
|
|
20
|
+
"events": [e.__dict__.copy() for e in self.events],
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from naijanorm.numbers import spell_integer
|
|
3
|
+
from naijanorm import NaijaNorm
|
|
4
|
+
|
|
5
|
+
@pytest.mark.parametrize("lang, expected", [
|
|
6
|
+
("en-NG", "two hundred and fifty"),
|
|
7
|
+
("pcm-NG", "tu hundred faiv"),
|
|
8
|
+
("yo-NG", "ẹ̀ta"),
|
|
9
|
+
("ha-NG", "ɗaya"),
|
|
10
|
+
("ig-NG", "abụọ"),
|
|
11
|
+
])
|
|
12
|
+
def test_language_profiles(lang, expected):
|
|
13
|
+
values = {"en-NG":250, "pcm-NG":205, "yo-NG":3, "ha-NG":1, "ig-NG":2}
|
|
14
|
+
assert spell_integer(values[lang], lang).startswith(expected)
|
|
15
|
+
|
|
16
|
+
def test_english_examples():
|
|
17
|
+
n = NaijaNorm("en-NG")
|
|
18
|
+
assert n.normalize("250") == "two hundred and fifty"
|
|
19
|
+
assert n.normalize("25th") == "twenty-fifth"
|
|
20
|
+
assert n.normalize("3.5") == "three point five"
|
|
21
|
+
|
|
22
|
+
def test_phone_digit_by_digit():
|
|
23
|
+
out = NaijaNorm("en-NG").normalize("08012345678")
|
|
24
|
+
assert out == "zero eight zero one two three four five six seven eight"
|
|
25
|
+
|
|
26
|
+
def test_currency():
|
|
27
|
+
assert NaijaNorm("en-NG").normalize("₦5,000") == "five thousand naira"
|
|
28
|
+
|
|
29
|
+
def test_data_unit():
|
|
30
|
+
assert NaijaNorm("en-NG").normalize("250MB") == "two hundred and fifty megabytes"
|
|
31
|
+
|
|
32
|
+
def test_shortcode():
|
|
33
|
+
assert NaijaNorm("en-NG").normalize("*310#") == "star three one zero hash"
|
|
34
|
+
|
|
35
|
+
def test_unicode_preservation():
|
|
36
|
+
text = "Ọ̀ṣun àti Ìbàdàn"
|
|
37
|
+
assert NaijaNorm("yo-NG").normalize(text) == text
|
|
38
|
+
|
|
39
|
+
def test_yoruba_traditional_boundaries():
|
|
40
|
+
from naijanorm.numbers import yo
|
|
41
|
+
assert yo(16) == "mẹ́rìndínlógún"
|
|
42
|
+
assert yo(21) == "ogún lé ọ̀kan"
|
|
43
|
+
assert yo(25) == "àrún-ún dín lọ́gbọ̀n"
|
|
44
|
+
assert yo(31) == "ọgbọ̀n lé ọ̀kan"
|
|
45
|
+
|
|
46
|
+
def test_category_filter():
|
|
47
|
+
n = NaijaNorm("en-NG")
|
|
48
|
+
assert n.normalize("₦5,000 and 250MB", categories=["currency"]) == "five thousand naira and 250MB"
|