hindi-normalize 0.1.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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python -c \"import build; print\\('build', build.__version__\\)\")",
5
+ "Bash(python -c \"import sys; print\\(sys.executable\\); print\\(sys.version\\)\")",
6
+ "Bash(python -m build --version)",
7
+ "Bash(python -m twine --version)",
8
+ "Bash(PYTHONPATH=src python -m unittest discover -s tests)",
9
+ "Bash(python -m pip install --quiet --upgrade build twine)",
10
+ "Bash(rm -rf dist/ build/ src/*.egg-info)",
11
+ "Bash(python -m build)"
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,28 @@
1
+ CLAUDE.md
2
+
3
+ # Python
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.egg-info/
7
+ .eggs/
8
+ build/
9
+ dist/
10
+ *.egg
11
+
12
+ # Virtual envs
13
+ .venv/
14
+ venv/
15
+ env/
16
+
17
+ # Test / coverage
18
+ .pytest_cache/
19
+ .coverage
20
+ htmlcov/
21
+
22
+ # Editor / OS
23
+ .vscode/
24
+ .idea/
25
+ *.swp
26
+ .DS_Store
27
+ Thumbs.db
28
+ .env
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.1.0] — unreleased
7
+
8
+ Initial release. Extracted and generalized from the Hindi normalization pass in
9
+ [Sakhi](https://github.com/Tushar-9802/Sakhi).
10
+
11
+ ### Added
12
+
13
+ - **numbers** — `convert_numbers`, `parse_hindi_number`, `WORD_TO_NUM`. Hindi
14
+ number words 0–999 to digits, tolerant of ASR spelling variants and merged
15
+ compounds (`एकसो` → `110`). Adjacent unrelated numbers are not summed.
16
+ - **devanagari** — `normalize_devanagari` plus `strip_zero_width`,
17
+ `devanagari_digits_to_ascii`, `ascii_digits_to_devanagari`, `normalize_danda`,
18
+ `fold_nasals`. Invisibles removal, NFC composition, Devanagari↔ASCII digits,
19
+ danda punctuation, and opt-in nukta removal / nasal folding / colon→visarga.
20
+ - **terms** — `replace_terms` with a pluggable dictionary; ships `MEDICAL_TERMS`
21
+ (ASHA maternal/child-health vocabulary).
22
+ - **repetition** — `collapse_repetition` for inline ASR stutter.
23
+ - **transcript** — `normalize_transcript`, the batteries-included pipeline.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tushar Jaju
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. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: hindi-normalize
3
+ Version: 0.1.0
4
+ Summary: Convert Hindi number words to digits and normalize Devanagari ASR/OCR text
5
+ Project-URL: Homepage, https://github.com/Tushar-9802/hindi-normalize
6
+ Project-URL: Issues, https://github.com/Tushar-9802/hindi-normalize/issues
7
+ Author-email: Tushar Jaju <tusharbrisingr9802@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Tushar Jaju
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. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: asr,devanagari,hindi,indic,nlp,normalization,number-words,speech-to-text,text-processing
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Natural Language :: Hindi
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.9
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Classifier: Topic :: Text Processing :: Linguistic
43
+ Requires-Python: >=3.9
44
+ Provides-Extra: dev
45
+ Requires-Dist: pytest>=7.0; extra == 'dev'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # hindi-normalize
49
+
50
+ Convert Hindi number words to digits and normalize Devanagari text from ASR, OCR, or copy-paste. Handles the encoding variants, punctuation quirks, and number-word spellings that break search, equality checks, tokenization, and downstream extraction. Zero runtime dependencies — pure standard library.
51
+
52
+ I built this because [Sakhi](https://github.com/Tushar-9802/Sakhi) — an offline medical Hindi voice-to-form tool meant for ASHAs— kept reinventing the same regex passes against Whisper output: number words that never became digits, `५` and `5` treated as different characters, `|` where a danda `।` belonged, the same word encoded two different ways. This is that pass, extracted and generalized.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install hindi-normalize
58
+ ```
59
+
60
+ ## Quick start
61
+
62
+ ```python
63
+ from hindi_normalize import normalize_transcript
64
+
65
+ normalize_transcript("आपका BP एक सौ दस बटा सत्तर है, वजन अट्ठावन kg")
66
+ # 'आपका BP 110/70 है, वजन 58 kg'
67
+ ```
68
+
69
+ `normalize_transcript` runs the whole pipeline. The individual transforms are also exported and usable on their own.
70
+
71
+ ## Numbers
72
+
73
+ Hindi number words → digits, tolerant of ASR spelling variants (`पाँच`/`पांच`/`पाच`, `सत्तर`/`सतर`). Adjacent unrelated numbers are not summed — `दो तीन दिन` ("two-three days") stays `2 3 दिन`, never `5`.
74
+
75
+ ```python
76
+ from hindi_normalize import convert_numbers, parse_hindi_number
77
+
78
+ convert_numbers("एक सौ दस बटा सत्तर") # '110 बटा 70'
79
+ parse_hindi_number("नौ सौ निन्यानवे") # 999
80
+ ```
81
+
82
+ ## Devanagari
83
+
84
+ `normalize_devanagari` folds the character-level variants OCR/ASR/copy-paste introduce, following the operations the [Indic NLP Library](https://github.com/anoopkunchukuttan/indic_nlp_library) established as standard.
85
+
86
+ ```python
87
+ from hindi_normalize import normalize_devanagari
88
+
89
+ normalize_devanagari("वजन ५८ किलो | ठीक है") # 'वजन 58 किलो। ठीक है'
90
+ ```
91
+
92
+ Default (lossless, on):
93
+
94
+ - **Invisibles** — strip ZWJ/ZWNJ/ZWSP, BOM, soft hyphen, word joiner; no-break space → space
95
+ - **NFC** — unify nukta encodings (`क़` as one codepoint vs `क` + nukta) and matra order
96
+ - **Digits** — Devanagari digits (`५`) → ASCII (`5`)
97
+ - **Danda** — `|` → `।`, `||` → `॥`, collapse runs, drop space before a danda
98
+
99
+ Opt-in (lossy, off):
100
+
101
+ - `remove_nukta` — `क़` → `क`, `ज़` → `ज`
102
+ - `nasals` — fold nasal clusters to anusvara (`हिन्दी` → `हिंदी`)
103
+ - `chandrabindu` — `ँ` → `ं`
104
+ - `visarga` — ASCII colon after a Devanagari letter → visarga (`दु:ख` → `दुःख`)
105
+
106
+ Unlike a naive `unicodedata`-category filter, nothing here strips the Unicode Mark category, so matras and the virama survive — the [failure mode](https://kavyamanohar.com/post/indic-normalizer/) that silently deletes vowel signs from Indic text.
107
+
108
+ ## Terms
109
+
110
+ Map spoken/mis-recognized terms to canonical forms. Ships a maternal/child-health (ASHA home-visit) dictionary; pass your own for any domain.
111
+
112
+ ```python
113
+ from hindi_normalize import replace_terms, MEDICAL_TERMS
114
+
115
+ replace_terms("बीबी एक सौ दस बटा सत्तर", MEDICAL_TERMS) # 'BP एक सौ दस / सत्तर'
116
+ replace_terms("क ख", {"क": "K", "ख": "KH"}) # 'K KH'
117
+ ```
118
+
119
+ ## Repetition
120
+
121
+ Collapse the back-to-back stutter recognisers emit.
122
+
123
+ ```python
124
+ from hindi_normalize import collapse_repetition
125
+
126
+ collapse_repetition("ठीकठीकठीकठीक है") # 'ठीक है'
127
+ ```
128
+
129
+ For runaway *tail* loops in long LLM generations (repeated sentences/paragraphs) and for general invisible/typographic cleanup of non-Devanagari text, compose with [llmclean](https://github.com/Tushar-9802/llmclean).
130
+
131
+ ## API
132
+
133
+ | Function | Purpose |
134
+ |---|---|
135
+ | `normalize_transcript(text, *, terms, numbers, devanagari, repetition, sentence_breaks)` | Full pipeline |
136
+ | `convert_numbers(text)` | Hindi number words → digits |
137
+ | `parse_hindi_number(text)` | Parse one number expression → `int` |
138
+ | `normalize_devanagari(text, *, ...)` | Character-level normalization |
139
+ | `strip_zero_width(text)` | Remove invisibles; NBSP → space |
140
+ | `devanagari_digits_to_ascii(text)` / `ascii_digits_to_devanagari(text)` | Digit conversion |
141
+ | `normalize_danda(text)` | Danda punctuation |
142
+ | `fold_nasals(text, *, chandrabindu)` | Nasal clusters → anusvara |
143
+ | `replace_terms(text, terms)` | Term dictionary replacement |
144
+ | `collapse_repetition(text, *, min_repeats)` | Collapse inline stutter |
145
+
146
+ Every function is guarded against non-string input and returns the input unchanged rather than raising.
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,103 @@
1
+ # hindi-normalize
2
+
3
+ Convert Hindi number words to digits and normalize Devanagari text from ASR, OCR, or copy-paste. Handles the encoding variants, punctuation quirks, and number-word spellings that break search, equality checks, tokenization, and downstream extraction. Zero runtime dependencies — pure standard library.
4
+
5
+ I built this because [Sakhi](https://github.com/Tushar-9802/Sakhi) — an offline medical Hindi voice-to-form tool meant for ASHAs— kept reinventing the same regex passes against Whisper output: number words that never became digits, `५` and `5` treated as different characters, `|` where a danda `।` belonged, the same word encoded two different ways. This is that pass, extracted and generalized.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install hindi-normalize
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from hindi_normalize import normalize_transcript
17
+
18
+ normalize_transcript("आपका BP एक सौ दस बटा सत्तर है, वजन अट्ठावन kg")
19
+ # 'आपका BP 110/70 है, वजन 58 kg'
20
+ ```
21
+
22
+ `normalize_transcript` runs the whole pipeline. The individual transforms are also exported and usable on their own.
23
+
24
+ ## Numbers
25
+
26
+ Hindi number words → digits, tolerant of ASR spelling variants (`पाँच`/`पांच`/`पाच`, `सत्तर`/`सतर`). Adjacent unrelated numbers are not summed — `दो तीन दिन` ("two-three days") stays `2 3 दिन`, never `5`.
27
+
28
+ ```python
29
+ from hindi_normalize import convert_numbers, parse_hindi_number
30
+
31
+ convert_numbers("एक सौ दस बटा सत्तर") # '110 बटा 70'
32
+ parse_hindi_number("नौ सौ निन्यानवे") # 999
33
+ ```
34
+
35
+ ## Devanagari
36
+
37
+ `normalize_devanagari` folds the character-level variants OCR/ASR/copy-paste introduce, following the operations the [Indic NLP Library](https://github.com/anoopkunchukuttan/indic_nlp_library) established as standard.
38
+
39
+ ```python
40
+ from hindi_normalize import normalize_devanagari
41
+
42
+ normalize_devanagari("वजन ५८ किलो | ठीक है") # 'वजन 58 किलो। ठीक है'
43
+ ```
44
+
45
+ Default (lossless, on):
46
+
47
+ - **Invisibles** — strip ZWJ/ZWNJ/ZWSP, BOM, soft hyphen, word joiner; no-break space → space
48
+ - **NFC** — unify nukta encodings (`क़` as one codepoint vs `क` + nukta) and matra order
49
+ - **Digits** — Devanagari digits (`५`) → ASCII (`5`)
50
+ - **Danda** — `|` → `।`, `||` → `॥`, collapse runs, drop space before a danda
51
+
52
+ Opt-in (lossy, off):
53
+
54
+ - `remove_nukta` — `क़` → `क`, `ज़` → `ज`
55
+ - `nasals` — fold nasal clusters to anusvara (`हिन्दी` → `हिंदी`)
56
+ - `chandrabindu` — `ँ` → `ं`
57
+ - `visarga` — ASCII colon after a Devanagari letter → visarga (`दु:ख` → `दुःख`)
58
+
59
+ Unlike a naive `unicodedata`-category filter, nothing here strips the Unicode Mark category, so matras and the virama survive — the [failure mode](https://kavyamanohar.com/post/indic-normalizer/) that silently deletes vowel signs from Indic text.
60
+
61
+ ## Terms
62
+
63
+ Map spoken/mis-recognized terms to canonical forms. Ships a maternal/child-health (ASHA home-visit) dictionary; pass your own for any domain.
64
+
65
+ ```python
66
+ from hindi_normalize import replace_terms, MEDICAL_TERMS
67
+
68
+ replace_terms("बीबी एक सौ दस बटा सत्तर", MEDICAL_TERMS) # 'BP एक सौ दस / सत्तर'
69
+ replace_terms("क ख", {"क": "K", "ख": "KH"}) # 'K KH'
70
+ ```
71
+
72
+ ## Repetition
73
+
74
+ Collapse the back-to-back stutter recognisers emit.
75
+
76
+ ```python
77
+ from hindi_normalize import collapse_repetition
78
+
79
+ collapse_repetition("ठीकठीकठीकठीक है") # 'ठीक है'
80
+ ```
81
+
82
+ For runaway *tail* loops in long LLM generations (repeated sentences/paragraphs) and for general invisible/typographic cleanup of non-Devanagari text, compose with [llmclean](https://github.com/Tushar-9802/llmclean).
83
+
84
+ ## API
85
+
86
+ | Function | Purpose |
87
+ |---|---|
88
+ | `normalize_transcript(text, *, terms, numbers, devanagari, repetition, sentence_breaks)` | Full pipeline |
89
+ | `convert_numbers(text)` | Hindi number words → digits |
90
+ | `parse_hindi_number(text)` | Parse one number expression → `int` |
91
+ | `normalize_devanagari(text, *, ...)` | Character-level normalization |
92
+ | `strip_zero_width(text)` | Remove invisibles; NBSP → space |
93
+ | `devanagari_digits_to_ascii(text)` / `ascii_digits_to_devanagari(text)` | Digit conversion |
94
+ | `normalize_danda(text)` | Danda punctuation |
95
+ | `fold_nasals(text, *, chandrabindu)` | Nasal clusters → anusvara |
96
+ | `replace_terms(text, terms)` | Term dictionary replacement |
97
+ | `collapse_repetition(text, *, min_repeats)` | Collapse inline stutter |
98
+
99
+ Every function is guarded against non-string input and returns the input unchanged rather than raising.
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hindi-normalize"
7
+ version = "0.1.0"
8
+ description = "Convert Hindi number words to digits and normalize Devanagari ASR/OCR text"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Tushar Jaju", email = "tusharbrisingr9802@gmail.com" }]
13
+ keywords = [
14
+ "hindi",
15
+ "devanagari",
16
+ "nlp",
17
+ "normalization",
18
+ "asr",
19
+ "speech-to-text",
20
+ "indic",
21
+ "text-processing",
22
+ "number-words",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.9",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Natural Language :: Hindi",
35
+ "Topic :: Software Development :: Libraries :: Python Modules",
36
+ "Topic :: Text Processing :: Linguistic",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/Tushar-9802/hindi-normalize"
41
+ Issues = "https://github.com/Tushar-9802/hindi-normalize/issues"
42
+
43
+ [project.optional-dependencies]
44
+ dev = ["pytest>=7.0"]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/hindi_normalize"]
@@ -0,0 +1,56 @@
1
+ """
2
+ hindi_normalize — convert Hindi number words to digits and normalize Devanagari
3
+ ASR/OCR text. Zero dependencies beyond the Python standard library.
4
+
5
+ Quick start::
6
+
7
+ from hindi_normalize import normalize_transcript, convert_numbers
8
+ from hindi_normalize import normalize_devanagari, replace_terms
9
+
10
+ normalize_transcript("आपका BP एक सौ दस बटा सत्तर है") # 'आपका BP 110/70 है'
11
+ convert_numbers("नौ सौ निन्यानवे") # '999'
12
+ normalize_devanagari("वजन ५८ किलो | ठीक है") # 'वजन 58 किलो। ठीक है'
13
+ replace_terms("बीबी ठीक है") # 'BP ठीक है'
14
+
15
+ Building blocks (all importable from the top level):
16
+
17
+ * numbers — parse_hindi_number, convert_numbers, WORD_TO_NUM
18
+ * devanagari — normalize_devanagari, strip_zero_width, digit conversion,
19
+ normalize_danda, fold_nasals
20
+ * terms — replace_terms, MEDICAL_TERMS
21
+ * repetition — collapse_repetition
22
+ * transcript — normalize_transcript (batteries-included pipeline)
23
+
24
+ For invisible/typographic cleanup of general (non-Devanagari) text, compose with
25
+ ``llmclean``.
26
+ """
27
+
28
+ from .numbers import WORD_TO_NUM, parse_hindi_number, convert_numbers
29
+ from .devanagari import (
30
+ strip_zero_width,
31
+ devanagari_digits_to_ascii,
32
+ ascii_digits_to_devanagari,
33
+ normalize_danda,
34
+ fold_nasals,
35
+ normalize_devanagari,
36
+ )
37
+ from .terms import MEDICAL_TERMS, replace_terms
38
+ from .repetition import collapse_repetition
39
+ from .transcript import normalize_transcript
40
+
41
+ __all__ = [
42
+ "WORD_TO_NUM",
43
+ "parse_hindi_number",
44
+ "convert_numbers",
45
+ "strip_zero_width",
46
+ "devanagari_digits_to_ascii",
47
+ "ascii_digits_to_devanagari",
48
+ "normalize_danda",
49
+ "fold_nasals",
50
+ "normalize_devanagari",
51
+ "MEDICAL_TERMS",
52
+ "replace_terms",
53
+ "collapse_repetition",
54
+ "normalize_transcript",
55
+ ]
56
+ __version__ = "0.1.0"
@@ -0,0 +1,244 @@
1
+ """devanagari.py — character-level normalization of Devanagari (Hindi) text.
2
+
3
+ Devanagari has several ways to encode the same visible text, and OCR/ASR/
4
+ copy-paste freely mix them. Left alone, these break equality checks, search,
5
+ regex, tokenization, and dedup — the string *looks* identical but isn't. This
6
+ module folds the common variants to a consistent form, following the operations
7
+ the Indic NLP Library's ``DevanagariNormalizer`` established as standard.
8
+
9
+ Handled:
10
+
11
+ * **Invisible / space characters.** Zero-width joiner/non-joiner, zero-width
12
+ space, BOM, soft hyphen, word joiner are removed; no-break space becomes a
13
+ regular space. On by default.
14
+
15
+ * **Encoding variants (NFC).** क़ can be one codepoint (U+0958) or क + nukta
16
+ (U+0915 U+093C), and matras can arrive in non-canonical order. ``compose``
17
+ applies Unicode NFC so both spellings become the same bytes. Lossless.
18
+
19
+ * **Devanagari digits.** ``५`` (U+096B) vs ``5``. ``digits`` maps Devanagari
20
+ digits to ASCII; :func:`ascii_digits_to_devanagari` does the reverse.
21
+
22
+ * **Danda punctuation.** ASR/OCR emit ASCII ``|`` where the danda ``।``
23
+ belongs, repeat it, or put a space before it. ``danda`` fixes all three.
24
+
25
+ * **Colon → visarga (opt-in).** ``दु:ख`` typed with an ASCII colon becomes
26
+ ``दुःख``. Off by default — a colon after a Devanagari word is sometimes real
27
+ punctuation.
28
+
29
+ * **Nukta removal / nasal folding (opt-in, lossy).** ``remove_nukta`` folds
30
+ क़→क, ज़→ज; ``nasals`` folds न्, म् … clusters to anusvara (हिन्दी→हिंदी);
31
+ ``chandrabindu`` folds ँ→ं. All off by default.
32
+
33
+ Unlike a naive ``unicodedata``-category filter, nothing here strips the Unicode
34
+ Mark category, so matras and the virama survive.
35
+
36
+ For general typographic punctuation (smart quotes, em dashes), compose this with
37
+ ``llmclean``'s ``normalize_typography`` rather than duplicating it.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import re
43
+ import unicodedata
44
+
45
+ __all__ = [
46
+ "strip_zero_width",
47
+ "devanagari_digits_to_ascii",
48
+ "ascii_digits_to_devanagari",
49
+ "normalize_danda",
50
+ "fold_nasals",
51
+ "normalize_devanagari",
52
+ ]
53
+
54
+ _DEVA_RANGE = "ऀ-ॿ"
55
+ _NUKTA = "़"
56
+ _ANUSVARA = "ं"
57
+ _CHANDRABINDU = "ँ"
58
+ _VISARGA = "ः"
59
+ _DANDA = "।"
60
+ _DOUBLE_DANDA = "॥"
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Invisibles / spaces
64
+ # ---------------------------------------------------------------------------
65
+
66
+ _ZERO_WIDTH_TRANSLATE = {
67
+ 0xFEFF: None, 0xFFFE: None, # BOM
68
+ 0x200B: None, 0x200C: None, 0x200D: None, # ZWSP, ZWNJ, ZWJ
69
+ 0x00AD: None, # soft hyphen
70
+ 0x2060: None, # word joiner
71
+ 0x00A0: 0x20, # NBSP → space
72
+ }
73
+
74
+
75
+ def strip_zero_width(text: str) -> str:
76
+ """Remove zero-width and joiner characters; map no-break space to space.
77
+
78
+ Follows the Indic NLP base normalization: strips ZWJ/ZWNJ/ZWSP, BOM, soft
79
+ hyphen, and word joiner, and turns a no-break space into a regular one.
80
+ Note this drops ZWJ/ZWNJ unconditionally, which can affect explicit conjunct
81
+ rendering; for Hindi normalization that is the accepted trade-off. Returns
82
+ the input unchanged when nothing matches; never raises.
83
+ """
84
+ if not isinstance(text, str):
85
+ return text
86
+ try:
87
+ return text.translate(_ZERO_WIDTH_TRANSLATE)
88
+ except Exception:
89
+ return text
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Digits
94
+ # ---------------------------------------------------------------------------
95
+
96
+ _DEVA_DIGITS = "०१२३४५६७८९"
97
+ _ASCII_DIGITS = "0123456789"
98
+ _DEVA_TO_ASCII = {ord(d): a for d, a in zip(_DEVA_DIGITS, _ASCII_DIGITS)}
99
+ _ASCII_TO_DEVA = {ord(a): d for d, a in zip(_DEVA_DIGITS, _ASCII_DIGITS)}
100
+
101
+
102
+ def devanagari_digits_to_ascii(text: str) -> str:
103
+ """Map Devanagari digits (``०१२…``) to ASCII digits (``012…``).
104
+
105
+ Returns the input unchanged when it holds none; never raises.
106
+ """
107
+ if not isinstance(text, str):
108
+ return text
109
+ try:
110
+ return text.translate(_DEVA_TO_ASCII)
111
+ except Exception:
112
+ return text
113
+
114
+
115
+ def ascii_digits_to_devanagari(text: str) -> str:
116
+ """Map ASCII digits (``012…``) to Devanagari digits (``०१२…``).
117
+
118
+ The inverse of :func:`devanagari_digits_to_ascii`; never raises.
119
+ """
120
+ if not isinstance(text, str):
121
+ return text
122
+ try:
123
+ return text.translate(_ASCII_TO_DEVA)
124
+ except Exception:
125
+ return text
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Danda punctuation
130
+ # ---------------------------------------------------------------------------
131
+
132
+ _PIPE_DOUBLE_RE = re.compile(r"\|\s*\|")
133
+ _PIPE_SINGLE_RE = re.compile(r"\|")
134
+ _DANDA_RUN_RE = re.compile(r"।(?:\s*।)+")
135
+ _SPACE_BEFORE_DANDA_RE = re.compile(r"\s+([।॥])")
136
+
137
+
138
+ def normalize_danda(text: str) -> str:
139
+ """Normalize danda punctuation.
140
+
141
+ Converts ASCII pipes used as dandas (``|``→``।``, ``||``→``॥``), collapses a
142
+ run of repeated dandas to one, and removes whitespace before a danda.
143
+ Returns the input unchanged when nothing matches; never raises.
144
+ """
145
+ if not isinstance(text, str):
146
+ return text
147
+ try:
148
+ text = _PIPE_DOUBLE_RE.sub(_DOUBLE_DANDA, text)
149
+ text = _PIPE_SINGLE_RE.sub(_DANDA, text)
150
+ text = _DANDA_RUN_RE.sub(_DANDA, text)
151
+ text = _SPACE_BEFORE_DANDA_RE.sub(r"\1", text)
152
+ return text
153
+ except Exception:
154
+ return text
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Nukta / nasals / visarga
159
+ # ---------------------------------------------------------------------------
160
+
161
+ # Pancham (class-nasal) consonant + halant before a consonant = anusvara.
162
+ _PANCHAM = "ङञणनम"
163
+ _NASAL_CLUSTER_RE = re.compile(r"[" + _PANCHAM + r"]्(?=[क-ह])")
164
+ _COLON_VISARGA_RE = re.compile(r"([" + _DEVA_RANGE + r"]):")
165
+
166
+
167
+ def fold_nasals(text: str, *, chandrabindu: bool = False) -> str:
168
+ """Fold explicit nasal-cluster spellings to anusvara (``ं``).
169
+
170
+ Rewrites a pancham consonant + halant before another consonant to anusvara
171
+ (``हिन्दी``→``हिंदी``). **Lossy** — discards which nasal was written. When
172
+ ``chandrabindu`` is true, also folds ``ँ``→``ं``. Never raises.
173
+ """
174
+ if not isinstance(text, str):
175
+ return text
176
+ try:
177
+ text = _NASAL_CLUSTER_RE.sub(_ANUSVARA, text)
178
+ if chandrabindu:
179
+ text = text.replace(_CHANDRABINDU, _ANUSVARA)
180
+ return text
181
+ except Exception:
182
+ return text
183
+
184
+
185
+ def _remove_nukta(text: str) -> str:
186
+ text = unicodedata.normalize("NFD", text).replace(_NUKTA, "")
187
+ return unicodedata.normalize("NFC", text)
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Orchestrator
192
+ # ---------------------------------------------------------------------------
193
+
194
+ _MULTISPACE_RE = re.compile(r"[^\S\n]{2,}")
195
+
196
+
197
+ def normalize_devanagari(
198
+ text: str,
199
+ *,
200
+ zero_width: bool = True,
201
+ compose: bool = True,
202
+ digits: bool = True,
203
+ danda: bool = True,
204
+ visarga: bool = False,
205
+ remove_nukta: bool = False,
206
+ nasals: bool = False,
207
+ chandrabindu: bool = False,
208
+ collapse_space: bool = True,
209
+ ) -> str:
210
+ """Character-level normalization of Devanagari text.
211
+
212
+ Runs the enabled steps in order: strip invisibles (``zero_width``), NFC
213
+ (``compose``), nukta removal (``remove_nukta``), Devanagari→ASCII digits
214
+ (``digits``), danda punctuation (``danda``), colon→visarga (``visarga``),
215
+ nasal folding (``nasals``/``chandrabindu``), and space-run collapse
216
+ (``collapse_space``, newlines kept).
217
+
218
+ ``zero_width``, ``compose``, ``digits``, ``danda``, ``collapse_space`` are on
219
+ by default (safe/lossless bar the accepted ZWJ trade-off). ``visarga``,
220
+ ``remove_nukta``, ``nasals``, ``chandrabindu`` are lossy and off by default.
221
+ Never raises.
222
+ """
223
+ if not isinstance(text, str):
224
+ return text
225
+ try:
226
+ if zero_width:
227
+ text = strip_zero_width(text)
228
+ if compose:
229
+ text = unicodedata.normalize("NFC", text)
230
+ if remove_nukta:
231
+ text = _remove_nukta(text)
232
+ if digits:
233
+ text = devanagari_digits_to_ascii(text)
234
+ if danda:
235
+ text = normalize_danda(text)
236
+ if visarga:
237
+ text = _COLON_VISARGA_RE.sub(r"\1" + _VISARGA, text)
238
+ if nasals or chandrabindu:
239
+ text = fold_nasals(text, chandrabindu=chandrabindu)
240
+ if collapse_space:
241
+ text = _MULTISPACE_RE.sub(" ", text)
242
+ return text
243
+ except Exception:
244
+ return text
@@ -0,0 +1,273 @@
1
+ """numbers.py — convert Hindi number words to digits.
2
+
3
+ The core problem: a speech recogniser transcribes spoken Hindi numerals as
4
+ *words* ("एक सौ दस"), not digits ("110"), and downstream code that wants a BP
5
+ reading, a weight, or a dose needs the digits. This module maps Hindi number
6
+ words (0-999) to integers, tolerant of the spelling variants an ASR engine
7
+ produces ("पाँच"/"पांच"/"पाच", "सत्तर"/"सतर").
8
+
9
+ >>> convert_numbers("एक सौ दस बटा सत्तर")
10
+ '110 बटा 70'
11
+ >>> parse_hindi_number("नौ सौ निन्यानवे")
12
+ 999
13
+
14
+ A deliberate design choice: adjacent *unrelated* number words are NOT summed.
15
+ "दो तीन दिन" ("two-three days") stays "2 3 दिन", never "5 दिन" — collapsing a
16
+ spoken range into its sum is a classic normalization bug.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import Dict, List, Optional, Tuple
23
+
24
+ # Number words 0-100, including common ASR misspellings.
25
+ WORD_TO_NUM: Dict[str, int] = {
26
+ # 0-10
27
+ "शून्य": 0, "एक": 1, "दो": 2, "तीन": 3, "चार": 4,
28
+ "पांच": 5, "पाँच": 5, "पाच": 5, "छह": 6, "छः": 6,
29
+ "सात": 7, "आठ": 8, "नौ": 9, "दस": 10,
30
+ # 11-19
31
+ "ग्यारह": 11, "गयारह": 11, "ग्यारा": 11,
32
+ "बारह": 12, "बारा": 12,
33
+ "तेरह": 13, "तेरा": 13,
34
+ "चौदह": 14, "चौदा": 14,
35
+ "पंद्रह": 15, "पन्द्रह": 15, "पंद्रा": 15,
36
+ "सोलह": 16, "सोला": 16,
37
+ "सत्रह": 17, "सत्तरह": 17,
38
+ "अठारह": 18, "अठारा": 18,
39
+ "उन्नीस": 19, "उन्निस": 19,
40
+ # 20-29
41
+ "बीस": 20, "इक्कीस": 21, "इक्किस": 21,
42
+ "बाईस": 22, "बाइस": 22,
43
+ "तेईस": 23, "तेइस": 23,
44
+ "चौबीस": 24, "चौबिस": 24,
45
+ "पच्चीस": 25, "पचीस": 25, "पच्चिस": 25,
46
+ "छब्बीस": 26, "छब्बिस": 26,
47
+ "सत्ताईस": 27, "सत्ताइस": 27,
48
+ "अट्ठाईस": 28, "अट्ठाइस": 28, "अठ्ठाईस": 28,
49
+ "उनतीस": 29, "उन्तीस": 29,
50
+ # 30-39
51
+ "तीस": 30, "इकतीस": 31, "इकत्तीस": 31,
52
+ "बत्तीस": 32, "बतीस": 32,
53
+ "तैंतीस": 33, "तेंतीस": 33,
54
+ "चौंतीस": 34, "चौतीस": 34,
55
+ "पैंतीस": 35, "पेंतीस": 35,
56
+ "छत्तीस": 36, "छतीस": 36,
57
+ "सैंतीस": 37, "सेंतीस": 37,
58
+ "अड़तीस": 38, "अडतीस": 38,
59
+ "उनतालीस": 39, "उन्तालीस": 39,
60
+ # 40-49
61
+ "चालीस": 40, "चालिस": 40,
62
+ "इकतालीस": 41, "एकतालीस": 41,
63
+ "बयालीस": 42, "बयालिस": 42,
64
+ "तैंतालीस": 43, "तेंतालीस": 43,
65
+ "चौवालीस": 44, "चवालीस": 44,
66
+ "पैंतालीस": 45, "पेंतालीस": 45,
67
+ "छियालीस": 46, "छयालीस": 46,
68
+ "सैंतालीस": 47, "सेंतालीस": 47,
69
+ "अड़तालीस": 48, "अडतालीस": 48,
70
+ "उनचास": 49,
71
+ # 50-59
72
+ "पचास": 50,
73
+ "इक्यावन": 51,
74
+ "बावन": 52,
75
+ "तिरपन": 53, "तिरेपन": 53,
76
+ "चौवन": 54, "चौबन": 54,
77
+ "पचपन": 55,
78
+ "छप्पन": 56, "छपन": 56,
79
+ "सत्तावन": 57, "सतावन": 57,
80
+ "अट्ठावन": 58, "अठावन": 58, "अठ्ठावन": 58,
81
+ "उनसठ": 59,
82
+ # 60-69
83
+ "साठ": 60, "साट": 60,
84
+ "इकसठ": 61, "एकसठ": 61,
85
+ "बासठ": 62, "बासट": 62,
86
+ "तिरसठ": 63, "तिरेसठ": 63,
87
+ "चौंसठ": 64, "चौसठ": 64,
88
+ "पैंसठ": 65, "पेंसठ": 65,
89
+ "छियासठ": 66, "छयासठ": 66,
90
+ "सड़सठ": 67, "सडसठ": 67,
91
+ "अड़सठ": 68, "अडसठ": 68,
92
+ "उनहत्तर": 69, "उनहतर": 69,
93
+ # 70-79
94
+ "सत्तर": 70, "सतर": 70,
95
+ "इकहत्तर": 71, "इकहतर": 71,
96
+ "बहत्तर": 72, "बहतर": 72,
97
+ "तिहत्तर": 73, "तिहतर": 73,
98
+ "चौहत्तर": 74, "चौहतर": 74,
99
+ "पचहत्तर": 75, "पचहतर": 75,
100
+ "छिहत्तर": 76, "छिहतर": 76,
101
+ "सतहत्तर": 77, "सतहतर": 77,
102
+ "अठहत्तर": 78, "अठहतर": 78,
103
+ "उन्यासी": 79, "उनासी": 79, "उन्नासी": 79,
104
+ # 80-89
105
+ "अस्सी": 80, "अस्सि": 80,
106
+ "इक्यासी": 81, "एक्यासी": 81,
107
+ "बयासी": 82, "ब्यासी": 82,
108
+ "तिरासी": 83,
109
+ "चौरासी": 84,
110
+ "पचासी": 85,
111
+ "छियासी": 86, "छयासी": 86,
112
+ "सत्तासी": 87, "सतासी": 87,
113
+ "अट्ठासी": 88, "अठासी": 88,
114
+ "नवासी": 89, "नव्वासी": 89,
115
+ # 90-99
116
+ "नब्बे": 90, "नब्बें": 90,
117
+ "इक्यानवे": 91,
118
+ "बानवे": 92,
119
+ "तिरानवे": 93,
120
+ "चौरानवे": 94,
121
+ "पंचानवे": 95, "पचानवे": 95,
122
+ "छियानवे": 96,
123
+ "सत्तानवे": 97, "सतानवे": 97,
124
+ "अट्ठानवे": 98, "अठानवे": 98,
125
+ "निन्यानवे": 99, "निन्नानवे": 99,
126
+ # Hundred marker
127
+ "सौ": 100, "सो": 100,
128
+ }
129
+
130
+ # Longest-first for greedy matching.
131
+ _NUM_SORTED = sorted(WORD_TO_NUM.items(), key=lambda x: -len(x[0]))
132
+
133
+ _DEVA = r"ऀ-ॿ"
134
+ _NUM_WORD_INNER = r"(?:" + "|".join(re.escape(w) for w, _ in _NUM_SORTED) + r")"
135
+
136
+ # A run of number words with Devanagari-aware boundaries.
137
+ _NUM_SEQ_RE = re.compile(
138
+ r"(?<![" + _DEVA + r"])"
139
+ + _NUM_WORD_INNER
140
+ + r"(?:\s+" + _NUM_WORD_INNER + r")*"
141
+ + r"(?![" + _DEVA + r"])"
142
+ )
143
+
144
+ # ASR merges digit-word onto सौ ("एकसो" → "एक सो").
145
+ _COMPOUND_SPLITS = re.compile(
146
+ r"(एकसो|दोसो|तीनसो|चारसो|पांचसो|पाँचसो|छहसो|सातसो|आठसो|नौसो)"
147
+ )
148
+ _COMPOUND_SPLIT_MAP = {
149
+ "एकसो": "एक सो", "दोसो": "दो सो", "तीनसो": "तीन सो",
150
+ "चारसो": "चार सो", "पांचसो": "पांच सो", "पाँचसो": "पाँच सो",
151
+ "छहसो": "छह सो", "सातसो": "सात सो", "आठसो": "आठ सो", "नौसो": "नौ सो",
152
+ }
153
+
154
+
155
+ def _parse_one_number(words: List[str], start: int) -> Tuple[int, Optional[int]]:
156
+ """Parse one Hindi number expression starting at ``words[start]``.
157
+
158
+ Returns ``(consumed_word_count, value)``, or ``(0, None)`` if no number
159
+ begins at ``start``.
160
+
161
+ Recognized patterns::
162
+
163
+ [1-9] सौ [1-99] → एक सौ साठ = 160
164
+ [1-9] सौ → दो सौ = 200
165
+ सौ [1-99] → सौ दस = 110
166
+ सौ → सौ = 100
167
+ [0-99] → अट्ठावन = 58, दस = 10
168
+
169
+ Adjacent *simple* digits are NOT merged: ``"दो तीन"`` yields ``(1, 2)`` — the
170
+ caller advances and parses ``"तीन"`` separately.
171
+ """
172
+ n = len(words)
173
+ if start >= n:
174
+ return 0, None
175
+ v0 = WORD_TO_NUM.get(words[start])
176
+ if v0 is None:
177
+ return 0, None
178
+
179
+ # Pattern: [1-9] सौ [optional 1-99]
180
+ if 1 <= v0 < 10 and start + 1 < n and WORD_TO_NUM.get(words[start + 1]) == 100:
181
+ total = v0 * 100
182
+ if start + 2 < n:
183
+ v2 = WORD_TO_NUM.get(words[start + 2])
184
+ if v2 is not None and 0 < v2 < 100:
185
+ return 3, total + v2
186
+ return 2, total
187
+
188
+ # Pattern: सौ [optional 1-99]
189
+ if v0 == 100:
190
+ if start + 1 < n:
191
+ v1 = WORD_TO_NUM.get(words[start + 1])
192
+ if v1 is not None and 0 < v1 < 100:
193
+ return 2, 100 + v1
194
+ return 1, 100
195
+
196
+ # Pattern: any single number word (0-99)
197
+ return 1, v0
198
+
199
+
200
+ def parse_hindi_number(text: str) -> Optional[int]:
201
+ """Parse a single Hindi number expression into an integer.
202
+
203
+ For sequences of *unrelated* numbers (e.g. ``"दो तीन"`` — two then three),
204
+ returns only the first parseable number (``2``). Use :func:`convert_numbers`
205
+ to handle mixed sequences within a real transcript.
206
+
207
+ Examples::
208
+
209
+ "एक सौ दस" → 110
210
+ "एक सौ पचपन" → 155
211
+ "दो सौ" → 200
212
+ "सौ" → 100
213
+ "सत्तर" → 70
214
+ "अट्ठावन" → 58
215
+ "नौ सौ निन्यानवे" → 999
216
+
217
+ Returns ``None`` for empty input or a leading non-number word. Never raises.
218
+ """
219
+ if not isinstance(text, str):
220
+ return None
221
+ try:
222
+ words = text.strip().split()
223
+ if not words:
224
+ return None
225
+ consumed, val = _parse_one_number(words, 0)
226
+ if consumed == 0:
227
+ return None
228
+ return val
229
+ except Exception:
230
+ return None
231
+
232
+
233
+ def convert_numbers(text: str) -> str:
234
+ """Replace every Hindi number-word sequence in *text* with digit strings.
235
+
236
+ Within a matched sequence, numbers are parsed one at a time, so unrelated
237
+ adjacent number words (``"दो तीन"``) stay separate (``"2 3"``) instead of
238
+ summing.
239
+
240
+ Examples::
241
+
242
+ "एक सौ दस" → "110"
243
+ "एक सौ दस बटा सत्तर" → "110 बटा 70"
244
+ "अट्ठावन kg" → "58 kg"
245
+ "एकसो दस" → "110" (compound-split ASR artifact)
246
+
247
+ Returns the input unchanged when it holds no number words; never raises.
248
+ """
249
+ if not isinstance(text, str):
250
+ return text
251
+ try:
252
+ # Pre-split merged compounds like "एकसो" → "एक सो".
253
+ text = _COMPOUND_SPLITS.sub(
254
+ lambda m: _COMPOUND_SPLIT_MAP.get(m.group(0), m.group(0)), text
255
+ )
256
+
257
+ def _replace(m: "re.Match[str]") -> str:
258
+ words = m.group(0).split()
259
+ out: List[str] = []
260
+ i = 0
261
+ while i < len(words):
262
+ consumed, val = _parse_one_number(words, i)
263
+ if consumed == 0:
264
+ out.append(words[i])
265
+ i += 1
266
+ else:
267
+ out.append(str(val))
268
+ i += consumed
269
+ return " ".join(out)
270
+
271
+ return _NUM_SEQ_RE.sub(_replace, text)
272
+ except Exception:
273
+ return text
File without changes
@@ -0,0 +1,47 @@
1
+ """repetition.py — collapse inline repetition artifacts from ASR output.
2
+
3
+ Speech recognisers stutter: they emit the same short cluster or word several
4
+ times in a row (``ठीकठीकठीकठीक``, ``है है है है``). This collapses those runs
5
+ back to a single instance.
6
+
7
+ This targets *inline, back-to-back* repeats anywhere in the text. For runaway
8
+ *tail* loops at the end of long LLM generations (repeated sentences or
9
+ paragraphs), reach for ``llmclean``'s ``trim_repetition`` instead — the two are
10
+ complementary.
11
+
12
+ >>> collapse_repetition("ठीकठीकठीकठीक है")
13
+ 'ठीक है'
14
+ >>> collapse_repetition("हाँ हाँ हाँ हाँ जी")
15
+ 'हाँ जी'
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ __all__ = ["collapse_repetition"]
23
+
24
+
25
+ def collapse_repetition(text: str, *, min_repeats: int = 4) -> str:
26
+ """Collapse back-to-back repeats of a short cluster or whole word.
27
+
28
+ A cluster of up to 5 characters or a whitespace-separated word repeated
29
+ ``min_repeats`` or more times in a row collapses to one instance. Returns
30
+ the input unchanged when nothing repeats that often; never raises.
31
+
32
+ Parameters
33
+ ----------
34
+ text:
35
+ Raw ASR text.
36
+ min_repeats:
37
+ Minimum consecutive repeats before collapsing (default 4).
38
+ """
39
+ if not isinstance(text, str) or min_repeats < 2:
40
+ return text
41
+ try:
42
+ n = min_repeats - 1
43
+ text = re.sub(r"(.{1,5}?)\1{" + str(n) + r",}", r"\1", text)
44
+ text = re.sub(r"(\S+)(?:\s+\1){" + str(n) + r",}", r"\1", text)
45
+ return text
46
+ except Exception:
47
+ return text
@@ -0,0 +1,52 @@
1
+ """terms.py — map spoken/mis-recognized terms to canonical forms.
2
+
3
+ An ASR engine writes English clinical or technical terms inconsistently: some
4
+ in Latin (``BP``), some transliterated into Devanagari (``बीपी``, ``बीबी``),
5
+ some spelled several different ways. A small term dictionary maps every variant
6
+ to one canonical form.
7
+
8
+ :data:`MEDICAL_TERMS` ships a maternal/child-health (ASHA home-visit) vocabulary
9
+ as a ready default. Pass your own dictionary to :func:`replace_terms` for any
10
+ other domain — the replacement logic is domain-agnostic.
11
+
12
+ >>> replace_terms("बीबी एक सौ दस बटा सत्तर", MEDICAL_TERMS)
13
+ 'BP एक सौ दस / सत्तर'
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Dict, Mapping
19
+
20
+ __all__ = ["MEDICAL_TERMS", "replace_terms"]
21
+
22
+ MEDICAL_TERMS: Dict[str, str] = {
23
+ "बीपी": "BP", "भीपी": "BP", "बीबी": "BP", "बी पी": "BP", "बी.पी.": "BP",
24
+ "एचबी": "Hb", "हबी": "Hb", "हीमोग्लोबिन": "Hb", "एच बी": "Hb",
25
+ "आईएफए": "IFA", "आई एफ ए": "IFA",
26
+ "टीटी": "TT", "टी टी": "TT",
27
+ "पीएचसी": "PHC", "पी एच सी": "PHC", "पीएचसे": "PHC",
28
+ "सीएचसी": "CHC", "सी एच सी": "CHC",
29
+ "बीसीजी": "BCG", "ओपीवी": "OPV", "हेप बी": "Hep-B",
30
+ "आईएमएनसीआई": "IMNCI",
31
+ "किलो": "kg", "किलोग्राम": "kg",
32
+ "बटा": "/", "बता": "/",
33
+ "दशमलव": ".", "दशम्लव": ".", "दशम्लफ": ".",
34
+ "डिग्री": "°",
35
+ }
36
+
37
+
38
+ def replace_terms(text: str, terms: Mapping[str, str] = MEDICAL_TERMS) -> str:
39
+ """Replace each key of *terms* in *text* with its value, longest key first.
40
+
41
+ Longest-first ordering stops a short key from firing inside a longer one.
42
+ Pass ``terms=None`` or ``{}`` to no-op. Returns the input unchanged when
43
+ nothing matches; never raises.
44
+ """
45
+ if not isinstance(text, str) or not terms:
46
+ return text
47
+ try:
48
+ for spoken, canonical in sorted(terms.items(), key=lambda x: -len(x[0])):
49
+ text = text.replace(spoken, canonical)
50
+ return text
51
+ except Exception:
52
+ return text
@@ -0,0 +1,74 @@
1
+ """transcript.py — batteries-included normalization for Hindi ASR transcripts.
2
+
3
+ :func:`normalize_transcript` chains the package's transforms in the order that
4
+ works for real speech-to-text output — collapse stutter, canonicalize the
5
+ Devanagari, map domain terms, convert number words to digits, tidy the
6
+ punctuation — so a raw Whisper/ASR string comes out ready for downstream
7
+ extraction:
8
+
9
+ >>> normalize_transcript("आपका BP एक सौ दस बटा सत्तर है, वजन अट्ठावन kg")
10
+ 'आपका BP 110/70 है, वजन 58 kg'
11
+
12
+ Each stage is a flag, and the term dictionary is swappable, so the pipeline
13
+ adapts to non-medical domains or to callers that only want part of it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Mapping, Optional
20
+
21
+ from .devanagari import normalize_devanagari
22
+ from .numbers import convert_numbers
23
+ from .repetition import collapse_repetition
24
+ from .terms import MEDICAL_TERMS, replace_terms
25
+
26
+ __all__ = ["normalize_transcript"]
27
+
28
+ _SLASH_RE = re.compile(r"\s*/\s*")
29
+ _DECIMAL_RE = re.compile(r"(\d)\s*\.\s*(\d)")
30
+ _SENTENCE_RE = re.compile(r"[।](?:\s+)")
31
+
32
+
33
+ def normalize_transcript(
34
+ transcript: str,
35
+ *,
36
+ terms: Optional[Mapping[str, str]] = MEDICAL_TERMS,
37
+ numbers: bool = True,
38
+ devanagari: bool = True,
39
+ repetition: bool = True,
40
+ sentence_breaks: bool = True,
41
+ ) -> str:
42
+ """Normalize a Hindi ASR transcript end to end.
43
+
44
+ Pipeline (each stage toggleable):
45
+
46
+ 1. **repetition** — collapse ASR stutter (:func:`collapse_repetition`).
47
+ 2. **devanagari** — invisibles, NFC, Devanagari→ASCII digits, danda
48
+ (:func:`normalize_devanagari`, safe defaults only).
49
+ 3. **terms** — map domain terms to canonical forms (:func:`replace_terms`);
50
+ defaults to :data:`MEDICAL_TERMS`, pass ``None`` to skip.
51
+ 4. **numbers** — Hindi number words → digits (:func:`convert_numbers`).
52
+ 5. Spacing cleanup around ``/`` and decimal points.
53
+ 6. **sentence_breaks** — newline after each danda ``।``.
54
+
55
+ Trailing punctuation and whitespace are always trimmed. Never raises.
56
+ """
57
+ if not isinstance(transcript, str):
58
+ return transcript
59
+ try:
60
+ if repetition:
61
+ transcript = collapse_repetition(transcript)
62
+ if devanagari:
63
+ transcript = normalize_devanagari(transcript)
64
+ if terms:
65
+ transcript = replace_terms(transcript, terms)
66
+ if numbers:
67
+ transcript = convert_numbers(transcript)
68
+ transcript = _SLASH_RE.sub("/", transcript)
69
+ transcript = _DECIMAL_RE.sub(r"\1.\2", transcript)
70
+ if sentence_breaks:
71
+ transcript = _SENTENCE_RE.sub("।\n", transcript)
72
+ return transcript.strip().rstrip(",. ")
73
+ except Exception:
74
+ return transcript
@@ -0,0 +1,192 @@
1
+ import unicodedata
2
+ import unittest
3
+
4
+ from hindi_normalize import (
5
+ WORD_TO_NUM,
6
+ parse_hindi_number,
7
+ convert_numbers,
8
+ strip_zero_width,
9
+ devanagari_digits_to_ascii,
10
+ ascii_digits_to_devanagari,
11
+ normalize_danda,
12
+ fold_nasals,
13
+ normalize_devanagari,
14
+ MEDICAL_TERMS,
15
+ replace_terms,
16
+ collapse_repetition,
17
+ normalize_transcript,
18
+ )
19
+
20
+
21
+ class TestNumbers(unittest.TestCase):
22
+ def test_word_map_coverage(self):
23
+ self.assertGreaterEqual(len(WORD_TO_NUM), 160)
24
+ self.assertEqual(WORD_TO_NUM["शून्य"], 0)
25
+ self.assertEqual(WORD_TO_NUM["एक"], 1)
26
+ self.assertEqual(WORD_TO_NUM["दस"], 10)
27
+ self.assertEqual(WORD_TO_NUM["सौ"], 100)
28
+
29
+ def test_parse_singles(self):
30
+ self.assertEqual(parse_hindi_number("शून्य"), 0)
31
+ self.assertEqual(parse_hindi_number("सत्तर"), 70)
32
+ self.assertEqual(parse_hindi_number("अट्ठावन"), 58)
33
+ self.assertEqual(parse_hindi_number("सौ"), 100)
34
+
35
+ def test_parse_compounds(self):
36
+ self.assertEqual(parse_hindi_number("एक सौ दस"), 110)
37
+ self.assertEqual(parse_hindi_number("एक सौ पचपन"), 155)
38
+ self.assertEqual(parse_hindi_number("दो सौ"), 200)
39
+ self.assertEqual(parse_hindi_number("पाँच सौ"), 500)
40
+ self.assertEqual(parse_hindi_number("नौ सौ निन्यानवे"), 999)
41
+
42
+ def test_parse_none_cases(self):
43
+ self.assertIsNone(parse_hindi_number(""))
44
+ self.assertIsNone(parse_hindi_number(" "))
45
+ self.assertIsNone(parse_hindi_number("नमस्ते"))
46
+ self.assertIsNone(parse_hindi_number(None))
47
+
48
+ def test_parse_first_number_only(self):
49
+ self.assertEqual(parse_hindi_number("दस नमस्ते बीस"), 10)
50
+
51
+ def test_convert_basic(self):
52
+ self.assertEqual(convert_numbers("एक सौ दस"), "110")
53
+ self.assertEqual(convert_numbers("अट्ठावन kg"), "58 kg")
54
+
55
+ def test_convert_range_not_summed(self):
56
+ self.assertEqual(convert_numbers("दो तीन दिन"), "2 3 दिन")
57
+
58
+ def test_convert_compound_split(self):
59
+ self.assertEqual(convert_numbers("एकसो दस"), "110")
60
+ self.assertEqual(convert_numbers("दोसो पचास"), "250")
61
+
62
+ def test_convert_non_str(self):
63
+ self.assertEqual(convert_numbers(None), None)
64
+
65
+
66
+ class TestDevanagari(unittest.TestCase):
67
+ def test_strip_zero_width(self):
68
+ self.assertEqual(strip_zero_width("क‍म‌​ख"), "कमख")
69
+ self.assertEqual(strip_zero_width("राम"), "राम")
70
+ self.assertEqual(strip_zero_width("क ख"), "क ख")
71
+ self.assertEqual(strip_zero_width(None), None)
72
+
73
+ def test_digit_conversion(self):
74
+ self.assertEqual(devanagari_digits_to_ascii("वजन ५८ किलो"), "वजन 58 किलो")
75
+ self.assertEqual(ascii_digits_to_devanagari("58"), "५८")
76
+ self.assertEqual(devanagari_digits_to_ascii(ascii_digits_to_devanagari("2026")), "2026")
77
+
78
+ def test_danda(self):
79
+ self.assertEqual(normalize_danda("ठीक है |"), "ठीक है।")
80
+ self.assertEqual(normalize_danda("श्लोक ||"), "श्लोक॥")
81
+ self.assertEqual(normalize_danda("ठीक ।। है"), "ठीक। है")
82
+ self.assertEqual(normalize_danda("ठीक है ।"), "ठीक है।")
83
+
84
+ def test_fold_nasals(self):
85
+ self.assertEqual(fold_nasals("हिन्दी"), "हिंदी")
86
+ self.assertEqual(fold_nasals("गङ्गा"), "गंगा")
87
+ self.assertEqual(fold_nasals("सम्भव"), "संभव")
88
+ self.assertEqual(fold_nasals("चँद", chandrabindu=True), "चंद")
89
+ self.assertEqual(fold_nasals("चँद"), "चँद")
90
+
91
+ def test_nfc_unifies_nukta(self):
92
+ precomposed = "क़" # क़ single codepoint
93
+ decomposed = "क़" # क + nukta
94
+ self.assertNotEqual(precomposed, decomposed)
95
+ a = normalize_devanagari(precomposed, digits=False, danda=False)
96
+ b = normalize_devanagari(decomposed, digits=False, danda=False)
97
+ self.assertEqual(a, b)
98
+ self.assertEqual(a, unicodedata.normalize("NFC", decomposed))
99
+
100
+ def test_remove_nukta(self):
101
+ self.assertEqual(
102
+ normalize_devanagari("क़ानून", remove_nukta=True, digits=False, danda=False),
103
+ "कानून",
104
+ )
105
+
106
+ def test_visarga_optin(self):
107
+ self.assertEqual(normalize_devanagari("दु:ख", visarga=True), "दुःख")
108
+ self.assertEqual(normalize_devanagari("दु:ख"), "दु:ख")
109
+
110
+ def test_defaults_are_lossless_side(self):
111
+ self.assertEqual(normalize_devanagari("हिन्दी"), "हिन्दी")
112
+
113
+ def test_orchestrator_digits_and_danda(self):
114
+ self.assertEqual(normalize_devanagari("वजन ५८ किलो | ठीक"), "वजन 58 किलो। ठीक")
115
+
116
+ def test_collapse_space(self):
117
+ self.assertEqual(normalize_devanagari("राम नाम"), "राम नाम")
118
+
119
+
120
+ class TestTerms(unittest.TestCase):
121
+ def test_replace(self):
122
+ self.assertEqual(replace_terms("बीबी ठीक"), "BP ठीक")
123
+ self.assertEqual(replace_terms("दस बटा सात"), "दस / सात")
124
+
125
+ def test_longest_first(self):
126
+ self.assertEqual(replace_terms("बी पी ठीक"), "BP ठीक")
127
+
128
+ def test_custom_dict(self):
129
+ self.assertEqual(replace_terms("क ख", {"क": "K", "ख": "KH"}), "K KH")
130
+
131
+ def test_none_and_empty(self):
132
+ self.assertEqual(replace_terms("बीबी", None), "बीबी")
133
+ self.assertEqual(replace_terms("बीबी", {}), "बीबी")
134
+ self.assertEqual(replace_terms(None), None)
135
+
136
+
137
+ class TestRepetition(unittest.TestCase):
138
+ def test_cluster_run(self):
139
+ self.assertEqual(collapse_repetition("ठीकठीकठीकठीक है"), "ठीक है")
140
+
141
+ def test_word_run(self):
142
+ self.assertEqual(collapse_repetition("राम राम राम राम जी"), "राम जी")
143
+
144
+ def test_below_threshold_unchanged(self):
145
+ self.assertEqual(collapse_repetition("ठीक ठीक"), "ठीक ठीक")
146
+
147
+ def test_min_repeats_param(self):
148
+ self.assertEqual(collapse_repetition("हा हा हा", min_repeats=3), "हा")
149
+
150
+ def test_non_str(self):
151
+ self.assertEqual(collapse_repetition(None), None)
152
+
153
+
154
+ class TestTranscript(unittest.TestCase):
155
+ def test_canonical_bp(self):
156
+ out = normalize_transcript("आपका BP एक सौ दस बटा सत्तर है, वजन अट्ठावन kg")
157
+ self.assertEqual(out, "आपका BP 110/70 है, वजन 58 kg")
158
+
159
+ def test_bp_term(self):
160
+ out = normalize_transcript("बीपी एक सौ दस")
161
+ self.assertTrue(out.startswith("BP "))
162
+ self.assertIn("110", out)
163
+
164
+ def test_devanagari_digits(self):
165
+ self.assertIn("5 दिन", normalize_transcript("५ दिन"))
166
+
167
+ def test_repetition_fixed(self):
168
+ out = normalize_transcript("ठीकठीकठीकठीक है")
169
+ self.assertNotIn("ठीकठीक", out)
170
+
171
+ def test_sentence_break(self):
172
+ out = normalize_transcript("वजन बढ़ रहा है। BP ठीक है।")
173
+ self.assertIn("।\n", out)
174
+
175
+ def test_trailing_trim(self):
176
+ self.assertEqual(normalize_transcript(" ठीक है. "), "ठीक है")
177
+
178
+ def test_terms_off(self):
179
+ out = normalize_transcript("दस बजे", terms=None)
180
+ self.assertEqual(out, "10 बजे")
181
+
182
+ def test_preserve_english(self):
183
+ out = normalize_transcript("BP ठीक, IFA दे दी")
184
+ self.assertIn("BP", out)
185
+ self.assertIn("IFA", out)
186
+
187
+ def test_non_str(self):
188
+ self.assertEqual(normalize_transcript(None), None)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ unittest.main()