Skip to content

API

polish_whisper_normalizer.basic

remove_symbols(s, keep='')

Replace any other markers, symbols, punctuations with a space, keeping diacritics (and any characters listed in keep). Uses NFKC to keep composed diacritics intact.

Source code in src/polish_whisper_normalizer/basic.py
56
57
58
59
60
61
62
63
64
def remove_symbols(s: str, keep: str = "") -> str:
    """
    Replace any other markers, symbols, punctuations with a space, keeping diacritics
    (and any characters listed in `keep`). Uses NFKC to keep composed diacritics intact.
    """
    return "".join(
        c if c in keep else (" " if unicodedata.category(c)[0] in "MSP" else c)
        for c in unicodedata.normalize("NFKC", s)
    )

remove_symbols_and_diacritics(s, keep='')

Replace any other markers, symbols, and punctuations with a space, and drop any diacritics (category 'Mn' and some manual mappings). Uses NFKD to decompose diacritics; manual ADDITIONAL_DIACRITICS handles chars not decomposed (e.g. ł).

Source code in src/polish_whisper_normalizer/basic.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def remove_symbols_and_diacritics(s: str, keep: str = "") -> str:
    """
    Replace any other markers, symbols, and punctuations with a space,
    and drop any diacritics (category 'Mn' and some manual mappings).
    Uses NFKD to decompose diacritics; manual ADDITIONAL_DIACRITICS handles
    chars not decomposed (e.g. ł).
    """
    return "".join(
        (
            c
            if c in keep
            else (
                ADDITIONAL_DIACRITICS[c]
                if c in ADDITIONAL_DIACRITICS
                else (
                    ""
                    if unicodedata.category(c) == "Mn"
                    else " "
                    if unicodedata.category(c)[0] in "MSP"
                    else c
                )
            )
        )
        for c in unicodedata.normalize("NFKD", s)
    )

polish_whisper_normalizer.lemmatizer.PolishLemmatizer

Thin, caching wrapper around Morfeusz 2 used to map declined number words back to their base (lemma) form. If Morfeusz is unavailable it degrades gracefully and simply returns no analyses.

Morfeusz is the single source of truth for declension – we do not re-implement Polish morphology manually; instead we rely on analyse/generate to canonicalize declined forms.

Source code in src/polish_whisper_normalizer/lemmatizer.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
class PolishLemmatizer:
    """
    Thin, caching wrapper around Morfeusz 2 used to map declined number words
    back to their base (lemma) form. If Morfeusz is unavailable it degrades
    gracefully and simply returns no analyses.

    Morfeusz is the single source of truth for declension – we do not
    re-implement Polish morphology manually; instead we rely on
    ``analyse``/``generate`` to canonicalize declined forms.
    """

    def __init__(self) -> None:
        self._morf: Any | None = None
        self._morf_failed: bool = False
        self._cache: dict[str, list[tuple[str, str]]] = {}
        self._gen_cache: dict[str, list[tuple[str, str, str]]] = {}
        self._lock = threading.Lock()

    @property
    def morf(self) -> Any | None:
        if self._morf is not None:
            return self._morf
        if self._morf_failed:
            return None
        with self._lock:
            if self._morf is not None:
                return self._morf
            if self._morf_failed:
                return None
            try:
                import morfeusz2

                self._morf = morfeusz2.Morfeusz()
            except ImportError:
                logger.debug("morfeusz2 not installed – lemmatizer disabled")
                self._morf_failed = True
                return None
            except Exception as exc:  # pragma: no cover – unexpected Morfeusz init error
                logger.warning("Failed to initialize Morfeusz: %s", exc)
                self._morf_failed = True
                return None
            return self._morf

    def analyse(self, word: str) -> list[tuple[str, str]]:
        """Return a list of (base_lemma, part_of_speech) tuples."""
        morf = self.morf
        if morf is None:
            return []
        if word in self._cache:
            return self._cache[word]
        results: list[tuple[str, str]] = []
        try:
            analyses = morf.analyse(word)  # type: ignore[union-attr]
        except Exception as exc:  # pragma: no cover
            logger.debug("Morfeusz analyse failed for %r: %s", word, exc)
            self._cache[word] = []
            return []
        for analysis in analyses:
            try:
                if len(analysis) == 3 and isinstance(analysis[2], tuple):
                    datum = analysis[2]  # type: ignore[assignment]
                    lemma, morph = datum[1], datum[2]  # type: ignore[index]
                else:
                    lemma, morph = analysis[1], analysis[2]  # type: ignore[misc]
                results.append((lemma.split(":")[0], morph.split(":")[0]))
            except Exception:  # pragma: no cover – malformed entry
                continue
        self._cache[word] = results
        return results

    def generate(self, lemma: str) -> list[tuple[str, str, str]]:
        """Generate all surface forms for *lemma* (wrapper for Morfeusz.generate)."""
        if lemma in self._gen_cache:
            return self._gen_cache[lemma]
        morf = self.morf
        if morf is None:
            return []
        try:
            # Morfeusz.generate returns list of (form, lemma, tag, ..., ...)
            forms = morf.generate(lemma)  # type: ignore[union-attr]
        except Exception as exc:  # pragma: no cover
            logger.debug("Morfeusz generate failed for %r: %s", lemma, exc)
            self._gen_cache[lemma] = []
            return []
        # Normalize to (surface, lemma, tag)
        out: list[tuple[str, str, str]] = []
        for entry in forms:
            if len(entry) >= 3:
                surface, lem, tag = entry[0], entry[1], entry[2]  # type: ignore[misc]
                if isinstance(surface, str) and isinstance(lem, str) and isinstance(tag, str):
                    out.append((surface, lem, tag))
        self._gen_cache[lemma] = out
        return out

analyse(word)

Return a list of (base_lemma, part_of_speech) tuples.

Source code in src/polish_whisper_normalizer/lemmatizer.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def analyse(self, word: str) -> list[tuple[str, str]]:
    """Return a list of (base_lemma, part_of_speech) tuples."""
    morf = self.morf
    if morf is None:
        return []
    if word in self._cache:
        return self._cache[word]
    results: list[tuple[str, str]] = []
    try:
        analyses = morf.analyse(word)  # type: ignore[union-attr]
    except Exception as exc:  # pragma: no cover
        logger.debug("Morfeusz analyse failed for %r: %s", word, exc)
        self._cache[word] = []
        return []
    for analysis in analyses:
        try:
            if len(analysis) == 3 and isinstance(analysis[2], tuple):
                datum = analysis[2]  # type: ignore[assignment]
                lemma, morph = datum[1], datum[2]  # type: ignore[index]
            else:
                lemma, morph = analysis[1], analysis[2]  # type: ignore[misc]
            results.append((lemma.split(":")[0], morph.split(":")[0]))
        except Exception:  # pragma: no cover – malformed entry
            continue
    self._cache[word] = results
    return results

generate(lemma)

Generate all surface forms for lemma (wrapper for Morfeusz.generate).

Source code in src/polish_whisper_normalizer/lemmatizer.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def generate(self, lemma: str) -> list[tuple[str, str, str]]:
    """Generate all surface forms for *lemma* (wrapper for Morfeusz.generate)."""
    if lemma in self._gen_cache:
        return self._gen_cache[lemma]
    morf = self.morf
    if morf is None:
        return []
    try:
        # Morfeusz.generate returns list of (form, lemma, tag, ..., ...)
        forms = morf.generate(lemma)  # type: ignore[union-attr]
    except Exception as exc:  # pragma: no cover
        logger.debug("Morfeusz generate failed for %r: %s", lemma, exc)
        self._gen_cache[lemma] = []
        return []
    # Normalize to (surface, lemma, tag)
    out: list[tuple[str, str, str]] = []
    for entry in forms:
        if len(entry) >= 3:
            surface, lem, tag = entry[0], entry[1], entry[2]  # type: ignore[misc]
            if isinstance(surface, str) and isinstance(lem, str) and isinstance(tag, str):
                out.append((surface, lem, tag))
    self._gen_cache[lemma] = out
    return out

polish_whisper_normalizer.numbers.PolishNumberNormalizer

Convert spelled-out Polish numbers into arabic digits, while handling:

  • cardinal numbers ("sto dwadzieścia trzy" -> "123")
  • grammatical variants of big multipliers ("tysiąc/tysiące/tysięcy" -> 1000)
  • currency ("pięć złotych" -> "5 zł", "pięćdziesiąt groszy" -> "50 gr")
  • percents ("dwadzieścia procent" -> "20%")
  • decimals ("trzy przecinek czternaście" -> "3.14")
  • signs ("minus dziesięć" -> "-10")
  • ordinal numbers, including declension ("dwudziesty pierwszy" -> "21.", "pierwszego" -> "1.", "trzeciej" -> "3.")
  • declined cardinal forms ("pięciu" -> "5", "dwóm" -> "2", "tysiąca" -> "1000")
Source code in src/polish_whisper_normalizer/numbers.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
class PolishNumberNormalizer:
    """
    Convert spelled-out Polish numbers into arabic digits, while handling:

    - cardinal numbers ("sto dwadzieścia trzy" -> "123")
    - grammatical variants of big multipliers ("tysiąc/tysiące/tysięcy" -> 1000)
    - currency ("pięć złotych" -> "5 zł", "pięćdziesiąt groszy" -> "50 gr")
    - percents ("dwadzieścia procent" -> "20%")
    - decimals ("trzy przecinek czternaście" -> "3.14")
    - signs ("minus dziesięć" -> "-10")
    - ordinal numbers, including declension ("dwudziesty pierwszy" -> "21.",
      "pierwszego" -> "1.", "trzeciej" -> "3.")
    - declined cardinal forms ("pięciu" -> "5", "dwóm" -> "2", "tysiąca" -> "1000")
    """

    # class-level shared state – built once, reused (thread-safe)
    _CACHE: dict[str, object] | None = None
    _CACHE_LOCK = threading.Lock()
    _DECLINED_ASCII_CACHE: dict[str, str] | None = None
    _DECLINED_LOCK = threading.Lock()

    # pre-compiled patterns (avoid recompiling per token)
    _POLISH_WORD_RE = re.compile(r"[a-ząćęłńóśźż]+")
    _NUMERIC_RE = re.compile(r"^\d+(?:\.\d+)?$")
    _NUMERIC_PREFIX_RE = re.compile(r"^\d")
    _DIGIT_RE = re.compile(r"^\d+(?:\.\d+)?$")

    def __init__(self) -> None:
        super().__init__()

        self.zeros = {"zero"}
        self.ones = {
            "jeden": 1,
            "jedna": 1,
            "jedno": 1,
            "dwa": 2,
            "dwie": 2,
            "trzy": 3,
            "cztery": 4,
            "pięć": 5,
            "sześć": 6,
            "siedem": 7,
            "osiem": 8,
            "dziewięć": 9,
            "dziesięć": 10,
            "jedenaście": 11,
            "dwanaście": 12,
            "trzynaście": 13,
            "czternaście": 14,
            "piętnaście": 15,
            "szesnaście": 16,
            "siedemnaście": 17,
            "osiemnaście": 18,
            "ośmnaście": 18,
            "dziewiętnaście": 19,
        }
        self.tens = {
            "dwadzieścia": 20,
            "trzydzieści": 30,
            "czterdzieści": 40,
            "pięćdziesiąt": 50,
            "sześćdziesiąt": 60,
            "siedemdziesiąt": 70,
            "osiemdziesiąt": 80,
            "dziewięćdziesiąt": 90,
        }
        self.hundreds = {
            "sto": 100,
            "dwieście": 200,
            "trzysta": 300,
            "czterysta": 400,
            "pięćset": 500,
            "sześćset": 600,
            "siedemset": 700,
            "osiemset": 800,
            "dziewięćset": 900,
        }
        self.multipliers = {
            "tysiąc": 1_000,
            "tysiące": 1_000,
            "tysięcy": 1_000,
            "milion": 1_000_000,
            "miliony": 1_000_000,
            "milionów": 1_000_000,
            "miliard": 1_000_000_000,
            "miliardy": 1_000_000_000,
            "miliardów": 1_000_000_000,
            "bilion": 1_000_000_000_000,
            "biliony": 1_000_000_000_000,
            "bilionów": 1_000_000_000_000,
            "biliard": 1_000_000_000_000_000,
            "biliardy": 1_000_000_000_000_000,
            "biliardów": 1_000_000_000_000_000,
            "trylion": 1_000_000_000_000_000_000,
            "tryliony": 1_000_000_000_000_000_000,
            "trylionów": 1_000_000_000_000_000_000,
        }

        # ordinal numbers (base masculine-nominative forms)
        self.ones_ordinal = {
            "pierwszy": 1,
            "drugi": 2,
            "trzeci": 3,
            "czwarty": 4,
            "piąty": 5,
            "szósty": 6,
            "siódmy": 7,
            "ósmy": 8,
            "dziewiąty": 9,
            "dziesiąty": 10,
            "jedenasty": 11,
            "dwunasty": 12,
            "trzynasty": 13,
            "czternasty": 14,
            "piętnasty": 15,
            "szesnasty": 16,
            "siedemnasty": 17,
            "osiemnasty": 18,
            "dziewiętnasty": 19,
        }
        self.tens_ordinal = {
            "dwudziesty": 20,
            "trzydziesty": 30,
            "czterdziesty": 40,
            "pięćdziesiąty": 50,
            "sześćdziesiąty": 60,
            "siedemdziesiąty": 70,
            "osiemdziesiąty": 80,
            "dziewięćdziesiąty": 90,
        }
        self.hundreds_ordinal = {
            "setny": 100,
            "dwusetny": 200,
            "trzysetny": 300,
            "czterysetny": 400,
            "pięćsetny": 500,
            "sześćsetny": 600,
            "siedemsetny": 700,
            "osiemsetny": 800,
            "dziewięćsetny": 900,
        }
        self.multipliers_ordinal = {
            "tysięczny": 1_000,
            "milionowy": 1_000_000,
            "miliardowy": 1_000_000_000,
            "bilionowy": 1_000_000_000_000,
        }

        self.decimals = {*self.ones, *self.tens, *self.zeros}

        self.preceding_prefixers = {
            "minus": "-",
            "plus": "+",
        }
        self.currencies = {
            # Polish złoty / grosz
            "złoty": "zł",
            "złote": "zł",
            "złotych": "zł",
            "złotego": "zł",
            "zł": "zł",
            "pln": "zł",
            "złotówka": "zł",
            "grosz": "gr",
            "grosze": "gr",
            "groszy": "gr",
            "grosza": "gr",
            "gr": "gr",
            # euro / cent
            "euro": "€",
            "eur": "€",
            "cent": "¢",
            "centy": "¢",
            "centów": "¢",
            "centa": "¢",
            "¢": "¢",
            # dolar
            "dolar": "$",
            "dolary": "$",
            "dolarów": "$",
            "dolara": "$",
            "usd": "$",
            # funt (pound sterling)
            "funt": "£",
            "funty": "£",
            "funtów": "£",
            "gbp": "£",
            # currency symbols (canonical)
            "€": "€",
            "$": "$",
            "£": "£",
        }
        self.suffixers = {
            "procent": "%",
            "procenta": "%",
        }
        self.specials = {"przecinek", "kropka"}
        self.conjunctions = {"i"}
        self.prefixes = set(self.preceding_prefixers.values())

        self.words = {
            key
            for mapping in [
                self.zeros,
                self.ones,
                self.tens,
                self.hundreds,
                self.multipliers,
                self.ones_ordinal,
                self.tens_ordinal,
                self.hundreds_ordinal,
                self.multipliers_ordinal,
                self.preceding_prefixers,
                self.currencies,
                self.suffixers,
                self.specials,
                self.conjunctions,
            ]
            for key in mapping
        }

        # lemma -> value lookups used to canonicalize declined forms
        self.cardinal_lemmas = set(self.ones) | set(self.tens) | set(self.hundreds)
        self.multiplier_lemmas = set(self.multipliers)
        self.ordinal_lemmas = (
            set(self.ones_ordinal)
            | set(self.tens_ordinal)
            | set(self.hundreds_ordinal)
            | set(self.multipliers_ordinal)
        )
        self.ordinal_values = {
            **self.ones_ordinal,
            **self.tens_ordinal,
            **self.hundreds_ordinal,
            **self.multipliers_ordinal,
        }

        # feminine/neuter cardinal forms used as fraction numerators
        self.fraction_numerators = {
            "jedna": 1,
            "dwie": 2,
            "trzy": 3,
            "cztery": 4,
            "pięć": 5,
            "sześć": 6,
            "siedem": 7,
            "osiem": 8,
            "dziewięć": 9,
            "dziesięć": 10,
        }
        # non-number words whose declined forms we canonicalize
        self.percent_lemmas = {"procent"}
        # colloquial currency nouns
        self.currency_lemmas = {"złotówka"}
        # month lemmas -> number string (for full-date normalization)
        self.month_lemmas = {
            "styczeń": "1",
            "luty": "2",
            "marzec": "3",
            "kwiecień": "4",
            "maj": "5",
            "czerwiec": "6",
            "lipiec": "7",
            "sierpień": "8",
            "wrzesień": "9",
            "październik": "10",
            "listopad": "11",
            "grudzień": "12",
            # abbreviated months (Morfeusz-independent, common in refs)
            "sty": "1",
            "lut": "2",
            "mar": "3",
            "kwi": "4",
            "cze": "6",
            "lip": "7",
            "sie": "8",
            "wrz": "9",
            "paź": "10",
            "lis": "11",
            "gru": "12",
        }

        # --- ASCII-folded variants (support diacritic-less ASR output) ---------------
        # e.g. "czterdzieści" -> "czterdziesci", "pięć" -> "piec", "pięćset" -> "piecset"
        # keep original lemma sets for generating declined ASCII map (avoid generating from
        # ASCII collisions like "piec" (stove) vs "pięć" (5))
        _orig_cardinal = set(self.ones) | set(self.tens) | set(self.hundreds)
        _orig_multiplier = set(self.multipliers)
        _orig_ordinal = (
            set(self.ones_ordinal)
            | set(self.tens_ordinal)
            | set(self.hundreds_ordinal)
            | set(self.multipliers_ordinal)
        )
        _orig_percent = set(self.percent_lemmas)
        _orig_currency = set(self.currency_lemmas)
        _orig_month = set(self.month_lemmas)
        self._expand_ascii_variants()
        # re-derive composite sets after expansion
        self._rebuild_composite_sets()

        self.lemmatizer = PolishLemmatizer()
        self._canon_cache: dict[str, str] = {}
        # map stripped declined forms -> original base lemma (for diacritic-less declensions)
        # use class-level cache to avoid rebuilding via Morfeusz.generate on every instance
        _all_orig_bases = (
            _orig_cardinal
            | _orig_multiplier
            | _orig_ordinal
            | _orig_percent
            | _orig_currency
            | _orig_month
        )
        self._declined_ascii_map = self._get_or_build_declined_map(_all_orig_bases, self.lemmatizer)

    def _expand_ascii_variants(self) -> None:
        """Expand lexicons with ASCII-folded variants for diacritic-less ASR."""
        self.zeros = with_ascii_variants_set(self.zeros)
        self.ones = with_ascii_variants(self.ones)
        self.tens = with_ascii_variants(self.tens)
        self.hundreds = with_ascii_variants(self.hundreds)
        self.multipliers = with_ascii_variants(self.multipliers)
        self.ones_ordinal = with_ascii_variants(self.ones_ordinal)
        self.tens_ordinal = with_ascii_variants(self.tens_ordinal)
        self.hundreds_ordinal = with_ascii_variants(self.hundreds_ordinal)
        self.multipliers_ordinal = with_ascii_variants(self.multipliers_ordinal)
        self.currencies = with_ascii_variants(self.currencies)
        self.suffixers = with_ascii_variants(self.suffixers)
        self.specials = with_ascii_variants_set(self.specials)
        self.conjunctions = with_ascii_variants_set(self.conjunctions)
        self.fraction_numerators = with_ascii_variants(self.fraction_numerators)
        self.month_lemmas = with_ascii_variants(self.month_lemmas)
        self.percent_lemmas = with_ascii_variants_set(self.percent_lemmas)
        self.currency_lemmas = with_ascii_variants_set(self.currency_lemmas)

    def _rebuild_composite_sets(self) -> None:
        """Re-derive sets that depend on expanded lexicons."""
        self.words = {
            key
            for mapping in [
                self.zeros,
                self.ones,
                self.tens,
                self.hundreds,
                self.multipliers,
                self.ones_ordinal,
                self.tens_ordinal,
                self.hundreds_ordinal,
                self.multipliers_ordinal,
                self.preceding_prefixers,
                self.currencies,
                self.suffixers,
                self.specials,
                self.conjunctions,
            ]
            for key in mapping
        }
        self.cardinal_lemmas = set(self.ones) | set(self.tens) | set(self.hundreds)
        self.multiplier_lemmas = set(self.multipliers)
        self.ordinal_lemmas = (
            set(self.ones_ordinal)
            | set(self.tens_ordinal)
            | set(self.hundreds_ordinal)
            | set(self.multipliers_ordinal)
        )
        self.ordinal_values = {
            **self.ones_ordinal,
            **self.tens_ordinal,
            **self.hundreds_ordinal,
            **self.multipliers_ordinal,
        }
        self.decimals = {*self.ones, *self.tens, *self.zeros}
        self.prefixes = set(self.preceding_prefixers.values())

    @classmethod
    def _get_or_build_declined_map(
        cls, bases: set[str], lemmatizer: PolishLemmatizer
    ) -> dict[str, str]:
        """Return cached declined ASCII map or build via Morfeusz.generate."""
        if cls._DECLINED_ASCII_CACHE is not None:
            return copy.deepcopy(cls._DECLINED_ASCII_CACHE)
        declined: dict[str, str] = {}
        if lemmatizer.morf is not None:
            for base in bases:
                try:
                    forms = lemmatizer.generate(base)
                except Exception:
                    logger.debug("generate failed for %r", base)
                    continue
                for surface, lemma, _tag in forms:
                    clean_lemma = lemma.split(":")[0]
                    if clean_lemma != base:
                        continue
                    stripped_form = strip_diacritics(surface)
                    if stripped_form != surface and stripped_form not in declined:
                        declined[stripped_form] = base
        with cls._DECLINED_LOCK:
            if cls._DECLINED_ASCII_CACHE is None:
                cls._DECLINED_ASCII_CACHE = copy.deepcopy(declined)
        return declined

    def _canonicalize(self, word: str) -> str:
        """Map a declined number word to its base lemma, if it is one."""
        if word in self.words:
            return word
        if self._POLISH_WORD_RE.fullmatch(word) is None:
            return word
        cached = self._canon_cache.get(word)
        if cached is not None:
            return cached

        candidates: dict[str, str | None] = {
            "num": None,
            "adj": None,
            "subst": None,
            "percent": None,
            "currency": None,
        }
        for base, pos in self.lemmatizer.analyse(word):
            if pos == "num" and base in self.cardinal_lemmas:
                candidates["num"] = base
            elif pos == "adj" and base in self.ordinal_lemmas:
                candidates["adj"] = base
            elif pos == "adj" and base in self.cardinal_lemmas:
                # Polish numerals like "jeden" are tagged as adj in some forms (e.g. "jednej")
                candidates["num"] = base
            elif pos == "subst" and base in self.multiplier_lemmas:
                candidates["subst"] = base
            elif pos == "subst" and base in self.percent_lemmas:
                candidates["percent"] = base
            elif pos == "subst" and base in self.currency_lemmas:
                candidates["currency"] = base

        result = word
        for key in ("num", "adj", "subst", "percent", "currency"):
            if candidates[key] is not None:
                result = candidates[key]  # type: ignore[assignment]
                break
        # fallback: diacritic-less declined form (e.g. "pieciu" -> "pięć")
        if result == word and word in self._declined_ascii_map:
            result = self._declined_ascii_map[word]
        self._canon_cache[word] = result
        return result

    def process_words(self, words: list[str]) -> Iterator[str]:
        prefix: str | None = None
        value: str | int | None = None
        ordinal = False
        skip = False

        def to_fraction(s: str) -> Fraction | None:
            try:
                return Fraction(s)
            except ValueError:
                return None

        def output(result: str | int) -> str:
            nonlocal prefix, value, ordinal
            result = str(result)
            if prefix is not None:
                result = prefix + result
            if ordinal:
                result += "."
            value = None  # type: ignore[assignment]
            prefix = None
            ordinal = False
            return result

        if len(words) == 0:
            return

        for prev, current, next in _windowed_3([None, *words, None]):  # type: ignore[list-item]
            if skip:
                skip = False
                continue
            if current is None:  # type narrowing for mypy; never happens for non-empty words
                continue

            next_is_numeric = next is not None and self._NUMERIC_RE.match(next) is not None
            has_prefix = current[0] in self.prefixes
            current_without_prefix = current[1:] if has_prefix else current  # type: ignore[index]

            if self._NUMERIC_RE.match(current_without_prefix):  # type: ignore[arg-type]
                # arabic numbers (potentially with signs/currency prefixes)
                f = to_fraction(current_without_prefix)  # type: ignore[arg-type]
                assert f is not None
                if value is not None:
                    if isinstance(value, str) and value.endswith("."):
                        value = str(value) + str(current)
                        continue
                    else:
                        yield output(value)  # type: ignore[arg-type]

                prefix = current[0] if has_prefix else prefix  # type: ignore[index]
                value = f.numerator if f.denominator == 1 else current_without_prefix
            elif current not in self.words:
                # non-numeric words
                if value is not None:
                    yield output(value)  # type: ignore[arg-type]
                yield output(current)  # type: ignore[arg-type]
            elif current in self.zeros:
                value = str(value or "") + "0"
            elif current in self.ones:
                ones = self.ones[current]
                if value is None:
                    value = ones
                elif isinstance(value, str) or prev in self.ones:
                    if prev in self.tens and ones < 10:
                        assert value[-1] == "0"  # type: ignore[index]
                        value = value[:-1] + str(ones)  # type: ignore[index, union-attr]
                    else:
                        value = str(value) + str(ones)
                elif ones < 10:
                    if value % 10 == 0:
                        value += ones
                    else:
                        value = str(value) + str(ones)
                else:  # eleven to nineteen
                    if value % 100 == 0:
                        value += ones
                    else:
                        value = str(value) + str(ones)
            elif current in self.tens:
                tens = self.tens[current]
                if value is None:
                    value = tens
                elif isinstance(value, str):
                    value = str(value) + str(tens)
                elif value % 100 == 0:
                    value += tens
                else:
                    # invalid magnitude order ("dziesięć dwadzieścia") -> separate numeral
                    yield output(value)  # type: ignore[arg-type]
                    value = tens
            elif current in self.hundreds:
                hundred = self.hundreds[current]
                if value is None:
                    value = hundred
                elif isinstance(value, str):
                    value = str(value) + str(hundred)
                elif value % 1000 == 0:
                    value += hundred
                elif value >= 100:
                    # long digit-string reading ("trzysta czterdzieści osiemset ...")
                    value = str(value) + str(hundred)
                else:
                    # invalid magnitude order ("dziesięć pięćset") -> separate numeral
                    yield output(value)  # type: ignore[arg-type]
                    value = hundred
            elif current in self.multipliers:
                multiplier = self.multipliers[current]
                if value is None:
                    value = multiplier
                elif isinstance(value, str) or value == 0:
                    f = to_fraction(str(value))  # type: ignore[arg-type]
                    p = f * multiplier if f is not None else None  # type: ignore[operator]
                    if f is not None and p.denominator == 1:  # type: ignore[union-attr]
                        value = p.numerator  # type: ignore[union-attr]
                    else:
                        yield output(value)  # type: ignore[arg-type]
                        value = multiplier
                else:
                    before = value // 1000 * 1000
                    residual = value % 1000
                    value = before + residual * multiplier
            elif current in self.ones_ordinal:
                ones = self.ones_ordinal[current]
                ordinal = True
                if value is None:
                    yield output(ones)
                elif isinstance(value, str):
                    yield output(str(value) + str(ones))
                elif ones < 10:
                    if value % 10 == 0:
                        yield output(str(value + ones))
                    else:
                        yield output(str(value) + str(ones))
                else:  # 11-19
                    if value % 100 == 0:
                        yield output(str(value + ones))
                    else:
                        yield output(str(value) + str(ones))
            elif current in self.tens_ordinal:
                tens = self.tens_ordinal[current]
                ordinal = True
                if value is None:
                    value = tens
                elif isinstance(value, str):
                    value = str(value) + str(tens)
                else:
                    if value % 100 == 0:
                        value += tens
                    else:
                        value = str(value) + str(tens)
            elif current in self.hundreds_ordinal:
                # ordinal hundred; composes with a preceding multiplier
                # ("tysiąc dziewięćsetny" -> "1900.")
                hundred = self.hundreds_ordinal[current]
                ordinal = True
                if value is None:
                    yield output(hundred)
                elif isinstance(value, str):
                    yield output(str(value) + str(hundred))
                else:
                    if value % 1000 == 0:
                        value += hundred
                    else:
                        value = str(value) + str(hundred)
            elif current in self.multipliers_ordinal:
                if value is not None:
                    yield output(value)  # type: ignore[arg-type]
                ordinal = True
                yield output(self.multipliers_ordinal[current])
            elif current in self.preceding_prefixers:
                # apply prefix (minus, plus, etc.) if it precedes a number
                if value is not None:
                    yield output(value)  # type: ignore[arg-type]
                if next in self.words or next_is_numeric:
                    prefix = self.preceding_prefixers[current]
                else:
                    yield output(current)  # type: ignore[arg-type]
            elif current in self.currencies:
                # currency word/abbreviation follows the amount -> suffix
                if value is not None:
                    yield output(str(value) + " " + self.currencies[current])
                elif current.isalpha():
                    yield output(current)  # type: ignore[arg-type]
                # else: stray currency symbol with no amount -> drop
            elif current in self.suffixers:
                # apply suffix symbols (procent -> '%')
                if value is not None:
                    yield output(str(value) + self.suffixers[current])
                else:
                    yield output(current)  # type: ignore[arg-type]
            elif current in self.specials:
                # decimal separators ("przecinek", "kropka")
                if next in self.decimals or next_is_numeric:
                    value = str(value or "") + "."
                else:
                    if value is not None:
                        yield output(value)  # type: ignore[arg-type]
                    yield output(current)  # type: ignore[arg-type]
            elif current in self.conjunctions:
                # drop "i" only when it joins two numeric tokens
                # (e.g. "sto złotych i pięćdziesiąt groszy" -> "100 zł 50 gr")
                prev_numeric = prev is not None and (
                    prev in self.words or self._NUMERIC_PREFIX_RE.match(prev or "") is not None
                )
                next_numeric = next in self.words or next_is_numeric
                if prev_numeric and next_numeric:
                    if value is not None:
                        yield output(value)  # type: ignore[arg-type]
                else:
                    if value is not None:
                        yield output(value)  # type: ignore[arg-type]
                    yield output(current)  # type: ignore[arg-type]
            else:
                raise ValueError(f"Unexpected token: {current}")

        if value is not None:
            yield output(value)

    def preprocess(self, s: str) -> str:
        # ellipsis marks a sentence boundary: keep the boundary (so adjacent
        # spelled-out numbers are not merged) but drop the "." later
        s = re.sub(r"\s*(?:\.\s*){2,}|\s*…\s*", " . ", s)
        # "i pół" -> "przecinek pięć" (two and a half -> 2.5) – also ASCII "pol"
        s = re.sub(r"\bi\s+(?:pół|pol)\b", "przecinek pięć", s)
        s = re.sub(r"\b(?:półtora|poltora)\b", "jeden przecinek pięć", s)
        s = re.sub(r"\b(?:półtorej|poltorej)\b", "jeden przecinek pięć", s)
        # standalone "pół" (half) -> "0.5"
        s = re.sub(r"\b(?:pół|pol)\b", "zero przecinek pięć", s)

        # normalize currency symbols to follow the amount ("€10" -> "10 €",
        # "10€" -> "10 €", "$1.50" -> "1.50 $")
        s = re.sub(r"([€$£¢])\s*(\d+(?:\.\d+)?)", r"\2 \1", s)
        s = re.sub(r"(\d+(?:\.\d+)?)\s*([€$£¢])", r"\1 \2", s)

        # put a space at number/letter boundary
        s = re.sub(r"([^\W\d_])([0-9])", r"\1 \2", s)
        s = re.sub(r"([0-9])([^\W\d_])", r"\1 \2", s)

        return s

    def postprocess(self, s: str) -> str:
        # Normalize decimal fractions expressed as "21 i 5/10" (from
        # "dwadzieścia jeden i pięć dziesiątych") to decimal "21.5"
        # for equivalence with "21.5" reference (WER on jednostki).
        # Only decimal denominators (10,100,1000) and half (1/2) for 0.5==1/2.
        def _is_decimal_den(den: int) -> bool:
            s_den = str(den)
            return s_den[0] == "1" and all(c == "0" for c in s_den[1:])

        def _fraction_to_decimal_str(num: int, den: int, int_part: str | None = None) -> str | None:
            if num >= den or num == 0:
                return None
            # decimal denominators (10,100,1000) or half 1/2
            is_decimal = _is_decimal_den(den)
            is_half = num == 1 and den == 2
            if not (is_decimal or is_half):
                return None
            if is_half:
                # 1/2 -> 0.5, 21 i 1/2 -> 21.5
                return f"{int_part}.5" if int_part is not None else "0.5"
            # power-of-10 decimal
            k = len(str(den)) - 1
            frac_str = f"{num:0{k}d}"
            dec = f"{int_part}.{frac_str}" if int_part is not None else f"0.{frac_str}"
            dec = dec.rstrip("0").rstrip(".")
            if dec.endswith("."):
                dec += "0"
            if "." not in dec:
                dec = f"{int_part if int_part is not None else '0'}.0"
            return dec

        def _decimal_repl(m: re.Match[str]) -> str:
            int_part = m.group(1)
            num = int(m.group(2))
            den = int(m.group(3))
            dec = _fraction_to_decimal_str(num, den, int_part)
            if dec is None:
                return m.group(0)
            return dec

        s = re.sub(r"\b(\d+)\s+i\s+(\d+)/(\d+)\b", _decimal_repl, s)

        def _standalone_frac_repl(m: re.Match[str]) -> str:
            num = int(m.group(1))
            den = int(m.group(2))
            dec = _fraction_to_decimal_str(num, den, None)
            if dec is None:
                return m.group(0)
            return dec

        s = re.sub(r"\b(\d+)/(\d+)\b", _standalone_frac_repl, s)
        return s

    def _fraction_denominator(self, word: str) -> int | None:
        """Return the ordinal value of `word` if it is a fraction denominator."""
        for base, pos in self.lemmatizer.analyse(word):
            if pos == "adj" and base in self.ordinal_values:
                value = self.ordinal_values[base]
                if value >= 2:
                    return value
        # fallback for ASCII-folded declensions (e.g. "piate" -> "piąty")
        mapped = self._declined_ascii_map.get(word)
        if mapped is not None and mapped in self.ordinal_values:
            value = self.ordinal_values[mapped]
            if value >= 2:
                return value
        # also handle bare ASCII ordinal base itself (e.g., "piaty")
        if word in self.ordinal_values:
            value = self.ordinal_values[word]
            if value >= 2:
                return value
        return None

    def _fraction_numerator_value(self, word: str) -> int | None:
        """Return cardinal value for fraction numerator via Morfeusz.

        Supports declined forms (e.g. "jednej" -> 1, "dwóch" -> 2) and
        broader range (1-19, 20-90, 100-900) via Morfeusz lemmatization.
        Hundreds are included for decimal fractions like
        "czterysta pięćdziesiąt sześć tysięcznych" -> 456/1000.
        Collision with ordinal compounds (e.g. "sto dwudziesty" -> 100/20)
        is avoided by numerator < denominator check in caller.
        """
        # direct feminine dict (fast path, includes ASCII variants)
        if word in self.fraction_numerators:
            return self.fraction_numerators[word]
        # direct cardinal maps (covers 1-19, tens, hundreds, zero + ASCII)
        if word in self.ones:
            return self.ones[word]
        if word in self.tens:
            return self.tens[word]
        if word in self.hundreds:
            return self.hundreds[word]
        if word in self.zeros:
            return 0
        # via Morfeusz – handle declensions like "jednej" -> "jeden" (adj)
        for base, _pos in self.lemmatizer.analyse(word):
            if base in self.fraction_numerators:
                return self.fraction_numerators[base]
            if base in self.ones:
                return self.ones[base]
            if base in self.tens:
                return self.tens[base]
            if base in self.hundreds:
                return self.hundreds[base]
            if base in self.zeros:
                return 0
        # fallback diacritic-less declined map (e.g. "pieciu" -> "pięć")
        mapped = self._declined_ascii_map.get(word)
        if mapped is not None:
            if mapped in self.fraction_numerators:
                return self.fraction_numerators[mapped]
            if mapped in self.ones:
                return self.ones[mapped]
            if mapped in self.tens:
                return self.tens[mapped]
            if mapped in self.hundreds:
                return self.hundreds[mapped]
            if mapped in self.zeros:
                return 0
        return None

    def _parse_fraction_numerator(self, words: list[str], start: int) -> tuple[int, int] | None:
        """Parse 1-3 word cardinal numerator at words[start:].

        Handles "dwadzieścia trzy" -> 23, "czterysta pięćdziesiąt sześć" -> 456
        for fractions. Returns (value, length) or None.
        """
        n = len(words)
        # try 3-word hundreds + tens + ones (e.g. "czterysta pięćdziesiąt sześć" -> 456)
        if start + 2 < n:
            v1 = self._fraction_numerator_value(words[start])
            v2 = self._fraction_numerator_value(words[start + 1])
            v3 = self._fraction_numerator_value(words[start + 2])
            if v1 is not None and v2 is not None and v3 is not None:
                # hundreds 100-900 + tens 10-90 + ones 1-9
                if v1 in {100, 200, 300, 400, 500, 600, 700, 800, 900}:
                    if (
                        v2
                        in {10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 30, 40, 50, 60, 70, 80, 90}
                        and 1 <= v3 <= 9
                    ):
                        # need tens+ones distinct: e.g. 400+50+6, but also 100+20+3
                        # also handle 100+11+? but 11 already includes ones, so avoid double
                        if v2 < 20 and v3 < 10:
                            # v2 is 10-19, v3 would be extra ones -> invalid (e.g. 10 + 1)
                            pass
                        else:
                            return v1 + v2 + v3, 3
                    # hundreds + tens (e.g. "czterysta pięćdziesiąt" -> 450)
                    if v2 in {
                        10,
                        11,
                        12,
                        13,
                        14,
                        15,
                        16,
                        17,
                        18,
                        19,
                        20,
                        30,
                        40,
                        50,
                        60,
                        70,
                        80,
                        90,
                    } and v3 in {1, 2, 3, 4, 5, 6, 7, 8, 9}:
                        # already handled above as 3-word, but we try 3-word only if both
                        pass
                # also try hundreds + ones (e.g. "sto pięć" -> 105)
                if (
                    v1 in {100, 200, 300, 400, 500, 600, 700, 800, 900}
                    and 1 <= v3 <= 9
                    and v2 in {1, 2, 3, 4, 5, 6, 7, 8, 9}
                ):
                    # this is actually 2-word hundreds+ones, handled below, but 3-word with middle tens missing
                    pass
        # try 2-word
        if start + 1 < n:
            v1 = self._fraction_numerator_value(words[start])
            v2 = self._fraction_numerator_value(words[start + 1])
            if v1 is not None and v2 is not None:
                # tens 20-90 + ones 1-9
                if v1 in {20, 30, 40, 50, 60, 70, 80, 90} and 1 <= v2 <= 9:
                    return v1 + v2, 2
                # hundreds 100-900 + tens 10-90 or ones 1-9 or teens 10-19
                if v1 in {100, 200, 300, 400, 500, 600, 700, 800, 900} and v2 in {
                    1,
                    2,
                    3,
                    4,
                    5,
                    6,
                    7,
                    8,
                    9,
                    10,
                    11,
                    12,
                    13,
                    14,
                    15,
                    16,
                    17,
                    18,
                    19,
                    20,
                    30,
                    40,
                    50,
                    60,
                    70,
                    80,
                    90,
                }:
                    return v1 + v2, 2
                # hundreds + ones with tens missing already covered above, but also try 100+5
                # tens + ones already handled, also try teens + ones? not needed
        # try 3-word again for hundreds+tens+ones where we missed: do generic sum check
        if start + 2 < n:
            vals = [self._fraction_numerator_value(words[start + i]) for i in range(3)]
            if all(v is not None for v in vals):  # type: ignore[arg-type]
                v1, v2, v3 = vals  # type: ignore[assignment]
                # allow sum if values are descending magnitude and <1000
                # e.g. 400+50+6, 100+20+3, 200+11+? but 11 includes ones
                # simple check: v1 is hundreds, v2 is tens/10-19, v3 is ones, and v1>v2>v3
                if (
                    v1 in {100, 200, 300, 400, 500, 600, 700, 800, 900}
                    and v2
                    in {10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 30, 40, 50, 60, 70, 80, 90}
                    and v3 in {1, 2, 3, 4, 5, 6, 7, 8, 9}
                    and (v1 > v2 > v3 or (v1 > v2 and v2 >= 10 and v3 < 10))
                    and not (10 <= v2 <= 19 and v3 < 10)
                ):
                    return v1 + v2 + v3, 3
                # also 100+20+3 case already, but ensure
        # single word
        v = self._fraction_numerator_value(words[start])
        if v is not None:
            return v, 1
        return None

    def _convert_fractions(self, words: list[str]) -> list[str]:
        """Turn "jedna trzecia" -> "1/3", "trzy czwarte" -> "3/4", etc.

        Uses Morfeusz for declined numerators (e.g. "jednej trzeciej" -> "1/3",
        "dwóch trzecich" -> "2/3") and supports broader range (11-19) and
        multi-word numerators like "dwadzieścia trzy setne" -> "23/100".
        Requires numerator < denominator to avoid colliding with ordinal
        compounds like "sto dwudziesty" -> 120 (not 100/20).
        """
        result: list[str] = []
        i = 0
        n = len(words)
        while i < n:
            parsed = self._parse_fraction_numerator(words, i)
            if parsed is not None and i + parsed[1] < n:
                numerator, length = parsed
                denominator = self._fraction_denominator(words[i + length])
                if denominator is not None and numerator < denominator:
                    result.append(f"{numerator}/{denominator}")
                    i += length + 1
                    continue
            result.append(words[i])
            i += 1
        return result

    def __call__(self, s: str) -> str:
        s = self.preprocess(s)
        words = self._convert_fractions(s.split())
        words = [self._canonicalize(w) for w in words]
        s = " ".join(word for word in self.process_words(words) if word is not None and word != ".")
        s = self.postprocess(s)
        return s

polish_whisper_normalizer.time.PolishTimeNormalizer

Convert spoken times into HH:MM format:

  • "piąta trzydzieści" -> "5:30"
  • "dwudziesta piętnaście" -> "20:15"
  • "wpół do ósmej" -> "7:30"
  • "za piętnaście ósma" -> "7:45"
  • "piętnaście po piątej" -> "5:15"
  • "godzina piętnasta trzydzieści" -> "15:30"
  • "północ" -> "0:00", "południe" -> "12:00"
Source code in src/polish_whisper_normalizer/time.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
class PolishTimeNormalizer:
    """
    Convert spoken times into HH:MM format:

    - "piąta trzydzieści" -> "5:30"
    - "dwudziesta piętnaście" -> "20:15"
    - "wpół do ósmej" -> "7:30"
    - "za piętnaście ósma" -> "7:45"
    - "piętnaście po piątej" -> "5:15"
    - "godzina piętnasta trzydzieści" -> "15:30"
    - "północ" -> "0:00", "południe" -> "12:00"
    """

    # class-level cache: built once, reused by all instances
    _CACHE: dict[str, object] | None = None
    _CACHE_LOCK = threading.Lock()

    def __init__(self) -> None:
        if PolishTimeNormalizer._CACHE is not None:
            # deepcopy mutable containers to avoid cross-instance mutation
            cached = PolishTimeNormalizer._CACHE
            for key, value in cached.items():
                if isinstance(value, dict | set | list):
                    self.__dict__[key] = copy.deepcopy(value)
                elif hasattr(value, "pattern"):  # compiled regex
                    self.__dict__[key] = value
                else:
                    self.__dict__[key] = value
            return

        self.hours = {
            "pierwsza": 1,
            "druga": 2,
            "trzecia": 3,
            "czwarta": 4,
            "piąta": 5,
            "szósta": 6,
            "siódma": 7,
            "ósma": 8,
            "dziewiąta": 9,
            "dziesiąta": 10,
            "jedenasta": 11,
            "dwunasta": 12,
            "trzynasta": 13,
            "czternasta": 14,
            "piętnasta": 15,
            "szesnasta": 16,
            "siedemnasta": 17,
            "osiemnasta": 18,
            "dziewiętnasta": 19,
            "dwudziesta": 20,
            "dwudziesta pierwsza": 21,
            "dwudziesta druga": 22,
            "dwudziesta trzecia": 23,
            "dwudziesta czwarta": 24,
        }
        self.hours_gen = {
            "pierwszej": 1,
            "drugiej": 2,
            "trzeciej": 3,
            "czwartej": 4,
            "piątej": 5,
            "szóstej": 6,
            "siódmej": 7,
            "ósmej": 8,
            "dziewiątej": 9,
            "dziesiątej": 10,
            "jedenastej": 11,
            "dwunastej": 12,
            "trzynastej": 13,
            "czternastej": 14,
            "piętnastej": 15,
            "szesnastej": 16,
            "siedemnastej": 17,
            "osiemnastej": 18,
            "dziewiętnastej": 19,
            "dwudziestej": 20,
            "dwudziestej pierwszej": 21,
            "dwudziestej drugiej": 22,
            "dwudziestej trzeciej": 23,
            "dwudziestej czwartej": 24,
        }

        self.minutes = self._build_minutes()
        self.minutes_ordinal = self._build_minutes_ordinal()
        # expand with ASCII variants for diacritic-less ASR
        self.hours = with_ascii_variants(self.hours)
        self.hours_gen = with_ascii_variants(self.hours_gen)
        self.minutes = with_ascii_variants(self.minutes)
        self.minutes_ordinal = with_ascii_variants(self.minutes_ordinal)

        hours_alt = self._alternation(self.hours)
        hours_gen_alt = self._alternation(self.hours_gen)
        minutes_alt = self._alternation(self.minutes)
        minutes_ordinal_alt = self._alternation(self.minutes_ordinal)
        # literals with diacritics also need ASCII variants (diacritic-less ASR)
        wpol_pat = r"(?:wpół|wpol)"
        # build midnight/noon forms via Morfeusz (all declensions) + ASCII
        polnoc_forms, poludnie_forms = self._build_midnight_forms()
        # fallback hardcoded if Morfeusz unavailable (should not happen in prod)
        if not polnoc_forms:
            polnoc_forms = {
                "północ",
                "północy",
                "północą",
                "północe",
                "północom",
                "północami",
                "północach",
            }
        if not poludnie_forms:
            poludnie_forms = {
                "południe",
                "południa",
                "południowi",
                "południu",
                "południem",
                "południach",
                "południom",
                "południami",
            }
        # expand ASCII for midnight sets (strip_diacritics)
        polnoc_forms_expanded: set[str] = set()
        for f in polnoc_forms:
            polnoc_forms_expanded.add(f)
            polnoc_forms_expanded.add(strip_diacritics(f))
        poludnie_forms_expanded: set[str] = set()
        for f in poludnie_forms:
            poludnie_forms_expanded.add(f)
            poludnie_forms_expanded.add(strip_diacritics(f))
        # build alternations sorted by length desc
        polnoc_alt = self._alternation_set(polnoc_forms_expanded)
        poludnie_alt = self._alternation_set(poludnie_forms_expanded)
        # keep for __call__ (geographic vs time)
        self._polnoc_forms = polnoc_forms_expanded
        self._poludnie_forms = poludnie_forms_expanded
        self._polnoc_alt = polnoc_alt
        self._poludnie_alt = poludnie_alt

        # legacy single-form patterns kept for reference
        self._polnoc_pat = r"(?:północ|polnoc)"
        self._poludnie_pat = r"(?:południe|poludnie)"

        self._re_wpol = re.compile(r"\b" + wpol_pat + r"\s+do\s+(" + hours_gen_alt + r")\b")
        self._re_za = re.compile(r"\bza\s+(" + minutes_alt + r")\s+(" + hours_alt + r")\b")
        self._re_po = re.compile(r"\b(" + minutes_alt + r")\s+po\s+(" + hours_gen_alt + r")\b")
        self._re_godzina_min = re.compile(
            r"\bgodzina\s+(" + hours_alt + r")\s+(" + minutes_alt + r")\b"
        )
        self._re_godzina = re.compile(r"\bgodzina\s+(" + hours_alt + r")\b")
        self._re_o_godzinie_ord = re.compile(
            r"\bo\s+godzinie\s+(" + hours_gen_alt + r")\s+(" + minutes_ordinal_alt + r")\b"
        )
        self._re_o_godzinie_hour = re.compile(
            r"\bo\s+godzinie\s+(" + hours_gen_alt + r")\b(?!\s*(?:" + minutes_ordinal_alt + r")\b)"
        )
        self._re_godzina_digits = re.compile(r"\bgodzina\s+(\d{1,2})[.:,](\d{2})\b")
        self._re_o_godzinie_digits = re.compile(r"\bo\s+godzinie\s+(\d{1,2})[.:,](\d{2})\b")
        self._re_godzina_digit_hour = re.compile(r"\bgodzina\s+(\d{1,2})\b(?!\s*[:.,]\d)")
        self._re_o_godzinie_digit_hour = re.compile(r"\bo\s+godzinie\s+(\d{1,2})\b(?!\s*[:.,]\d)")
        self._re_hour_min = re.compile(r"\b(" + hours_alt + r")\s+(" + minutes_alt + r")\b")
        self._re_hour_gen_min = re.compile(r"\b(" + hours_gen_alt + r")\s+(" + minutes_alt + r")\b")
        # "o piątej" -> "o 5:00" (genitive hour, not followed by a word)
        self._re_o_hour = re.compile(r"\bo\s+(" + hours_gen_alt + r")\b(?!\s*[a-ząćęłńóśźż])")
        # time-of-day markers (Morfeusz-inspired, covers rano + wieczorem/nocy/południu)
        marker_phrases = [
            "rano",
            "wieczorem",
            "w nocy",
            "nocą",
            "nad ranem",
            "w dzień",
        ]
        marker_set: set[str] = set()
        for phrase in marker_phrases:
            phrase_lc = phrase.lower()
            marker_set.add(phrase_lc)
            stripped = strip_diacritics(phrase_lc)
            if stripped != phrase_lc:
                marker_set.add(stripped)
        # also ensure ascii variants for phrases containing diacritics are added
        # e.g. "w dzień" -> "w dzien"
        marker_alt = self._alternation_set(marker_set)
        self._marker_alt = marker_alt
        # hour + marker ("piąta rano", "ósma wieczorem" -> "5:00 rano")
        # also genitive hour + marker ("o piątej rano" is handled separately)
        self._re_hour_marker = re.compile(r"\b(" + hours_alt + r")\s+(" + marker_alt + r")\b")
        self._re_o_hour_marker = re.compile(
            r"\bo\s+(" + hours_gen_alt + r")\s+(" + marker_alt + r")\b"
        )
        # keep legacy rano regexes for backwards compat (they are now subset of marker)
        self._re_hour_rano = re.compile(r"\b(" + hours_alt + r")\s+rano\b")
        self._re_o_hour_rano = re.compile(r"\bo\s+(" + hours_gen_alt + r")\s+rano\b")
        # variants with an explicit "minut(ę/y)" word – include ASCII "minutę" -> "minute"
        self._re_za_minut = re.compile(
            r"\bza\s+(" + minutes_alt + r")\s+minut(?:a|ę|e|y)?\s+(" + hours_alt + r")\b"
        )
        self._re_po_minut = re.compile(
            r"\b(" + minutes_alt + r")\s+minut(?:a|ę|e|y)?\s+po\s+(" + hours_gen_alt + r")\b"
        )
        # "od piątej do szóstej" -> "od 5:00 do 6:00"
        self._re_range = re.compile(
            r"\bod\s+(" + hours_gen_alt + r")\s+do\s+(" + hours_gen_alt + r")\b"
        )
        # midnight/noon regexes: contextual (prep + declined) + standalone nominative
        # only declined forms with appropriate preposition should become time; bare "północy" stays word (see test)
        # include ASCII-folded variants for prepositions (około -> okolo, była -> byla, etc.)
        time_prep = r"(o|przed|po|do|od|około|okolo|w|jest|była|byla|było|bylo|był|byl|były|byly)"
        self._re_polnoc_context = re.compile(r"\b" + time_prep + r"\s+(?:" + polnoc_alt + r")\b")
        self._re_poludnie_context = re.compile(
            r"\b" + time_prep + r"\s+(?:" + poludnie_alt + r")\b"
        )
        self._re_polnoc_nominative = re.compile(r"\b(?:północ|polnoc)\b")
        self._re_poludnie_nominative = re.compile(r"\b(?:południe|poludnie)\b")
        # keep broad regex for any internal use but not in pipeline (avoid bare declined conversion)
        self._re_polnoc = re.compile(r"\b(?:" + polnoc_alt + r")\b")
        self._re_poludnie = re.compile(r"\b(?:" + poludnie_alt + r")\b")
        # geographic protection: preposition + północ/południe (any declined form)
        geo_preps = r"(?:na|z|od|do|w|ku|kierunek|strona|część|czesc|północno|polnocno|południowo|poludniowo|pod)"
        self._re_geo_polnoc = re.compile(r"\b" + geo_preps + r"\s+(?:" + polnoc_alt + r")\b")
        self._re_geo_poludnie = re.compile(r"\b" + geo_preps + r"\s+(?:" + poludnie_alt + r")\b")
        # broader geographic: "na północy", "z północy", "od północy" etc already covered,
        # but also "północny" adjectives should stay words – no conversion via midnight regex (word boundary)

        # cache for next instance (thread-safe)
        with PolishTimeNormalizer._CACHE_LOCK:
            if PolishTimeNormalizer._CACHE is None:
                PolishTimeNormalizer._CACHE = dict(self.__dict__)

    @staticmethod
    def _build_midnight_forms() -> tuple[set[str], set[str]]:
        """Collect midnight/noon declined forms via Morfeusz generate."""
        try:
            from .lemmatizer import PolishLemmatizer

            lem = PolishLemmatizer()
            polnoc: set[str] = set()
            poludnie: set[str] = set()
            if lem.morf is not None:
                for base, target in (("północ", polnoc), ("południe", poludnie)):
                    try:
                        forms = lem.generate(base)
                    except Exception:
                        continue
                    for surf, lemma, _tag in forms:
                        if lemma.split(":")[0] != base:
                            # allow "północ:..." but ignore abbreviations like "pn"
                            if len(surf) <= 2:
                                continue
                            # still check base
                            continue
                        if len(surf) <= 2:  # skip abbrev "pn", "pd"
                            continue
                        pol = surf.lower()
                        target.add(pol)
                # ensure base forms present even if generate failed partially
                polnoc.add("północ")
                poludnie.add("południe")
                return polnoc, poludnie
        except Exception:
            pass
        return set(), set()

    @staticmethod
    def _ones_words() -> list[str]:
        return [
            "zero",
            "jeden",
            "dwa",
            "trzy",
            "cztery",
            "pięć",
            "sześć",
            "siedem",
            "osiem",
            "dziewięć",
        ]

    def _build_minutes(self) -> dict[str, int]:
        ones = self._ones_words()
        teens = {
            10: "dziesięć",
            11: "jedenaście",
            12: "dwanaście",
            13: "trzynaście",
            14: "czternaście",
            15: "piętnaście",
            16: "szesnaście",
            17: "siedemnaście",
            18: "osiemnaście",
            19: "dziewiętnaście",
        }
        tens = {
            20: "dwadzieścia",
            30: "trzydzieści",
            40: "czterdzieści",
            50: "pięćdziesiąt",
            60: "sześćdziesiąt",
            70: "siedemdziesiąt",
            80: "osiemdziesiąt",
            90: "dziewięćdziesiąt",
        }
        kwadrans = {"kwadrans": 15}

        def cardinal(n: int) -> str:
            if n < 10:
                return ones[n]
            if n < 20:
                return teens[n]
            t = n // 10 * 10
            o = n % 10
            if o == 0:
                return tens[t]
            return tens[t] + " " + ones[o]

        minutes: dict[str, int] = {}
        for m in range(60):
            minutes[cardinal(m)] = m
        for d in range(10):
            minutes["zero " + ones[d]] = d
        minutes.update(kwadrans)
        # feminine cardinal forms used with "minuta/minuty"
        minutes["jedna"] = 1
        minutes["dwie"] = 2
        return minutes

    @staticmethod
    def _build_minutes_ordinal() -> dict[str, int]:
        """Genitive feminine ordinals used for minutes ("szesnastej piątej" -> 16:05)."""
        units = [
            "pierwszej",
            "drugiej",
            "trzeciej",
            "czwartej",
            "piątej",
            "szóstej",
            "siódmej",
            "ósmej",
            "dziewiątej",
            "dziesiątej",
            "jedenastej",
            "dwunastej",
            "trzynastej",
            "czternastej",
            "piętnastej",
            "szesnastej",
            "siedemnastej",
            "osiemnastej",
            "dziewiętnastej",
        ]
        tens = {
            20: "dwudziestej",
            30: "trzydziestej",
            40: "czterdziestej",
            50: "pięćdziesiątej",
        }
        result: dict[str, int] = {}
        for m in range(1, 60):
            if m <= 19:
                result[units[m - 1]] = m
            else:
                t = m // 10 * 10
                o = m % 10
                if o == 0:
                    result[tens[t]] = m
                else:
                    result[f"{tens[t]} {units[o - 1]}"] = m
        return result

    @staticmethod
    def _norm_phrase(s: str) -> str:
        return " ".join(s.split())

    @staticmethod
    def _alternation(mapping: dict[str, int]) -> str:
        keys = sorted(mapping.keys(), key=lambda w: (-len(w), -w.count(" ")))

        def _escape_phrase(phrase: str) -> str:
            # allow flexible whitespace between words (single vs double spaces)
            return r"\s+".join(re.escape(part) for part in phrase.split())

        return "|".join(_escape_phrase(k) for k in keys)

    @staticmethod
    def _alternation_set(values: set[str]) -> str:
        keys = sorted(values, key=lambda w: (-len(w), -w.count(" ")))

        def _escape_phrase(phrase: str) -> str:
            return r"\s+".join(re.escape(part) for part in phrase.split())

        return "|".join(_escape_phrase(k) for k in keys)

    def __call__(self, s: str) -> str:
        s = self._re_godzina_digits.sub(self._godzina_digits_repl, s)
        s = self._re_o_godzinie_digits.sub(self._o_godzinie_digits_repl, s)
        s = self._re_godzina_digit_hour.sub(self._godzina_digit_hour_repl, s)
        s = self._re_o_godzinie_digit_hour.sub(self._o_godzinie_digit_hour_repl, s)
        s = self._re_o_godzinie_hour.sub(self._o_godzinie_hour_repl, s)
        s = self._re_o_godzinie_ord.sub(self._o_godzinie_ord_repl, s)
        s = self._re_wpol.sub(
            lambda m: f"{(self.hours_gen[self._norm_phrase(m.group(1))] - 1) % 24}:30", s
        )
        # handle time-of-day markers: generic marker before narrow "rano" to capture all
        s = self._re_o_hour_marker.sub(self._o_hour_marker_repl, s)
        s = self._re_hour_marker.sub(self._hour_marker_repl, s)
        # legacy rano handlers (kept for compat, now redundant but harmless)
        s = self._re_o_hour_rano.sub(self._o_hour_rano_repl, s)
        s = self._re_hour_rano.sub(self._hour_rano_repl, s)

        # protect geographic "na północ/południe" (south/north) – keep as words
        # only convert time midnight/noon when not geographic
        _geo_map: dict[str, str] = {}

        def _protect_geo(m: re.Match[str]) -> str:
            # use UUID placeholder that cannot collide with user input
            # (no word chars that would match time patterns)
            key = f"__GEO_{uuid.uuid4().hex}__"
            _geo_map[key] = m.group(0)
            return key

        # geographic prepositions + północ/południe should stay (include ASCII variants and declensions via Morfeusz)
        # need to protect both midnight forms
        # we use two regexes but need to protect combined: first try polnoc then poludnie
        s = self._re_geo_polnoc.sub(_protect_geo, s)
        s = self._re_geo_poludnie.sub(_protect_geo, s)
        # fallback legacy pattern (covers simple "na północ" if Morfeusz forms miss)
        s = re.sub(
            r"\b(na|z|od|do|w|ku|kierunek|strona|część|czesc|północno|polnocno|południowo|poludniowo)\s+(północ|polnoc|południe|poludnie)\b",
            _protect_geo,
            s,
        )
        # convert midnight/noon: contextual prep+form and standalone nominative; bare declined like "północy" stays word
        s = self._re_polnoc_context.sub(lambda m: f"{m.group(1)} 0:00", s)
        s = self._re_poludnie_context.sub(lambda m: f"{m.group(1)} 12:00", s)
        s = self._re_polnoc_nominative.sub("0:00", s)
        s = self._re_poludnie_nominative.sub("12:00", s)
        for k, v in _geo_map.items():
            s = s.replace(k, v)
        s = self._re_za.sub(self._za_repl, s)
        s = self._re_po.sub(self._po_repl, s)
        s = self._re_godzina_min.sub(self._godzina_min_repl, s)
        s = self._re_godzina.sub(self._godzina_repl, s)
        s = self._re_hour_min.sub(self._hour_min_repl, s)
        s = self._re_hour_gen_min.sub(self._hour_gen_min_repl, s)
        s = self._re_o_hour.sub(self._o_hour_repl, s)
        s = self._re_za_minut.sub(self._za_repl, s)
        s = self._re_po_minut.sub(self._po_repl, s)
        s = self._re_range.sub(self._range_repl, s)
        return s

    def _za_repl(self, m: re.Match[str]) -> str:
        minute = self.minutes[self._norm_phrase(m.group(1))]
        hour = self.hours[self._norm_phrase(m.group(2))]
        if minute <= 0 or minute >= 60:
            return m.group(0)
        return f"{(hour - 1) % 24}:{60 - minute:02d}"

    def _po_repl(self, m: re.Match[str]) -> str:
        minute = self.minutes[self._norm_phrase(m.group(1))]
        hour = self.hours_gen[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _godzina_min_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        minute = self.minutes[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _godzina_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        return f"{hour}:00"

    def _hour_min_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        minute = self.minutes[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _hour_gen_min_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        minute = self.minutes[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _o_hour_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        return f"o {hour}:00"

    def _o_hour_rano_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        return f"o {hour}:00 rano"

    def _hour_rano_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        return f"{hour}:00 rano"

    def _o_hour_marker_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        marker = self._norm_phrase(m.group(2))
        return f"o {hour}:00 {marker}"

    def _hour_marker_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        marker = self._norm_phrase(m.group(2))
        return f"{hour}:00 {marker}"

    def _range_repl(self, m: re.Match[str]) -> str:
        start = self.hours_gen[self._norm_phrase(m.group(1))]
        end = self.hours_gen[self._norm_phrase(m.group(2))]
        return f"od {start}:00 do {end}:00"

    def _godzina_digits_repl(self, m: re.Match[str]) -> str:
        hour, minute = int(m.group(1)), int(m.group(2))
        if hour > 24 or minute > 59:
            return m.group(0)
        return f"{hour}:{minute:02d}"

    def _o_godzinie_digits_repl(self, m: re.Match[str]) -> str:
        hour, minute = int(m.group(1)), int(m.group(2))
        if hour > 24 or minute > 59:
            return m.group(0)
        return f"o {hour}:{minute:02d}"

    def _godzina_digit_hour_repl(self, m: re.Match[str]) -> str:
        hour = int(m.group(1))
        if hour > 24:
            return m.group(0)
        return f"{hour}:00"

    def _o_godzinie_digit_hour_repl(self, m: re.Match[str]) -> str:
        hour = int(m.group(1))
        if hour > 24:
            return m.group(0)
        return f"o {hour}:00"

    def _o_godzinie_ord_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        minute = self.minutes_ordinal[self._norm_phrase(m.group(2))]
        return f"o {hour}:{minute:02d}"

    def _o_godzinie_hour_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        return f"o {hour}:00"

polish_whisper_normalizer.text.PolishTextNormalizer

Source code in src/polish_whisper_normalizer/text.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
class PolishTextNormalizer:
    # pre-compiled patterns shared across instances
    _BRACKETS_RE = re.compile(r"<[^>]*>|\[[^\]]*\]")
    _PAREN_RE = re.compile(r"\([^)]*\)")
    _WS_RE = re.compile(r"\s+")
    _IGNORE_RE = re.compile(r"\b(?:eee+|yyy+|hmm+|mhm+|mmm+|uh+|um+)\b")
    _SENTENCE_PERIOD_RE = re.compile(r"(?<![\d.])\.(?!\.)([^0-9]|$)")
    _ELLIPSIS_RE = re.compile(r"\s*(?:\.\s*){2,}|\s*…\s*")
    _NUMBER_SEPARATOR_RE = re.compile(r"[,;!?—–]")
    _TRAILING_DIGIT_PERIOD_RE = re.compile(r"(?<=\d)\.(?=\s|$)")
    _R_ABBREV_RE = re.compile(r"\br\.?\s*(?=\d{3,4}\b)")
    _DECIMAL_COMMA_RE = re.compile(r"(\d),(\d)")
    _MONTH_DAY_RE = re.compile(r"(\d+)\.?\s+([a-ząćęłńóśźż]+)\b")
    _FULL_DATE_ROKU_BEFORE_RE = re.compile(r"(\d+)\.\s+(\d+)\s+(?:roku|r\.?)\s+(\d+)\.?")
    _FULL_DATE_ROKU_AFTER_RE = re.compile(r"(\d+)\.\s+(\d+)\s+(\d+)\.?\s+(?:roku|r\.?)\b")
    _FULL_DATE_RE = re.compile(r"(\d+)\.\s+(\d+)\s+(\d{4})\.?")
    _TRAILING_R_RE1 = re.compile(r"(\d{2}\.\d{2}\.\d{4})\s+r\.?\b")
    _TRAILING_R_RE2 = re.compile(r"(\d{2}\.\d{2}\.\d{4})r\.?\b")
    _DAY_MONTH_RE = re.compile(r"(\d+)\.\s+(\d+)\b(?!\.)")
    _PERCENT_RE = re.compile(r"([^0-9])%")
    _COLON_RE = re.compile(r"(?<!\d):|:(?!\d)")
    _SIGN_RE = re.compile(r"[-+](?!\d)")
    _WANTS_R_RE = re.compile(r"\br\.?\b")

    def __init__(self, date_format: str = "{day:02d}.{month:02d}.{year}", **kwargs: object) -> None:
        """
        Args:
            date_format: How to render full dates (day month year). Supports
                Python format with {day}, {month}, {year} (e.g. "{day:02d}.{month:02d}.{year}"
                or "{day}/{month}/{year}") and strftime with %d/%m/%Y
                (e.g. "%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Yr.").
                Default "{day:02d}.{month:02d}.{year}" -> "05.05.2026".
        """
        if kwargs:
            unexpected = ", ".join(sorted(kwargs.keys()))
            raise TypeError(f"Unexpected keyword arguments: {unexpected}")

        self.ignore_patterns = r"\b(?:eee+|yyy+|hmm+|mhm+|mmm+|uh+|um+)\b"
        self.standardize_numbers = PolishNumberNormalizer()
        self.standardize_time = PolishTimeNormalizer()
        self.date_format: str = date_format

    def _format_date(self, day: int, month: int, year: int) -> str:
        fmt = self.date_format
        has_brace = "{" in fmt and "}" in fmt
        has_percent = "%" in fmt
        # prefer explicit brace-format; if both present try brace first
        if has_brace:
            try:
                return fmt.format(day=day, month=month, year=year)
            except Exception as exc:
                logger.debug("brace date_format failed for %r: %s", fmt, exc)
                if not has_percent:
                    return f"{day:02d}.{month:02d}.{year}"
        if has_percent:
            try:
                return datetime.datetime(year, month, day).strftime(fmt)
            except Exception as exc:
                logger.debug("strftime date_format failed for %r: %s", fmt, exc)
        # fallback: brace format if not tried
        if not has_brace:
            try:
                return fmt.format(day=day, month=month, year=year)
            except Exception as exc:
                logger.debug("fallback brace format failed for %r: %s", fmt, exc)
        return f"{day:02d}.{month:02d}.{year}"

    def _month_number(self, word: str) -> str | None:
        # direct map is fastest
        if word in self.standardize_numbers.month_lemmas:
            return self.standardize_numbers.month_lemmas[word]
        for base, pos in self.standardize_numbers.lemmatizer.analyse(word):
            if pos == "subst" and base in self.standardize_numbers.month_lemmas:
                return self.standardize_numbers.month_lemmas[base]
        # fallback for ASCII-folded month (e.g. "styczen" -> "styczeń")
        mapped = self.standardize_numbers._declined_ascii_map.get(word)
        if mapped is not None and mapped in self.standardize_numbers.month_lemmas:
            return self.standardize_numbers.month_lemmas[mapped]
        return None

    def __call__(self, s: str) -> str:
        s = s.lower()

        s = self._BRACKETS_RE.sub("", s)  # remove words between brackets
        s = self._PAREN_RE.sub("", s)  # remove words between parenthesis
        s = self._IGNORE_RE.sub("", s)

        # expand the year abbreviation "r." / "r" to "roku" (w r. 1860 -> w roku 1860)
        s = self._R_ABBREV_RE.sub("roku ", s)

        # remove sentence periods before digits are introduced by time/number
        # normalization; keep decimals ("3.14"), ordinal markers ("21.") and
        # ellipses (handled as boundaries below). A boundary "." is emitted so
        # separate numerals ("10. 500") are not merged, then dropped by numbers.
        s = self._SENTENCE_PERIOD_RE.sub(r" . \1", s)
        s = self._ELLIPSIS_RE.sub(" . ", s)

        s = self.standardize_time(s)

        s = self._DECIMAL_COMMA_RE.sub(r"\1.\2", s)  # Polish decimal comma -> point
        # other punctuation separates numerals ("10, 500" -> "10 500") instead
        # of being erased (which would merge them into "10500")
        s = self._NUMBER_SEPARATOR_RE.sub(" . ", s)
        s = remove_symbols(
            s, keep=".:/%$€£¢+-"
        )  # keep numeric/time/sign/currency symbols + fraction slash

        s = self.standardize_numbers(s)

        # conditional month mapping: only when preceded by day (with or without dot)
        # ("3. maja" -> "3. 5", "5 maja" -> "5. 5", but "maja" alone or "w maju" stays)
        def _date_repl(m: re.Match[str]) -> str:
            day = m.group(1)
            month_word = m.group(2)
            month_num = self._month_number(month_word)
            if month_num:
                return f"{day}. {month_num}"
            return m.group(0)

        s = self._MONTH_DAY_RE.sub(_date_repl, s)

        # full date formatting: "5. 5 roku 2026." -> "05.05.2026", "5. 5 2026" -> "05.05.2026" (uniform, no r)
        def _full_date_roku_before(m: re.Match[str]) -> str:
            day, month, year = m.group(1), m.group(2), m.group(3)
            return self._format_date(int(day), int(month), int(year))

        def _full_date_roku_after(m: re.Match[str]) -> str:
            day, month, year = m.group(1), m.group(2), m.group(3)
            return self._format_date(int(day), int(month), int(year))

        def _full_date(m: re.Match[str]) -> str:
            day, month, year = m.group(1), m.group(2), m.group(3)
            return self._format_date(int(day), int(month), int(year))

        def _day_month(m: re.Match[str]) -> str:
            day, month = m.group(1), m.group(2)
            return f"{int(day):02d}.{int(month):02d}"

        # order matters: most specific first (handle roku and r. uniformly)
        s = self._FULL_DATE_ROKU_BEFORE_RE.sub(_full_date_roku_before, s)
        s = self._FULL_DATE_ROKU_AFTER_RE.sub(_full_date_roku_after, s)
        s = self._FULL_DATE_RE.sub(_full_date, s)
        # uniform output: strip trailing r/r. only if date_format does not request it
        # detect literal 'r' in format (e.g. "%d.%m.%Yr." or "{day} r.")
        _wants_r = (
            bool(self._WANTS_R_RE.search(self.date_format.lower())) if self.date_format else False
        )
        # legacy check for formats ending with r/r.
        if not _wants_r:
            _wants_r = (
                self.date_format.strip().lower().endswith("r.")
                or self.date_format.strip().lower().endswith(" r")
                or " r." in self.date_format.lower()
            )
        if not _wants_r:
            s = self._TRAILING_R_RE1.sub(r"\1", s)
            s = self._TRAILING_R_RE2.sub(r"\1", s)
        # day month without year -> 5. 5 -> 05.05 (as requested)
        # only when month is not ordinal (no trailing dot) – avoids "1. 2." -> "01.02."
        s = self._DAY_MONTH_RE.sub(_day_month, s)

        # ordinal dots are dropped at the end of the pipeline; strip the same
        # dot from already-digit input ("15." -> "15") for consistency
        s = self._TRAILING_DIGIT_PERIOD_RE.sub("", s)

        # remove leftover symbols that are not part of a number/time
        s = self._PERCENT_RE.sub(r"\1 ", s)
        s = self._COLON_RE.sub(" ", s)
        s = self._SIGN_RE.sub(" ", s)

        s = self._WS_RE.sub(" ", s)  # replace successive whitespaces with a space
        return s.strip()

__init__(date_format='{day:02d}.{month:02d}.{year}', **kwargs)

Parameters:

Name Type Description Default
date_format str

How to render full dates (day month year). Supports Python format with {day}, {month}, {year} (e.g. "{day:02d}.{month:02d}.{year}" or "{day}/{month}/{year}") and strftime with %d/%m/%Y (e.g. "%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Yr."). Default "{day:02d}.{month:02d}.{year}" -> "05.05.2026".

'{day:02d}.{month:02d}.{year}'
Source code in src/polish_whisper_normalizer/text.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def __init__(self, date_format: str = "{day:02d}.{month:02d}.{year}", **kwargs: object) -> None:
    """
    Args:
        date_format: How to render full dates (day month year). Supports
            Python format with {day}, {month}, {year} (e.g. "{day:02d}.{month:02d}.{year}"
            or "{day}/{month}/{year}") and strftime with %d/%m/%Y
            (e.g. "%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Yr.").
            Default "{day:02d}.{month:02d}.{year}" -> "05.05.2026".
    """
    if kwargs:
        unexpected = ", ".join(sorted(kwargs.keys()))
        raise TypeError(f"Unexpected keyword arguments: {unexpected}")

    self.ignore_patterns = r"\b(?:eee+|yyy+|hmm+|mhm+|mmm+|uh+|um+)\b"
    self.standardize_numbers = PolishNumberNormalizer()
    self.standardize_time = PolishTimeNormalizer()
    self.date_format: str = date_format

polish_whisper_normalizer.utils

Shared utilities – ASCII-folding and helpers.

Keeps diacritic handling in one place so normalizers don't duplicate logic.

strip_diacritics(word)

ASCII-fold a single word (like remove_symbols_and_diacritics but without adding spaces).

Source code in src/polish_whisper_normalizer/utils.py
13
14
15
def strip_diacritics(word: str) -> str:
    """ASCII-fold a single word (like remove_symbols_and_diacritics but without adding spaces)."""
    return remove_symbols_and_diacritics(word)

with_ascii_variants(mapping)

Return a copy of mapping extended with ASCII-folded keys for diacritic forms.

Source code in src/polish_whisper_normalizer/utils.py
18
19
20
21
22
23
24
25
def with_ascii_variants(mapping: dict[str, Any]) -> dict[str, Any]:
    """Return a copy of *mapping* extended with ASCII-folded keys for diacritic forms."""
    expanded: dict[str, Any] = dict(mapping)
    for key, value in list(mapping.items()):
        stripped = strip_diacritics(key)
        if stripped != key and stripped not in expanded:
            expanded[stripped] = value
    return expanded

with_ascii_variants_set(values)

Return a copy of values extended with ASCII-folded variants.

Source code in src/polish_whisper_normalizer/utils.py
28
29
30
31
32
33
34
35
def with_ascii_variants_set(values: set[str]) -> set[str]:
    """Return a copy of *values* extended with ASCII-folded variants."""
    expanded = set(values)
    for key in list(values):
        stripped = strip_diacritics(key)
        if stripped != key:
            expanded.add(stripped)
    return expanded

polish_whisper_normalizer.polish

Compatibility shim – re-exports from split modules.

The implementation was split into lemmatizer.py, numbers.py, time.py, text.py and utils.py to keep files focused and to delegate all morphology to Morfeusz2 (see those modules). This file remains for backwards compatibility: from polish_whisper_normalizer.polish import PolishNumberNormalizer continues to work.

PolishLemmatizer

Thin, caching wrapper around Morfeusz 2 used to map declined number words back to their base (lemma) form. If Morfeusz is unavailable it degrades gracefully and simply returns no analyses.

Morfeusz is the single source of truth for declension – we do not re-implement Polish morphology manually; instead we rely on analyse/generate to canonicalize declined forms.

Source code in src/polish_whisper_normalizer/lemmatizer.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
class PolishLemmatizer:
    """
    Thin, caching wrapper around Morfeusz 2 used to map declined number words
    back to their base (lemma) form. If Morfeusz is unavailable it degrades
    gracefully and simply returns no analyses.

    Morfeusz is the single source of truth for declension – we do not
    re-implement Polish morphology manually; instead we rely on
    ``analyse``/``generate`` to canonicalize declined forms.
    """

    def __init__(self) -> None:
        self._morf: Any | None = None
        self._morf_failed: bool = False
        self._cache: dict[str, list[tuple[str, str]]] = {}
        self._gen_cache: dict[str, list[tuple[str, str, str]]] = {}
        self._lock = threading.Lock()

    @property
    def morf(self) -> Any | None:
        if self._morf is not None:
            return self._morf
        if self._morf_failed:
            return None
        with self._lock:
            if self._morf is not None:
                return self._morf
            if self._morf_failed:
                return None
            try:
                import morfeusz2

                self._morf = morfeusz2.Morfeusz()
            except ImportError:
                logger.debug("morfeusz2 not installed – lemmatizer disabled")
                self._morf_failed = True
                return None
            except Exception as exc:  # pragma: no cover – unexpected Morfeusz init error
                logger.warning("Failed to initialize Morfeusz: %s", exc)
                self._morf_failed = True
                return None
            return self._morf

    def analyse(self, word: str) -> list[tuple[str, str]]:
        """Return a list of (base_lemma, part_of_speech) tuples."""
        morf = self.morf
        if morf is None:
            return []
        if word in self._cache:
            return self._cache[word]
        results: list[tuple[str, str]] = []
        try:
            analyses = morf.analyse(word)  # type: ignore[union-attr]
        except Exception as exc:  # pragma: no cover
            logger.debug("Morfeusz analyse failed for %r: %s", word, exc)
            self._cache[word] = []
            return []
        for analysis in analyses:
            try:
                if len(analysis) == 3 and isinstance(analysis[2], tuple):
                    datum = analysis[2]  # type: ignore[assignment]
                    lemma, morph = datum[1], datum[2]  # type: ignore[index]
                else:
                    lemma, morph = analysis[1], analysis[2]  # type: ignore[misc]
                results.append((lemma.split(":")[0], morph.split(":")[0]))
            except Exception:  # pragma: no cover – malformed entry
                continue
        self._cache[word] = results
        return results

    def generate(self, lemma: str) -> list[tuple[str, str, str]]:
        """Generate all surface forms for *lemma* (wrapper for Morfeusz.generate)."""
        if lemma in self._gen_cache:
            return self._gen_cache[lemma]
        morf = self.morf
        if morf is None:
            return []
        try:
            # Morfeusz.generate returns list of (form, lemma, tag, ..., ...)
            forms = morf.generate(lemma)  # type: ignore[union-attr]
        except Exception as exc:  # pragma: no cover
            logger.debug("Morfeusz generate failed for %r: %s", lemma, exc)
            self._gen_cache[lemma] = []
            return []
        # Normalize to (surface, lemma, tag)
        out: list[tuple[str, str, str]] = []
        for entry in forms:
            if len(entry) >= 3:
                surface, lem, tag = entry[0], entry[1], entry[2]  # type: ignore[misc]
                if isinstance(surface, str) and isinstance(lem, str) and isinstance(tag, str):
                    out.append((surface, lem, tag))
        self._gen_cache[lemma] = out
        return out

analyse(word)

Return a list of (base_lemma, part_of_speech) tuples.

Source code in src/polish_whisper_normalizer/lemmatizer.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def analyse(self, word: str) -> list[tuple[str, str]]:
    """Return a list of (base_lemma, part_of_speech) tuples."""
    morf = self.morf
    if morf is None:
        return []
    if word in self._cache:
        return self._cache[word]
    results: list[tuple[str, str]] = []
    try:
        analyses = morf.analyse(word)  # type: ignore[union-attr]
    except Exception as exc:  # pragma: no cover
        logger.debug("Morfeusz analyse failed for %r: %s", word, exc)
        self._cache[word] = []
        return []
    for analysis in analyses:
        try:
            if len(analysis) == 3 and isinstance(analysis[2], tuple):
                datum = analysis[2]  # type: ignore[assignment]
                lemma, morph = datum[1], datum[2]  # type: ignore[index]
            else:
                lemma, morph = analysis[1], analysis[2]  # type: ignore[misc]
            results.append((lemma.split(":")[0], morph.split(":")[0]))
        except Exception:  # pragma: no cover – malformed entry
            continue
    self._cache[word] = results
    return results

generate(lemma)

Generate all surface forms for lemma (wrapper for Morfeusz.generate).

Source code in src/polish_whisper_normalizer/lemmatizer.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def generate(self, lemma: str) -> list[tuple[str, str, str]]:
    """Generate all surface forms for *lemma* (wrapper for Morfeusz.generate)."""
    if lemma in self._gen_cache:
        return self._gen_cache[lemma]
    morf = self.morf
    if morf is None:
        return []
    try:
        # Morfeusz.generate returns list of (form, lemma, tag, ..., ...)
        forms = morf.generate(lemma)  # type: ignore[union-attr]
    except Exception as exc:  # pragma: no cover
        logger.debug("Morfeusz generate failed for %r: %s", lemma, exc)
        self._gen_cache[lemma] = []
        return []
    # Normalize to (surface, lemma, tag)
    out: list[tuple[str, str, str]] = []
    for entry in forms:
        if len(entry) >= 3:
            surface, lem, tag = entry[0], entry[1], entry[2]  # type: ignore[misc]
            if isinstance(surface, str) and isinstance(lem, str) and isinstance(tag, str):
                out.append((surface, lem, tag))
    self._gen_cache[lemma] = out
    return out

PolishNumberNormalizer

Convert spelled-out Polish numbers into arabic digits, while handling:

  • cardinal numbers ("sto dwadzieścia trzy" -> "123")
  • grammatical variants of big multipliers ("tysiąc/tysiące/tysięcy" -> 1000)
  • currency ("pięć złotych" -> "5 zł", "pięćdziesiąt groszy" -> "50 gr")
  • percents ("dwadzieścia procent" -> "20%")
  • decimals ("trzy przecinek czternaście" -> "3.14")
  • signs ("minus dziesięć" -> "-10")
  • ordinal numbers, including declension ("dwudziesty pierwszy" -> "21.", "pierwszego" -> "1.", "trzeciej" -> "3.")
  • declined cardinal forms ("pięciu" -> "5", "dwóm" -> "2", "tysiąca" -> "1000")
Source code in src/polish_whisper_normalizer/numbers.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
class PolishNumberNormalizer:
    """
    Convert spelled-out Polish numbers into arabic digits, while handling:

    - cardinal numbers ("sto dwadzieścia trzy" -> "123")
    - grammatical variants of big multipliers ("tysiąc/tysiące/tysięcy" -> 1000)
    - currency ("pięć złotych" -> "5 zł", "pięćdziesiąt groszy" -> "50 gr")
    - percents ("dwadzieścia procent" -> "20%")
    - decimals ("trzy przecinek czternaście" -> "3.14")
    - signs ("minus dziesięć" -> "-10")
    - ordinal numbers, including declension ("dwudziesty pierwszy" -> "21.",
      "pierwszego" -> "1.", "trzeciej" -> "3.")
    - declined cardinal forms ("pięciu" -> "5", "dwóm" -> "2", "tysiąca" -> "1000")
    """

    # class-level shared state – built once, reused (thread-safe)
    _CACHE: dict[str, object] | None = None
    _CACHE_LOCK = threading.Lock()
    _DECLINED_ASCII_CACHE: dict[str, str] | None = None
    _DECLINED_LOCK = threading.Lock()

    # pre-compiled patterns (avoid recompiling per token)
    _POLISH_WORD_RE = re.compile(r"[a-ząćęłńóśźż]+")
    _NUMERIC_RE = re.compile(r"^\d+(?:\.\d+)?$")
    _NUMERIC_PREFIX_RE = re.compile(r"^\d")
    _DIGIT_RE = re.compile(r"^\d+(?:\.\d+)?$")

    def __init__(self) -> None:
        super().__init__()

        self.zeros = {"zero"}
        self.ones = {
            "jeden": 1,
            "jedna": 1,
            "jedno": 1,
            "dwa": 2,
            "dwie": 2,
            "trzy": 3,
            "cztery": 4,
            "pięć": 5,
            "sześć": 6,
            "siedem": 7,
            "osiem": 8,
            "dziewięć": 9,
            "dziesięć": 10,
            "jedenaście": 11,
            "dwanaście": 12,
            "trzynaście": 13,
            "czternaście": 14,
            "piętnaście": 15,
            "szesnaście": 16,
            "siedemnaście": 17,
            "osiemnaście": 18,
            "ośmnaście": 18,
            "dziewiętnaście": 19,
        }
        self.tens = {
            "dwadzieścia": 20,
            "trzydzieści": 30,
            "czterdzieści": 40,
            "pięćdziesiąt": 50,
            "sześćdziesiąt": 60,
            "siedemdziesiąt": 70,
            "osiemdziesiąt": 80,
            "dziewięćdziesiąt": 90,
        }
        self.hundreds = {
            "sto": 100,
            "dwieście": 200,
            "trzysta": 300,
            "czterysta": 400,
            "pięćset": 500,
            "sześćset": 600,
            "siedemset": 700,
            "osiemset": 800,
            "dziewięćset": 900,
        }
        self.multipliers = {
            "tysiąc": 1_000,
            "tysiące": 1_000,
            "tysięcy": 1_000,
            "milion": 1_000_000,
            "miliony": 1_000_000,
            "milionów": 1_000_000,
            "miliard": 1_000_000_000,
            "miliardy": 1_000_000_000,
            "miliardów": 1_000_000_000,
            "bilion": 1_000_000_000_000,
            "biliony": 1_000_000_000_000,
            "bilionów": 1_000_000_000_000,
            "biliard": 1_000_000_000_000_000,
            "biliardy": 1_000_000_000_000_000,
            "biliardów": 1_000_000_000_000_000,
            "trylion": 1_000_000_000_000_000_000,
            "tryliony": 1_000_000_000_000_000_000,
            "trylionów": 1_000_000_000_000_000_000,
        }

        # ordinal numbers (base masculine-nominative forms)
        self.ones_ordinal = {
            "pierwszy": 1,
            "drugi": 2,
            "trzeci": 3,
            "czwarty": 4,
            "piąty": 5,
            "szósty": 6,
            "siódmy": 7,
            "ósmy": 8,
            "dziewiąty": 9,
            "dziesiąty": 10,
            "jedenasty": 11,
            "dwunasty": 12,
            "trzynasty": 13,
            "czternasty": 14,
            "piętnasty": 15,
            "szesnasty": 16,
            "siedemnasty": 17,
            "osiemnasty": 18,
            "dziewiętnasty": 19,
        }
        self.tens_ordinal = {
            "dwudziesty": 20,
            "trzydziesty": 30,
            "czterdziesty": 40,
            "pięćdziesiąty": 50,
            "sześćdziesiąty": 60,
            "siedemdziesiąty": 70,
            "osiemdziesiąty": 80,
            "dziewięćdziesiąty": 90,
        }
        self.hundreds_ordinal = {
            "setny": 100,
            "dwusetny": 200,
            "trzysetny": 300,
            "czterysetny": 400,
            "pięćsetny": 500,
            "sześćsetny": 600,
            "siedemsetny": 700,
            "osiemsetny": 800,
            "dziewięćsetny": 900,
        }
        self.multipliers_ordinal = {
            "tysięczny": 1_000,
            "milionowy": 1_000_000,
            "miliardowy": 1_000_000_000,
            "bilionowy": 1_000_000_000_000,
        }

        self.decimals = {*self.ones, *self.tens, *self.zeros}

        self.preceding_prefixers = {
            "minus": "-",
            "plus": "+",
        }
        self.currencies = {
            # Polish złoty / grosz
            "złoty": "zł",
            "złote": "zł",
            "złotych": "zł",
            "złotego": "zł",
            "zł": "zł",
            "pln": "zł",
            "złotówka": "zł",
            "grosz": "gr",
            "grosze": "gr",
            "groszy": "gr",
            "grosza": "gr",
            "gr": "gr",
            # euro / cent
            "euro": "€",
            "eur": "€",
            "cent": "¢",
            "centy": "¢",
            "centów": "¢",
            "centa": "¢",
            "¢": "¢",
            # dolar
            "dolar": "$",
            "dolary": "$",
            "dolarów": "$",
            "dolara": "$",
            "usd": "$",
            # funt (pound sterling)
            "funt": "£",
            "funty": "£",
            "funtów": "£",
            "gbp": "£",
            # currency symbols (canonical)
            "€": "€",
            "$": "$",
            "£": "£",
        }
        self.suffixers = {
            "procent": "%",
            "procenta": "%",
        }
        self.specials = {"przecinek", "kropka"}
        self.conjunctions = {"i"}
        self.prefixes = set(self.preceding_prefixers.values())

        self.words = {
            key
            for mapping in [
                self.zeros,
                self.ones,
                self.tens,
                self.hundreds,
                self.multipliers,
                self.ones_ordinal,
                self.tens_ordinal,
                self.hundreds_ordinal,
                self.multipliers_ordinal,
                self.preceding_prefixers,
                self.currencies,
                self.suffixers,
                self.specials,
                self.conjunctions,
            ]
            for key in mapping
        }

        # lemma -> value lookups used to canonicalize declined forms
        self.cardinal_lemmas = set(self.ones) | set(self.tens) | set(self.hundreds)
        self.multiplier_lemmas = set(self.multipliers)
        self.ordinal_lemmas = (
            set(self.ones_ordinal)
            | set(self.tens_ordinal)
            | set(self.hundreds_ordinal)
            | set(self.multipliers_ordinal)
        )
        self.ordinal_values = {
            **self.ones_ordinal,
            **self.tens_ordinal,
            **self.hundreds_ordinal,
            **self.multipliers_ordinal,
        }

        # feminine/neuter cardinal forms used as fraction numerators
        self.fraction_numerators = {
            "jedna": 1,
            "dwie": 2,
            "trzy": 3,
            "cztery": 4,
            "pięć": 5,
            "sześć": 6,
            "siedem": 7,
            "osiem": 8,
            "dziewięć": 9,
            "dziesięć": 10,
        }
        # non-number words whose declined forms we canonicalize
        self.percent_lemmas = {"procent"}
        # colloquial currency nouns
        self.currency_lemmas = {"złotówka"}
        # month lemmas -> number string (for full-date normalization)
        self.month_lemmas = {
            "styczeń": "1",
            "luty": "2",
            "marzec": "3",
            "kwiecień": "4",
            "maj": "5",
            "czerwiec": "6",
            "lipiec": "7",
            "sierpień": "8",
            "wrzesień": "9",
            "październik": "10",
            "listopad": "11",
            "grudzień": "12",
            # abbreviated months (Morfeusz-independent, common in refs)
            "sty": "1",
            "lut": "2",
            "mar": "3",
            "kwi": "4",
            "cze": "6",
            "lip": "7",
            "sie": "8",
            "wrz": "9",
            "paź": "10",
            "lis": "11",
            "gru": "12",
        }

        # --- ASCII-folded variants (support diacritic-less ASR output) ---------------
        # e.g. "czterdzieści" -> "czterdziesci", "pięć" -> "piec", "pięćset" -> "piecset"
        # keep original lemma sets for generating declined ASCII map (avoid generating from
        # ASCII collisions like "piec" (stove) vs "pięć" (5))
        _orig_cardinal = set(self.ones) | set(self.tens) | set(self.hundreds)
        _orig_multiplier = set(self.multipliers)
        _orig_ordinal = (
            set(self.ones_ordinal)
            | set(self.tens_ordinal)
            | set(self.hundreds_ordinal)
            | set(self.multipliers_ordinal)
        )
        _orig_percent = set(self.percent_lemmas)
        _orig_currency = set(self.currency_lemmas)
        _orig_month = set(self.month_lemmas)
        self._expand_ascii_variants()
        # re-derive composite sets after expansion
        self._rebuild_composite_sets()

        self.lemmatizer = PolishLemmatizer()
        self._canon_cache: dict[str, str] = {}
        # map stripped declined forms -> original base lemma (for diacritic-less declensions)
        # use class-level cache to avoid rebuilding via Morfeusz.generate on every instance
        _all_orig_bases = (
            _orig_cardinal
            | _orig_multiplier
            | _orig_ordinal
            | _orig_percent
            | _orig_currency
            | _orig_month
        )
        self._declined_ascii_map = self._get_or_build_declined_map(_all_orig_bases, self.lemmatizer)

    def _expand_ascii_variants(self) -> None:
        """Expand lexicons with ASCII-folded variants for diacritic-less ASR."""
        self.zeros = with_ascii_variants_set(self.zeros)
        self.ones = with_ascii_variants(self.ones)
        self.tens = with_ascii_variants(self.tens)
        self.hundreds = with_ascii_variants(self.hundreds)
        self.multipliers = with_ascii_variants(self.multipliers)
        self.ones_ordinal = with_ascii_variants(self.ones_ordinal)
        self.tens_ordinal = with_ascii_variants(self.tens_ordinal)
        self.hundreds_ordinal = with_ascii_variants(self.hundreds_ordinal)
        self.multipliers_ordinal = with_ascii_variants(self.multipliers_ordinal)
        self.currencies = with_ascii_variants(self.currencies)
        self.suffixers = with_ascii_variants(self.suffixers)
        self.specials = with_ascii_variants_set(self.specials)
        self.conjunctions = with_ascii_variants_set(self.conjunctions)
        self.fraction_numerators = with_ascii_variants(self.fraction_numerators)
        self.month_lemmas = with_ascii_variants(self.month_lemmas)
        self.percent_lemmas = with_ascii_variants_set(self.percent_lemmas)
        self.currency_lemmas = with_ascii_variants_set(self.currency_lemmas)

    def _rebuild_composite_sets(self) -> None:
        """Re-derive sets that depend on expanded lexicons."""
        self.words = {
            key
            for mapping in [
                self.zeros,
                self.ones,
                self.tens,
                self.hundreds,
                self.multipliers,
                self.ones_ordinal,
                self.tens_ordinal,
                self.hundreds_ordinal,
                self.multipliers_ordinal,
                self.preceding_prefixers,
                self.currencies,
                self.suffixers,
                self.specials,
                self.conjunctions,
            ]
            for key in mapping
        }
        self.cardinal_lemmas = set(self.ones) | set(self.tens) | set(self.hundreds)
        self.multiplier_lemmas = set(self.multipliers)
        self.ordinal_lemmas = (
            set(self.ones_ordinal)
            | set(self.tens_ordinal)
            | set(self.hundreds_ordinal)
            | set(self.multipliers_ordinal)
        )
        self.ordinal_values = {
            **self.ones_ordinal,
            **self.tens_ordinal,
            **self.hundreds_ordinal,
            **self.multipliers_ordinal,
        }
        self.decimals = {*self.ones, *self.tens, *self.zeros}
        self.prefixes = set(self.preceding_prefixers.values())

    @classmethod
    def _get_or_build_declined_map(
        cls, bases: set[str], lemmatizer: PolishLemmatizer
    ) -> dict[str, str]:
        """Return cached declined ASCII map or build via Morfeusz.generate."""
        if cls._DECLINED_ASCII_CACHE is not None:
            return copy.deepcopy(cls._DECLINED_ASCII_CACHE)
        declined: dict[str, str] = {}
        if lemmatizer.morf is not None:
            for base in bases:
                try:
                    forms = lemmatizer.generate(base)
                except Exception:
                    logger.debug("generate failed for %r", base)
                    continue
                for surface, lemma, _tag in forms:
                    clean_lemma = lemma.split(":")[0]
                    if clean_lemma != base:
                        continue
                    stripped_form = strip_diacritics(surface)
                    if stripped_form != surface and stripped_form not in declined:
                        declined[stripped_form] = base
        with cls._DECLINED_LOCK:
            if cls._DECLINED_ASCII_CACHE is None:
                cls._DECLINED_ASCII_CACHE = copy.deepcopy(declined)
        return declined

    def _canonicalize(self, word: str) -> str:
        """Map a declined number word to its base lemma, if it is one."""
        if word in self.words:
            return word
        if self._POLISH_WORD_RE.fullmatch(word) is None:
            return word
        cached = self._canon_cache.get(word)
        if cached is not None:
            return cached

        candidates: dict[str, str | None] = {
            "num": None,
            "adj": None,
            "subst": None,
            "percent": None,
            "currency": None,
        }
        for base, pos in self.lemmatizer.analyse(word):
            if pos == "num" and base in self.cardinal_lemmas:
                candidates["num"] = base
            elif pos == "adj" and base in self.ordinal_lemmas:
                candidates["adj"] = base
            elif pos == "adj" and base in self.cardinal_lemmas:
                # Polish numerals like "jeden" are tagged as adj in some forms (e.g. "jednej")
                candidates["num"] = base
            elif pos == "subst" and base in self.multiplier_lemmas:
                candidates["subst"] = base
            elif pos == "subst" and base in self.percent_lemmas:
                candidates["percent"] = base
            elif pos == "subst" and base in self.currency_lemmas:
                candidates["currency"] = base

        result = word
        for key in ("num", "adj", "subst", "percent", "currency"):
            if candidates[key] is not None:
                result = candidates[key]  # type: ignore[assignment]
                break
        # fallback: diacritic-less declined form (e.g. "pieciu" -> "pięć")
        if result == word and word in self._declined_ascii_map:
            result = self._declined_ascii_map[word]
        self._canon_cache[word] = result
        return result

    def process_words(self, words: list[str]) -> Iterator[str]:
        prefix: str | None = None
        value: str | int | None = None
        ordinal = False
        skip = False

        def to_fraction(s: str) -> Fraction | None:
            try:
                return Fraction(s)
            except ValueError:
                return None

        def output(result: str | int) -> str:
            nonlocal prefix, value, ordinal
            result = str(result)
            if prefix is not None:
                result = prefix + result
            if ordinal:
                result += "."
            value = None  # type: ignore[assignment]
            prefix = None
            ordinal = False
            return result

        if len(words) == 0:
            return

        for prev, current, next in _windowed_3([None, *words, None]):  # type: ignore[list-item]
            if skip:
                skip = False
                continue
            if current is None:  # type narrowing for mypy; never happens for non-empty words
                continue

            next_is_numeric = next is not None and self._NUMERIC_RE.match(next) is not None
            has_prefix = current[0] in self.prefixes
            current_without_prefix = current[1:] if has_prefix else current  # type: ignore[index]

            if self._NUMERIC_RE.match(current_without_prefix):  # type: ignore[arg-type]
                # arabic numbers (potentially with signs/currency prefixes)
                f = to_fraction(current_without_prefix)  # type: ignore[arg-type]
                assert f is not None
                if value is not None:
                    if isinstance(value, str) and value.endswith("."):
                        value = str(value) + str(current)
                        continue
                    else:
                        yield output(value)  # type: ignore[arg-type]

                prefix = current[0] if has_prefix else prefix  # type: ignore[index]
                value = f.numerator if f.denominator == 1 else current_without_prefix
            elif current not in self.words:
                # non-numeric words
                if value is not None:
                    yield output(value)  # type: ignore[arg-type]
                yield output(current)  # type: ignore[arg-type]
            elif current in self.zeros:
                value = str(value or "") + "0"
            elif current in self.ones:
                ones = self.ones[current]
                if value is None:
                    value = ones
                elif isinstance(value, str) or prev in self.ones:
                    if prev in self.tens and ones < 10:
                        assert value[-1] == "0"  # type: ignore[index]
                        value = value[:-1] + str(ones)  # type: ignore[index, union-attr]
                    else:
                        value = str(value) + str(ones)
                elif ones < 10:
                    if value % 10 == 0:
                        value += ones
                    else:
                        value = str(value) + str(ones)
                else:  # eleven to nineteen
                    if value % 100 == 0:
                        value += ones
                    else:
                        value = str(value) + str(ones)
            elif current in self.tens:
                tens = self.tens[current]
                if value is None:
                    value = tens
                elif isinstance(value, str):
                    value = str(value) + str(tens)
                elif value % 100 == 0:
                    value += tens
                else:
                    # invalid magnitude order ("dziesięć dwadzieścia") -> separate numeral
                    yield output(value)  # type: ignore[arg-type]
                    value = tens
            elif current in self.hundreds:
                hundred = self.hundreds[current]
                if value is None:
                    value = hundred
                elif isinstance(value, str):
                    value = str(value) + str(hundred)
                elif value % 1000 == 0:
                    value += hundred
                elif value >= 100:
                    # long digit-string reading ("trzysta czterdzieści osiemset ...")
                    value = str(value) + str(hundred)
                else:
                    # invalid magnitude order ("dziesięć pięćset") -> separate numeral
                    yield output(value)  # type: ignore[arg-type]
                    value = hundred
            elif current in self.multipliers:
                multiplier = self.multipliers[current]
                if value is None:
                    value = multiplier
                elif isinstance(value, str) or value == 0:
                    f = to_fraction(str(value))  # type: ignore[arg-type]
                    p = f * multiplier if f is not None else None  # type: ignore[operator]
                    if f is not None and p.denominator == 1:  # type: ignore[union-attr]
                        value = p.numerator  # type: ignore[union-attr]
                    else:
                        yield output(value)  # type: ignore[arg-type]
                        value = multiplier
                else:
                    before = value // 1000 * 1000
                    residual = value % 1000
                    value = before + residual * multiplier
            elif current in self.ones_ordinal:
                ones = self.ones_ordinal[current]
                ordinal = True
                if value is None:
                    yield output(ones)
                elif isinstance(value, str):
                    yield output(str(value) + str(ones))
                elif ones < 10:
                    if value % 10 == 0:
                        yield output(str(value + ones))
                    else:
                        yield output(str(value) + str(ones))
                else:  # 11-19
                    if value % 100 == 0:
                        yield output(str(value + ones))
                    else:
                        yield output(str(value) + str(ones))
            elif current in self.tens_ordinal:
                tens = self.tens_ordinal[current]
                ordinal = True
                if value is None:
                    value = tens
                elif isinstance(value, str):
                    value = str(value) + str(tens)
                else:
                    if value % 100 == 0:
                        value += tens
                    else:
                        value = str(value) + str(tens)
            elif current in self.hundreds_ordinal:
                # ordinal hundred; composes with a preceding multiplier
                # ("tysiąc dziewięćsetny" -> "1900.")
                hundred = self.hundreds_ordinal[current]
                ordinal = True
                if value is None:
                    yield output(hundred)
                elif isinstance(value, str):
                    yield output(str(value) + str(hundred))
                else:
                    if value % 1000 == 0:
                        value += hundred
                    else:
                        value = str(value) + str(hundred)
            elif current in self.multipliers_ordinal:
                if value is not None:
                    yield output(value)  # type: ignore[arg-type]
                ordinal = True
                yield output(self.multipliers_ordinal[current])
            elif current in self.preceding_prefixers:
                # apply prefix (minus, plus, etc.) if it precedes a number
                if value is not None:
                    yield output(value)  # type: ignore[arg-type]
                if next in self.words or next_is_numeric:
                    prefix = self.preceding_prefixers[current]
                else:
                    yield output(current)  # type: ignore[arg-type]
            elif current in self.currencies:
                # currency word/abbreviation follows the amount -> suffix
                if value is not None:
                    yield output(str(value) + " " + self.currencies[current])
                elif current.isalpha():
                    yield output(current)  # type: ignore[arg-type]
                # else: stray currency symbol with no amount -> drop
            elif current in self.suffixers:
                # apply suffix symbols (procent -> '%')
                if value is not None:
                    yield output(str(value) + self.suffixers[current])
                else:
                    yield output(current)  # type: ignore[arg-type]
            elif current in self.specials:
                # decimal separators ("przecinek", "kropka")
                if next in self.decimals or next_is_numeric:
                    value = str(value or "") + "."
                else:
                    if value is not None:
                        yield output(value)  # type: ignore[arg-type]
                    yield output(current)  # type: ignore[arg-type]
            elif current in self.conjunctions:
                # drop "i" only when it joins two numeric tokens
                # (e.g. "sto złotych i pięćdziesiąt groszy" -> "100 zł 50 gr")
                prev_numeric = prev is not None and (
                    prev in self.words or self._NUMERIC_PREFIX_RE.match(prev or "") is not None
                )
                next_numeric = next in self.words or next_is_numeric
                if prev_numeric and next_numeric:
                    if value is not None:
                        yield output(value)  # type: ignore[arg-type]
                else:
                    if value is not None:
                        yield output(value)  # type: ignore[arg-type]
                    yield output(current)  # type: ignore[arg-type]
            else:
                raise ValueError(f"Unexpected token: {current}")

        if value is not None:
            yield output(value)

    def preprocess(self, s: str) -> str:
        # ellipsis marks a sentence boundary: keep the boundary (so adjacent
        # spelled-out numbers are not merged) but drop the "." later
        s = re.sub(r"\s*(?:\.\s*){2,}|\s*…\s*", " . ", s)
        # "i pół" -> "przecinek pięć" (two and a half -> 2.5) – also ASCII "pol"
        s = re.sub(r"\bi\s+(?:pół|pol)\b", "przecinek pięć", s)
        s = re.sub(r"\b(?:półtora|poltora)\b", "jeden przecinek pięć", s)
        s = re.sub(r"\b(?:półtorej|poltorej)\b", "jeden przecinek pięć", s)
        # standalone "pół" (half) -> "0.5"
        s = re.sub(r"\b(?:pół|pol)\b", "zero przecinek pięć", s)

        # normalize currency symbols to follow the amount ("€10" -> "10 €",
        # "10€" -> "10 €", "$1.50" -> "1.50 $")
        s = re.sub(r"([€$£¢])\s*(\d+(?:\.\d+)?)", r"\2 \1", s)
        s = re.sub(r"(\d+(?:\.\d+)?)\s*([€$£¢])", r"\1 \2", s)

        # put a space at number/letter boundary
        s = re.sub(r"([^\W\d_])([0-9])", r"\1 \2", s)
        s = re.sub(r"([0-9])([^\W\d_])", r"\1 \2", s)

        return s

    def postprocess(self, s: str) -> str:
        # Normalize decimal fractions expressed as "21 i 5/10" (from
        # "dwadzieścia jeden i pięć dziesiątych") to decimal "21.5"
        # for equivalence with "21.5" reference (WER on jednostki).
        # Only decimal denominators (10,100,1000) and half (1/2) for 0.5==1/2.
        def _is_decimal_den(den: int) -> bool:
            s_den = str(den)
            return s_den[0] == "1" and all(c == "0" for c in s_den[1:])

        def _fraction_to_decimal_str(num: int, den: int, int_part: str | None = None) -> str | None:
            if num >= den or num == 0:
                return None
            # decimal denominators (10,100,1000) or half 1/2
            is_decimal = _is_decimal_den(den)
            is_half = num == 1 and den == 2
            if not (is_decimal or is_half):
                return None
            if is_half:
                # 1/2 -> 0.5, 21 i 1/2 -> 21.5
                return f"{int_part}.5" if int_part is not None else "0.5"
            # power-of-10 decimal
            k = len(str(den)) - 1
            frac_str = f"{num:0{k}d}"
            dec = f"{int_part}.{frac_str}" if int_part is not None else f"0.{frac_str}"
            dec = dec.rstrip("0").rstrip(".")
            if dec.endswith("."):
                dec += "0"
            if "." not in dec:
                dec = f"{int_part if int_part is not None else '0'}.0"
            return dec

        def _decimal_repl(m: re.Match[str]) -> str:
            int_part = m.group(1)
            num = int(m.group(2))
            den = int(m.group(3))
            dec = _fraction_to_decimal_str(num, den, int_part)
            if dec is None:
                return m.group(0)
            return dec

        s = re.sub(r"\b(\d+)\s+i\s+(\d+)/(\d+)\b", _decimal_repl, s)

        def _standalone_frac_repl(m: re.Match[str]) -> str:
            num = int(m.group(1))
            den = int(m.group(2))
            dec = _fraction_to_decimal_str(num, den, None)
            if dec is None:
                return m.group(0)
            return dec

        s = re.sub(r"\b(\d+)/(\d+)\b", _standalone_frac_repl, s)
        return s

    def _fraction_denominator(self, word: str) -> int | None:
        """Return the ordinal value of `word` if it is a fraction denominator."""
        for base, pos in self.lemmatizer.analyse(word):
            if pos == "adj" and base in self.ordinal_values:
                value = self.ordinal_values[base]
                if value >= 2:
                    return value
        # fallback for ASCII-folded declensions (e.g. "piate" -> "piąty")
        mapped = self._declined_ascii_map.get(word)
        if mapped is not None and mapped in self.ordinal_values:
            value = self.ordinal_values[mapped]
            if value >= 2:
                return value
        # also handle bare ASCII ordinal base itself (e.g., "piaty")
        if word in self.ordinal_values:
            value = self.ordinal_values[word]
            if value >= 2:
                return value
        return None

    def _fraction_numerator_value(self, word: str) -> int | None:
        """Return cardinal value for fraction numerator via Morfeusz.

        Supports declined forms (e.g. "jednej" -> 1, "dwóch" -> 2) and
        broader range (1-19, 20-90, 100-900) via Morfeusz lemmatization.
        Hundreds are included for decimal fractions like
        "czterysta pięćdziesiąt sześć tysięcznych" -> 456/1000.
        Collision with ordinal compounds (e.g. "sto dwudziesty" -> 100/20)
        is avoided by numerator < denominator check in caller.
        """
        # direct feminine dict (fast path, includes ASCII variants)
        if word in self.fraction_numerators:
            return self.fraction_numerators[word]
        # direct cardinal maps (covers 1-19, tens, hundreds, zero + ASCII)
        if word in self.ones:
            return self.ones[word]
        if word in self.tens:
            return self.tens[word]
        if word in self.hundreds:
            return self.hundreds[word]
        if word in self.zeros:
            return 0
        # via Morfeusz – handle declensions like "jednej" -> "jeden" (adj)
        for base, _pos in self.lemmatizer.analyse(word):
            if base in self.fraction_numerators:
                return self.fraction_numerators[base]
            if base in self.ones:
                return self.ones[base]
            if base in self.tens:
                return self.tens[base]
            if base in self.hundreds:
                return self.hundreds[base]
            if base in self.zeros:
                return 0
        # fallback diacritic-less declined map (e.g. "pieciu" -> "pięć")
        mapped = self._declined_ascii_map.get(word)
        if mapped is not None:
            if mapped in self.fraction_numerators:
                return self.fraction_numerators[mapped]
            if mapped in self.ones:
                return self.ones[mapped]
            if mapped in self.tens:
                return self.tens[mapped]
            if mapped in self.hundreds:
                return self.hundreds[mapped]
            if mapped in self.zeros:
                return 0
        return None

    def _parse_fraction_numerator(self, words: list[str], start: int) -> tuple[int, int] | None:
        """Parse 1-3 word cardinal numerator at words[start:].

        Handles "dwadzieścia trzy" -> 23, "czterysta pięćdziesiąt sześć" -> 456
        for fractions. Returns (value, length) or None.
        """
        n = len(words)
        # try 3-word hundreds + tens + ones (e.g. "czterysta pięćdziesiąt sześć" -> 456)
        if start + 2 < n:
            v1 = self._fraction_numerator_value(words[start])
            v2 = self._fraction_numerator_value(words[start + 1])
            v3 = self._fraction_numerator_value(words[start + 2])
            if v1 is not None and v2 is not None and v3 is not None:
                # hundreds 100-900 + tens 10-90 + ones 1-9
                if v1 in {100, 200, 300, 400, 500, 600, 700, 800, 900}:
                    if (
                        v2
                        in {10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 30, 40, 50, 60, 70, 80, 90}
                        and 1 <= v3 <= 9
                    ):
                        # need tens+ones distinct: e.g. 400+50+6, but also 100+20+3
                        # also handle 100+11+? but 11 already includes ones, so avoid double
                        if v2 < 20 and v3 < 10:
                            # v2 is 10-19, v3 would be extra ones -> invalid (e.g. 10 + 1)
                            pass
                        else:
                            return v1 + v2 + v3, 3
                    # hundreds + tens (e.g. "czterysta pięćdziesiąt" -> 450)
                    if v2 in {
                        10,
                        11,
                        12,
                        13,
                        14,
                        15,
                        16,
                        17,
                        18,
                        19,
                        20,
                        30,
                        40,
                        50,
                        60,
                        70,
                        80,
                        90,
                    } and v3 in {1, 2, 3, 4, 5, 6, 7, 8, 9}:
                        # already handled above as 3-word, but we try 3-word only if both
                        pass
                # also try hundreds + ones (e.g. "sto pięć" -> 105)
                if (
                    v1 in {100, 200, 300, 400, 500, 600, 700, 800, 900}
                    and 1 <= v3 <= 9
                    and v2 in {1, 2, 3, 4, 5, 6, 7, 8, 9}
                ):
                    # this is actually 2-word hundreds+ones, handled below, but 3-word with middle tens missing
                    pass
        # try 2-word
        if start + 1 < n:
            v1 = self._fraction_numerator_value(words[start])
            v2 = self._fraction_numerator_value(words[start + 1])
            if v1 is not None and v2 is not None:
                # tens 20-90 + ones 1-9
                if v1 in {20, 30, 40, 50, 60, 70, 80, 90} and 1 <= v2 <= 9:
                    return v1 + v2, 2
                # hundreds 100-900 + tens 10-90 or ones 1-9 or teens 10-19
                if v1 in {100, 200, 300, 400, 500, 600, 700, 800, 900} and v2 in {
                    1,
                    2,
                    3,
                    4,
                    5,
                    6,
                    7,
                    8,
                    9,
                    10,
                    11,
                    12,
                    13,
                    14,
                    15,
                    16,
                    17,
                    18,
                    19,
                    20,
                    30,
                    40,
                    50,
                    60,
                    70,
                    80,
                    90,
                }:
                    return v1 + v2, 2
                # hundreds + ones with tens missing already covered above, but also try 100+5
                # tens + ones already handled, also try teens + ones? not needed
        # try 3-word again for hundreds+tens+ones where we missed: do generic sum check
        if start + 2 < n:
            vals = [self._fraction_numerator_value(words[start + i]) for i in range(3)]
            if all(v is not None for v in vals):  # type: ignore[arg-type]
                v1, v2, v3 = vals  # type: ignore[assignment]
                # allow sum if values are descending magnitude and <1000
                # e.g. 400+50+6, 100+20+3, 200+11+? but 11 includes ones
                # simple check: v1 is hundreds, v2 is tens/10-19, v3 is ones, and v1>v2>v3
                if (
                    v1 in {100, 200, 300, 400, 500, 600, 700, 800, 900}
                    and v2
                    in {10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 30, 40, 50, 60, 70, 80, 90}
                    and v3 in {1, 2, 3, 4, 5, 6, 7, 8, 9}
                    and (v1 > v2 > v3 or (v1 > v2 and v2 >= 10 and v3 < 10))
                    and not (10 <= v2 <= 19 and v3 < 10)
                ):
                    return v1 + v2 + v3, 3
                # also 100+20+3 case already, but ensure
        # single word
        v = self._fraction_numerator_value(words[start])
        if v is not None:
            return v, 1
        return None

    def _convert_fractions(self, words: list[str]) -> list[str]:
        """Turn "jedna trzecia" -> "1/3", "trzy czwarte" -> "3/4", etc.

        Uses Morfeusz for declined numerators (e.g. "jednej trzeciej" -> "1/3",
        "dwóch trzecich" -> "2/3") and supports broader range (11-19) and
        multi-word numerators like "dwadzieścia trzy setne" -> "23/100".
        Requires numerator < denominator to avoid colliding with ordinal
        compounds like "sto dwudziesty" -> 120 (not 100/20).
        """
        result: list[str] = []
        i = 0
        n = len(words)
        while i < n:
            parsed = self._parse_fraction_numerator(words, i)
            if parsed is not None and i + parsed[1] < n:
                numerator, length = parsed
                denominator = self._fraction_denominator(words[i + length])
                if denominator is not None and numerator < denominator:
                    result.append(f"{numerator}/{denominator}")
                    i += length + 1
                    continue
            result.append(words[i])
            i += 1
        return result

    def __call__(self, s: str) -> str:
        s = self.preprocess(s)
        words = self._convert_fractions(s.split())
        words = [self._canonicalize(w) for w in words]
        s = " ".join(word for word in self.process_words(words) if word is not None and word != ".")
        s = self.postprocess(s)
        return s

PolishTextNormalizer

Source code in src/polish_whisper_normalizer/text.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
class PolishTextNormalizer:
    # pre-compiled patterns shared across instances
    _BRACKETS_RE = re.compile(r"<[^>]*>|\[[^\]]*\]")
    _PAREN_RE = re.compile(r"\([^)]*\)")
    _WS_RE = re.compile(r"\s+")
    _IGNORE_RE = re.compile(r"\b(?:eee+|yyy+|hmm+|mhm+|mmm+|uh+|um+)\b")
    _SENTENCE_PERIOD_RE = re.compile(r"(?<![\d.])\.(?!\.)([^0-9]|$)")
    _ELLIPSIS_RE = re.compile(r"\s*(?:\.\s*){2,}|\s*…\s*")
    _NUMBER_SEPARATOR_RE = re.compile(r"[,;!?—–]")
    _TRAILING_DIGIT_PERIOD_RE = re.compile(r"(?<=\d)\.(?=\s|$)")
    _R_ABBREV_RE = re.compile(r"\br\.?\s*(?=\d{3,4}\b)")
    _DECIMAL_COMMA_RE = re.compile(r"(\d),(\d)")
    _MONTH_DAY_RE = re.compile(r"(\d+)\.?\s+([a-ząćęłńóśźż]+)\b")
    _FULL_DATE_ROKU_BEFORE_RE = re.compile(r"(\d+)\.\s+(\d+)\s+(?:roku|r\.?)\s+(\d+)\.?")
    _FULL_DATE_ROKU_AFTER_RE = re.compile(r"(\d+)\.\s+(\d+)\s+(\d+)\.?\s+(?:roku|r\.?)\b")
    _FULL_DATE_RE = re.compile(r"(\d+)\.\s+(\d+)\s+(\d{4})\.?")
    _TRAILING_R_RE1 = re.compile(r"(\d{2}\.\d{2}\.\d{4})\s+r\.?\b")
    _TRAILING_R_RE2 = re.compile(r"(\d{2}\.\d{2}\.\d{4})r\.?\b")
    _DAY_MONTH_RE = re.compile(r"(\d+)\.\s+(\d+)\b(?!\.)")
    _PERCENT_RE = re.compile(r"([^0-9])%")
    _COLON_RE = re.compile(r"(?<!\d):|:(?!\d)")
    _SIGN_RE = re.compile(r"[-+](?!\d)")
    _WANTS_R_RE = re.compile(r"\br\.?\b")

    def __init__(self, date_format: str = "{day:02d}.{month:02d}.{year}", **kwargs: object) -> None:
        """
        Args:
            date_format: How to render full dates (day month year). Supports
                Python format with {day}, {month}, {year} (e.g. "{day:02d}.{month:02d}.{year}"
                or "{day}/{month}/{year}") and strftime with %d/%m/%Y
                (e.g. "%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Yr.").
                Default "{day:02d}.{month:02d}.{year}" -> "05.05.2026".
        """
        if kwargs:
            unexpected = ", ".join(sorted(kwargs.keys()))
            raise TypeError(f"Unexpected keyword arguments: {unexpected}")

        self.ignore_patterns = r"\b(?:eee+|yyy+|hmm+|mhm+|mmm+|uh+|um+)\b"
        self.standardize_numbers = PolishNumberNormalizer()
        self.standardize_time = PolishTimeNormalizer()
        self.date_format: str = date_format

    def _format_date(self, day: int, month: int, year: int) -> str:
        fmt = self.date_format
        has_brace = "{" in fmt and "}" in fmt
        has_percent = "%" in fmt
        # prefer explicit brace-format; if both present try brace first
        if has_brace:
            try:
                return fmt.format(day=day, month=month, year=year)
            except Exception as exc:
                logger.debug("brace date_format failed for %r: %s", fmt, exc)
                if not has_percent:
                    return f"{day:02d}.{month:02d}.{year}"
        if has_percent:
            try:
                return datetime.datetime(year, month, day).strftime(fmt)
            except Exception as exc:
                logger.debug("strftime date_format failed for %r: %s", fmt, exc)
        # fallback: brace format if not tried
        if not has_brace:
            try:
                return fmt.format(day=day, month=month, year=year)
            except Exception as exc:
                logger.debug("fallback brace format failed for %r: %s", fmt, exc)
        return f"{day:02d}.{month:02d}.{year}"

    def _month_number(self, word: str) -> str | None:
        # direct map is fastest
        if word in self.standardize_numbers.month_lemmas:
            return self.standardize_numbers.month_lemmas[word]
        for base, pos in self.standardize_numbers.lemmatizer.analyse(word):
            if pos == "subst" and base in self.standardize_numbers.month_lemmas:
                return self.standardize_numbers.month_lemmas[base]
        # fallback for ASCII-folded month (e.g. "styczen" -> "styczeń")
        mapped = self.standardize_numbers._declined_ascii_map.get(word)
        if mapped is not None and mapped in self.standardize_numbers.month_lemmas:
            return self.standardize_numbers.month_lemmas[mapped]
        return None

    def __call__(self, s: str) -> str:
        s = s.lower()

        s = self._BRACKETS_RE.sub("", s)  # remove words between brackets
        s = self._PAREN_RE.sub("", s)  # remove words between parenthesis
        s = self._IGNORE_RE.sub("", s)

        # expand the year abbreviation "r." / "r" to "roku" (w r. 1860 -> w roku 1860)
        s = self._R_ABBREV_RE.sub("roku ", s)

        # remove sentence periods before digits are introduced by time/number
        # normalization; keep decimals ("3.14"), ordinal markers ("21.") and
        # ellipses (handled as boundaries below). A boundary "." is emitted so
        # separate numerals ("10. 500") are not merged, then dropped by numbers.
        s = self._SENTENCE_PERIOD_RE.sub(r" . \1", s)
        s = self._ELLIPSIS_RE.sub(" . ", s)

        s = self.standardize_time(s)

        s = self._DECIMAL_COMMA_RE.sub(r"\1.\2", s)  # Polish decimal comma -> point
        # other punctuation separates numerals ("10, 500" -> "10 500") instead
        # of being erased (which would merge them into "10500")
        s = self._NUMBER_SEPARATOR_RE.sub(" . ", s)
        s = remove_symbols(
            s, keep=".:/%$€£¢+-"
        )  # keep numeric/time/sign/currency symbols + fraction slash

        s = self.standardize_numbers(s)

        # conditional month mapping: only when preceded by day (with or without dot)
        # ("3. maja" -> "3. 5", "5 maja" -> "5. 5", but "maja" alone or "w maju" stays)
        def _date_repl(m: re.Match[str]) -> str:
            day = m.group(1)
            month_word = m.group(2)
            month_num = self._month_number(month_word)
            if month_num:
                return f"{day}. {month_num}"
            return m.group(0)

        s = self._MONTH_DAY_RE.sub(_date_repl, s)

        # full date formatting: "5. 5 roku 2026." -> "05.05.2026", "5. 5 2026" -> "05.05.2026" (uniform, no r)
        def _full_date_roku_before(m: re.Match[str]) -> str:
            day, month, year = m.group(1), m.group(2), m.group(3)
            return self._format_date(int(day), int(month), int(year))

        def _full_date_roku_after(m: re.Match[str]) -> str:
            day, month, year = m.group(1), m.group(2), m.group(3)
            return self._format_date(int(day), int(month), int(year))

        def _full_date(m: re.Match[str]) -> str:
            day, month, year = m.group(1), m.group(2), m.group(3)
            return self._format_date(int(day), int(month), int(year))

        def _day_month(m: re.Match[str]) -> str:
            day, month = m.group(1), m.group(2)
            return f"{int(day):02d}.{int(month):02d}"

        # order matters: most specific first (handle roku and r. uniformly)
        s = self._FULL_DATE_ROKU_BEFORE_RE.sub(_full_date_roku_before, s)
        s = self._FULL_DATE_ROKU_AFTER_RE.sub(_full_date_roku_after, s)
        s = self._FULL_DATE_RE.sub(_full_date, s)
        # uniform output: strip trailing r/r. only if date_format does not request it
        # detect literal 'r' in format (e.g. "%d.%m.%Yr." or "{day} r.")
        _wants_r = (
            bool(self._WANTS_R_RE.search(self.date_format.lower())) if self.date_format else False
        )
        # legacy check for formats ending with r/r.
        if not _wants_r:
            _wants_r = (
                self.date_format.strip().lower().endswith("r.")
                or self.date_format.strip().lower().endswith(" r")
                or " r." in self.date_format.lower()
            )
        if not _wants_r:
            s = self._TRAILING_R_RE1.sub(r"\1", s)
            s = self._TRAILING_R_RE2.sub(r"\1", s)
        # day month without year -> 5. 5 -> 05.05 (as requested)
        # only when month is not ordinal (no trailing dot) – avoids "1. 2." -> "01.02."
        s = self._DAY_MONTH_RE.sub(_day_month, s)

        # ordinal dots are dropped at the end of the pipeline; strip the same
        # dot from already-digit input ("15." -> "15") for consistency
        s = self._TRAILING_DIGIT_PERIOD_RE.sub("", s)

        # remove leftover symbols that are not part of a number/time
        s = self._PERCENT_RE.sub(r"\1 ", s)
        s = self._COLON_RE.sub(" ", s)
        s = self._SIGN_RE.sub(" ", s)

        s = self._WS_RE.sub(" ", s)  # replace successive whitespaces with a space
        return s.strip()

__init__(date_format='{day:02d}.{month:02d}.{year}', **kwargs)

Parameters:

Name Type Description Default
date_format str

How to render full dates (day month year). Supports Python format with {day}, {month}, {year} (e.g. "{day:02d}.{month:02d}.{year}" or "{day}/{month}/{year}") and strftime with %d/%m/%Y (e.g. "%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Yr."). Default "{day:02d}.{month:02d}.{year}" -> "05.05.2026".

'{day:02d}.{month:02d}.{year}'
Source code in src/polish_whisper_normalizer/text.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def __init__(self, date_format: str = "{day:02d}.{month:02d}.{year}", **kwargs: object) -> None:
    """
    Args:
        date_format: How to render full dates (day month year). Supports
            Python format with {day}, {month}, {year} (e.g. "{day:02d}.{month:02d}.{year}"
            or "{day}/{month}/{year}") and strftime with %d/%m/%Y
            (e.g. "%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Yr.").
            Default "{day:02d}.{month:02d}.{year}" -> "05.05.2026".
    """
    if kwargs:
        unexpected = ", ".join(sorted(kwargs.keys()))
        raise TypeError(f"Unexpected keyword arguments: {unexpected}")

    self.ignore_patterns = r"\b(?:eee+|yyy+|hmm+|mhm+|mmm+|uh+|um+)\b"
    self.standardize_numbers = PolishNumberNormalizer()
    self.standardize_time = PolishTimeNormalizer()
    self.date_format: str = date_format

PolishTimeNormalizer

Convert spoken times into HH:MM format:

  • "piąta trzydzieści" -> "5:30"
  • "dwudziesta piętnaście" -> "20:15"
  • "wpół do ósmej" -> "7:30"
  • "za piętnaście ósma" -> "7:45"
  • "piętnaście po piątej" -> "5:15"
  • "godzina piętnasta trzydzieści" -> "15:30"
  • "północ" -> "0:00", "południe" -> "12:00"
Source code in src/polish_whisper_normalizer/time.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
class PolishTimeNormalizer:
    """
    Convert spoken times into HH:MM format:

    - "piąta trzydzieści" -> "5:30"
    - "dwudziesta piętnaście" -> "20:15"
    - "wpół do ósmej" -> "7:30"
    - "za piętnaście ósma" -> "7:45"
    - "piętnaście po piątej" -> "5:15"
    - "godzina piętnasta trzydzieści" -> "15:30"
    - "północ" -> "0:00", "południe" -> "12:00"
    """

    # class-level cache: built once, reused by all instances
    _CACHE: dict[str, object] | None = None
    _CACHE_LOCK = threading.Lock()

    def __init__(self) -> None:
        if PolishTimeNormalizer._CACHE is not None:
            # deepcopy mutable containers to avoid cross-instance mutation
            cached = PolishTimeNormalizer._CACHE
            for key, value in cached.items():
                if isinstance(value, dict | set | list):
                    self.__dict__[key] = copy.deepcopy(value)
                elif hasattr(value, "pattern"):  # compiled regex
                    self.__dict__[key] = value
                else:
                    self.__dict__[key] = value
            return

        self.hours = {
            "pierwsza": 1,
            "druga": 2,
            "trzecia": 3,
            "czwarta": 4,
            "piąta": 5,
            "szósta": 6,
            "siódma": 7,
            "ósma": 8,
            "dziewiąta": 9,
            "dziesiąta": 10,
            "jedenasta": 11,
            "dwunasta": 12,
            "trzynasta": 13,
            "czternasta": 14,
            "piętnasta": 15,
            "szesnasta": 16,
            "siedemnasta": 17,
            "osiemnasta": 18,
            "dziewiętnasta": 19,
            "dwudziesta": 20,
            "dwudziesta pierwsza": 21,
            "dwudziesta druga": 22,
            "dwudziesta trzecia": 23,
            "dwudziesta czwarta": 24,
        }
        self.hours_gen = {
            "pierwszej": 1,
            "drugiej": 2,
            "trzeciej": 3,
            "czwartej": 4,
            "piątej": 5,
            "szóstej": 6,
            "siódmej": 7,
            "ósmej": 8,
            "dziewiątej": 9,
            "dziesiątej": 10,
            "jedenastej": 11,
            "dwunastej": 12,
            "trzynastej": 13,
            "czternastej": 14,
            "piętnastej": 15,
            "szesnastej": 16,
            "siedemnastej": 17,
            "osiemnastej": 18,
            "dziewiętnastej": 19,
            "dwudziestej": 20,
            "dwudziestej pierwszej": 21,
            "dwudziestej drugiej": 22,
            "dwudziestej trzeciej": 23,
            "dwudziestej czwartej": 24,
        }

        self.minutes = self._build_minutes()
        self.minutes_ordinal = self._build_minutes_ordinal()
        # expand with ASCII variants for diacritic-less ASR
        self.hours = with_ascii_variants(self.hours)
        self.hours_gen = with_ascii_variants(self.hours_gen)
        self.minutes = with_ascii_variants(self.minutes)
        self.minutes_ordinal = with_ascii_variants(self.minutes_ordinal)

        hours_alt = self._alternation(self.hours)
        hours_gen_alt = self._alternation(self.hours_gen)
        minutes_alt = self._alternation(self.minutes)
        minutes_ordinal_alt = self._alternation(self.minutes_ordinal)
        # literals with diacritics also need ASCII variants (diacritic-less ASR)
        wpol_pat = r"(?:wpół|wpol)"
        # build midnight/noon forms via Morfeusz (all declensions) + ASCII
        polnoc_forms, poludnie_forms = self._build_midnight_forms()
        # fallback hardcoded if Morfeusz unavailable (should not happen in prod)
        if not polnoc_forms:
            polnoc_forms = {
                "północ",
                "północy",
                "północą",
                "północe",
                "północom",
                "północami",
                "północach",
            }
        if not poludnie_forms:
            poludnie_forms = {
                "południe",
                "południa",
                "południowi",
                "południu",
                "południem",
                "południach",
                "południom",
                "południami",
            }
        # expand ASCII for midnight sets (strip_diacritics)
        polnoc_forms_expanded: set[str] = set()
        for f in polnoc_forms:
            polnoc_forms_expanded.add(f)
            polnoc_forms_expanded.add(strip_diacritics(f))
        poludnie_forms_expanded: set[str] = set()
        for f in poludnie_forms:
            poludnie_forms_expanded.add(f)
            poludnie_forms_expanded.add(strip_diacritics(f))
        # build alternations sorted by length desc
        polnoc_alt = self._alternation_set(polnoc_forms_expanded)
        poludnie_alt = self._alternation_set(poludnie_forms_expanded)
        # keep for __call__ (geographic vs time)
        self._polnoc_forms = polnoc_forms_expanded
        self._poludnie_forms = poludnie_forms_expanded
        self._polnoc_alt = polnoc_alt
        self._poludnie_alt = poludnie_alt

        # legacy single-form patterns kept for reference
        self._polnoc_pat = r"(?:północ|polnoc)"
        self._poludnie_pat = r"(?:południe|poludnie)"

        self._re_wpol = re.compile(r"\b" + wpol_pat + r"\s+do\s+(" + hours_gen_alt + r")\b")
        self._re_za = re.compile(r"\bza\s+(" + minutes_alt + r")\s+(" + hours_alt + r")\b")
        self._re_po = re.compile(r"\b(" + minutes_alt + r")\s+po\s+(" + hours_gen_alt + r")\b")
        self._re_godzina_min = re.compile(
            r"\bgodzina\s+(" + hours_alt + r")\s+(" + minutes_alt + r")\b"
        )
        self._re_godzina = re.compile(r"\bgodzina\s+(" + hours_alt + r")\b")
        self._re_o_godzinie_ord = re.compile(
            r"\bo\s+godzinie\s+(" + hours_gen_alt + r")\s+(" + minutes_ordinal_alt + r")\b"
        )
        self._re_o_godzinie_hour = re.compile(
            r"\bo\s+godzinie\s+(" + hours_gen_alt + r")\b(?!\s*(?:" + minutes_ordinal_alt + r")\b)"
        )
        self._re_godzina_digits = re.compile(r"\bgodzina\s+(\d{1,2})[.:,](\d{2})\b")
        self._re_o_godzinie_digits = re.compile(r"\bo\s+godzinie\s+(\d{1,2})[.:,](\d{2})\b")
        self._re_godzina_digit_hour = re.compile(r"\bgodzina\s+(\d{1,2})\b(?!\s*[:.,]\d)")
        self._re_o_godzinie_digit_hour = re.compile(r"\bo\s+godzinie\s+(\d{1,2})\b(?!\s*[:.,]\d)")
        self._re_hour_min = re.compile(r"\b(" + hours_alt + r")\s+(" + minutes_alt + r")\b")
        self._re_hour_gen_min = re.compile(r"\b(" + hours_gen_alt + r")\s+(" + minutes_alt + r")\b")
        # "o piątej" -> "o 5:00" (genitive hour, not followed by a word)
        self._re_o_hour = re.compile(r"\bo\s+(" + hours_gen_alt + r")\b(?!\s*[a-ząćęłńóśźż])")
        # time-of-day markers (Morfeusz-inspired, covers rano + wieczorem/nocy/południu)
        marker_phrases = [
            "rano",
            "wieczorem",
            "w nocy",
            "nocą",
            "nad ranem",
            "w dzień",
        ]
        marker_set: set[str] = set()
        for phrase in marker_phrases:
            phrase_lc = phrase.lower()
            marker_set.add(phrase_lc)
            stripped = strip_diacritics(phrase_lc)
            if stripped != phrase_lc:
                marker_set.add(stripped)
        # also ensure ascii variants for phrases containing diacritics are added
        # e.g. "w dzień" -> "w dzien"
        marker_alt = self._alternation_set(marker_set)
        self._marker_alt = marker_alt
        # hour + marker ("piąta rano", "ósma wieczorem" -> "5:00 rano")
        # also genitive hour + marker ("o piątej rano" is handled separately)
        self._re_hour_marker = re.compile(r"\b(" + hours_alt + r")\s+(" + marker_alt + r")\b")
        self._re_o_hour_marker = re.compile(
            r"\bo\s+(" + hours_gen_alt + r")\s+(" + marker_alt + r")\b"
        )
        # keep legacy rano regexes for backwards compat (they are now subset of marker)
        self._re_hour_rano = re.compile(r"\b(" + hours_alt + r")\s+rano\b")
        self._re_o_hour_rano = re.compile(r"\bo\s+(" + hours_gen_alt + r")\s+rano\b")
        # variants with an explicit "minut(ę/y)" word – include ASCII "minutę" -> "minute"
        self._re_za_minut = re.compile(
            r"\bza\s+(" + minutes_alt + r")\s+minut(?:a|ę|e|y)?\s+(" + hours_alt + r")\b"
        )
        self._re_po_minut = re.compile(
            r"\b(" + minutes_alt + r")\s+minut(?:a|ę|e|y)?\s+po\s+(" + hours_gen_alt + r")\b"
        )
        # "od piątej do szóstej" -> "od 5:00 do 6:00"
        self._re_range = re.compile(
            r"\bod\s+(" + hours_gen_alt + r")\s+do\s+(" + hours_gen_alt + r")\b"
        )
        # midnight/noon regexes: contextual (prep + declined) + standalone nominative
        # only declined forms with appropriate preposition should become time; bare "północy" stays word (see test)
        # include ASCII-folded variants for prepositions (około -> okolo, była -> byla, etc.)
        time_prep = r"(o|przed|po|do|od|około|okolo|w|jest|była|byla|było|bylo|był|byl|były|byly)"
        self._re_polnoc_context = re.compile(r"\b" + time_prep + r"\s+(?:" + polnoc_alt + r")\b")
        self._re_poludnie_context = re.compile(
            r"\b" + time_prep + r"\s+(?:" + poludnie_alt + r")\b"
        )
        self._re_polnoc_nominative = re.compile(r"\b(?:północ|polnoc)\b")
        self._re_poludnie_nominative = re.compile(r"\b(?:południe|poludnie)\b")
        # keep broad regex for any internal use but not in pipeline (avoid bare declined conversion)
        self._re_polnoc = re.compile(r"\b(?:" + polnoc_alt + r")\b")
        self._re_poludnie = re.compile(r"\b(?:" + poludnie_alt + r")\b")
        # geographic protection: preposition + północ/południe (any declined form)
        geo_preps = r"(?:na|z|od|do|w|ku|kierunek|strona|część|czesc|północno|polnocno|południowo|poludniowo|pod)"
        self._re_geo_polnoc = re.compile(r"\b" + geo_preps + r"\s+(?:" + polnoc_alt + r")\b")
        self._re_geo_poludnie = re.compile(r"\b" + geo_preps + r"\s+(?:" + poludnie_alt + r")\b")
        # broader geographic: "na północy", "z północy", "od północy" etc already covered,
        # but also "północny" adjectives should stay words – no conversion via midnight regex (word boundary)

        # cache for next instance (thread-safe)
        with PolishTimeNormalizer._CACHE_LOCK:
            if PolishTimeNormalizer._CACHE is None:
                PolishTimeNormalizer._CACHE = dict(self.__dict__)

    @staticmethod
    def _build_midnight_forms() -> tuple[set[str], set[str]]:
        """Collect midnight/noon declined forms via Morfeusz generate."""
        try:
            from .lemmatizer import PolishLemmatizer

            lem = PolishLemmatizer()
            polnoc: set[str] = set()
            poludnie: set[str] = set()
            if lem.morf is not None:
                for base, target in (("północ", polnoc), ("południe", poludnie)):
                    try:
                        forms = lem.generate(base)
                    except Exception:
                        continue
                    for surf, lemma, _tag in forms:
                        if lemma.split(":")[0] != base:
                            # allow "północ:..." but ignore abbreviations like "pn"
                            if len(surf) <= 2:
                                continue
                            # still check base
                            continue
                        if len(surf) <= 2:  # skip abbrev "pn", "pd"
                            continue
                        pol = surf.lower()
                        target.add(pol)
                # ensure base forms present even if generate failed partially
                polnoc.add("północ")
                poludnie.add("południe")
                return polnoc, poludnie
        except Exception:
            pass
        return set(), set()

    @staticmethod
    def _ones_words() -> list[str]:
        return [
            "zero",
            "jeden",
            "dwa",
            "trzy",
            "cztery",
            "pięć",
            "sześć",
            "siedem",
            "osiem",
            "dziewięć",
        ]

    def _build_minutes(self) -> dict[str, int]:
        ones = self._ones_words()
        teens = {
            10: "dziesięć",
            11: "jedenaście",
            12: "dwanaście",
            13: "trzynaście",
            14: "czternaście",
            15: "piętnaście",
            16: "szesnaście",
            17: "siedemnaście",
            18: "osiemnaście",
            19: "dziewiętnaście",
        }
        tens = {
            20: "dwadzieścia",
            30: "trzydzieści",
            40: "czterdzieści",
            50: "pięćdziesiąt",
            60: "sześćdziesiąt",
            70: "siedemdziesiąt",
            80: "osiemdziesiąt",
            90: "dziewięćdziesiąt",
        }
        kwadrans = {"kwadrans": 15}

        def cardinal(n: int) -> str:
            if n < 10:
                return ones[n]
            if n < 20:
                return teens[n]
            t = n // 10 * 10
            o = n % 10
            if o == 0:
                return tens[t]
            return tens[t] + " " + ones[o]

        minutes: dict[str, int] = {}
        for m in range(60):
            minutes[cardinal(m)] = m
        for d in range(10):
            minutes["zero " + ones[d]] = d
        minutes.update(kwadrans)
        # feminine cardinal forms used with "minuta/minuty"
        minutes["jedna"] = 1
        minutes["dwie"] = 2
        return minutes

    @staticmethod
    def _build_minutes_ordinal() -> dict[str, int]:
        """Genitive feminine ordinals used for minutes ("szesnastej piątej" -> 16:05)."""
        units = [
            "pierwszej",
            "drugiej",
            "trzeciej",
            "czwartej",
            "piątej",
            "szóstej",
            "siódmej",
            "ósmej",
            "dziewiątej",
            "dziesiątej",
            "jedenastej",
            "dwunastej",
            "trzynastej",
            "czternastej",
            "piętnastej",
            "szesnastej",
            "siedemnastej",
            "osiemnastej",
            "dziewiętnastej",
        ]
        tens = {
            20: "dwudziestej",
            30: "trzydziestej",
            40: "czterdziestej",
            50: "pięćdziesiątej",
        }
        result: dict[str, int] = {}
        for m in range(1, 60):
            if m <= 19:
                result[units[m - 1]] = m
            else:
                t = m // 10 * 10
                o = m % 10
                if o == 0:
                    result[tens[t]] = m
                else:
                    result[f"{tens[t]} {units[o - 1]}"] = m
        return result

    @staticmethod
    def _norm_phrase(s: str) -> str:
        return " ".join(s.split())

    @staticmethod
    def _alternation(mapping: dict[str, int]) -> str:
        keys = sorted(mapping.keys(), key=lambda w: (-len(w), -w.count(" ")))

        def _escape_phrase(phrase: str) -> str:
            # allow flexible whitespace between words (single vs double spaces)
            return r"\s+".join(re.escape(part) for part in phrase.split())

        return "|".join(_escape_phrase(k) for k in keys)

    @staticmethod
    def _alternation_set(values: set[str]) -> str:
        keys = sorted(values, key=lambda w: (-len(w), -w.count(" ")))

        def _escape_phrase(phrase: str) -> str:
            return r"\s+".join(re.escape(part) for part in phrase.split())

        return "|".join(_escape_phrase(k) for k in keys)

    def __call__(self, s: str) -> str:
        s = self._re_godzina_digits.sub(self._godzina_digits_repl, s)
        s = self._re_o_godzinie_digits.sub(self._o_godzinie_digits_repl, s)
        s = self._re_godzina_digit_hour.sub(self._godzina_digit_hour_repl, s)
        s = self._re_o_godzinie_digit_hour.sub(self._o_godzinie_digit_hour_repl, s)
        s = self._re_o_godzinie_hour.sub(self._o_godzinie_hour_repl, s)
        s = self._re_o_godzinie_ord.sub(self._o_godzinie_ord_repl, s)
        s = self._re_wpol.sub(
            lambda m: f"{(self.hours_gen[self._norm_phrase(m.group(1))] - 1) % 24}:30", s
        )
        # handle time-of-day markers: generic marker before narrow "rano" to capture all
        s = self._re_o_hour_marker.sub(self._o_hour_marker_repl, s)
        s = self._re_hour_marker.sub(self._hour_marker_repl, s)
        # legacy rano handlers (kept for compat, now redundant but harmless)
        s = self._re_o_hour_rano.sub(self._o_hour_rano_repl, s)
        s = self._re_hour_rano.sub(self._hour_rano_repl, s)

        # protect geographic "na północ/południe" (south/north) – keep as words
        # only convert time midnight/noon when not geographic
        _geo_map: dict[str, str] = {}

        def _protect_geo(m: re.Match[str]) -> str:
            # use UUID placeholder that cannot collide with user input
            # (no word chars that would match time patterns)
            key = f"__GEO_{uuid.uuid4().hex}__"
            _geo_map[key] = m.group(0)
            return key

        # geographic prepositions + północ/południe should stay (include ASCII variants and declensions via Morfeusz)
        # need to protect both midnight forms
        # we use two regexes but need to protect combined: first try polnoc then poludnie
        s = self._re_geo_polnoc.sub(_protect_geo, s)
        s = self._re_geo_poludnie.sub(_protect_geo, s)
        # fallback legacy pattern (covers simple "na północ" if Morfeusz forms miss)
        s = re.sub(
            r"\b(na|z|od|do|w|ku|kierunek|strona|część|czesc|północno|polnocno|południowo|poludniowo)\s+(północ|polnoc|południe|poludnie)\b",
            _protect_geo,
            s,
        )
        # convert midnight/noon: contextual prep+form and standalone nominative; bare declined like "północy" stays word
        s = self._re_polnoc_context.sub(lambda m: f"{m.group(1)} 0:00", s)
        s = self._re_poludnie_context.sub(lambda m: f"{m.group(1)} 12:00", s)
        s = self._re_polnoc_nominative.sub("0:00", s)
        s = self._re_poludnie_nominative.sub("12:00", s)
        for k, v in _geo_map.items():
            s = s.replace(k, v)
        s = self._re_za.sub(self._za_repl, s)
        s = self._re_po.sub(self._po_repl, s)
        s = self._re_godzina_min.sub(self._godzina_min_repl, s)
        s = self._re_godzina.sub(self._godzina_repl, s)
        s = self._re_hour_min.sub(self._hour_min_repl, s)
        s = self._re_hour_gen_min.sub(self._hour_gen_min_repl, s)
        s = self._re_o_hour.sub(self._o_hour_repl, s)
        s = self._re_za_minut.sub(self._za_repl, s)
        s = self._re_po_minut.sub(self._po_repl, s)
        s = self._re_range.sub(self._range_repl, s)
        return s

    def _za_repl(self, m: re.Match[str]) -> str:
        minute = self.minutes[self._norm_phrase(m.group(1))]
        hour = self.hours[self._norm_phrase(m.group(2))]
        if minute <= 0 or minute >= 60:
            return m.group(0)
        return f"{(hour - 1) % 24}:{60 - minute:02d}"

    def _po_repl(self, m: re.Match[str]) -> str:
        minute = self.minutes[self._norm_phrase(m.group(1))]
        hour = self.hours_gen[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _godzina_min_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        minute = self.minutes[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _godzina_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        return f"{hour}:00"

    def _hour_min_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        minute = self.minutes[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _hour_gen_min_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        minute = self.minutes[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _o_hour_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        return f"o {hour}:00"

    def _o_hour_rano_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        return f"o {hour}:00 rano"

    def _hour_rano_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        return f"{hour}:00 rano"

    def _o_hour_marker_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        marker = self._norm_phrase(m.group(2))
        return f"o {hour}:00 {marker}"

    def _hour_marker_repl(self, m: re.Match[str]) -> str:
        hour = self.hours[self._norm_phrase(m.group(1))]
        marker = self._norm_phrase(m.group(2))
        return f"{hour}:00 {marker}"

    def _range_repl(self, m: re.Match[str]) -> str:
        start = self.hours_gen[self._norm_phrase(m.group(1))]
        end = self.hours_gen[self._norm_phrase(m.group(2))]
        return f"od {start}:00 do {end}:00"

    def _godzina_digits_repl(self, m: re.Match[str]) -> str:
        hour, minute = int(m.group(1)), int(m.group(2))
        if hour > 24 or minute > 59:
            return m.group(0)
        return f"{hour}:{minute:02d}"

    def _o_godzinie_digits_repl(self, m: re.Match[str]) -> str:
        hour, minute = int(m.group(1)), int(m.group(2))
        if hour > 24 or minute > 59:
            return m.group(0)
        return f"o {hour}:{minute:02d}"

    def _godzina_digit_hour_repl(self, m: re.Match[str]) -> str:
        hour = int(m.group(1))
        if hour > 24:
            return m.group(0)
        return f"{hour}:00"

    def _o_godzinie_digit_hour_repl(self, m: re.Match[str]) -> str:
        hour = int(m.group(1))
        if hour > 24:
            return m.group(0)
        return f"o {hour}:00"

    def _o_godzinie_ord_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        minute = self.minutes_ordinal[self._norm_phrase(m.group(2))]
        return f"o {hour}:{minute:02d}"

    def _o_godzinie_hour_repl(self, m: re.Match[str]) -> str:
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        return f"o {hour}:00"

strip_diacritics(word)

ASCII-fold a single word (like remove_symbols_and_diacritics but without adding spaces).

Source code in src/polish_whisper_normalizer/utils.py
13
14
15
def strip_diacritics(word: str) -> str:
    """ASCII-fold a single word (like remove_symbols_and_diacritics but without adding spaces)."""
    return remove_symbols_and_diacritics(word)

with_ascii_variants(mapping)

Return a copy of mapping extended with ASCII-folded keys for diacritic forms.

Source code in src/polish_whisper_normalizer/utils.py
18
19
20
21
22
23
24
25
def with_ascii_variants(mapping: dict[str, Any]) -> dict[str, Any]:
    """Return a copy of *mapping* extended with ASCII-folded keys for diacritic forms."""
    expanded: dict[str, Any] = dict(mapping)
    for key, value in list(mapping.items()):
        stripped = strip_diacritics(key)
        if stripped != key and stripped not in expanded:
            expanded[stripped] = value
    return expanded

with_ascii_variants_set(values)

Return a copy of values extended with ASCII-folded variants.

Source code in src/polish_whisper_normalizer/utils.py
28
29
30
31
32
33
34
35
def with_ascii_variants_set(values: set[str]) -> set[str]:
    """Return a copy of *values* extended with ASCII-folded variants."""
    expanded = set(values)
    for key in list(values):
        stripped = strip_diacritics(key)
        if stripped != key:
            expanded.add(stripped)
    return expanded

polish_whisper_normalizer.jiwer

jiwer integration for Polish Whisper Normalizer.

PolishTransform

Bases: AbstractTransform

jiwer transform that applies :class:PolishTextNormalizer.

Use it inside a Compose that ends with ReduceToListOfListOfWords, or use the ready-made :data:polish_transform / :func:wer helpers:

Example

import jiwer from polish_whisper_normalizer.jiwer import PolishTransform tr = jiwer.Compose([PolishTransform(), jiwer.RemoveMultipleSpaces(), jiwer.Strip(), jiwer.ReduceToListOfListOfWords()]) jiwer.wer("piątego maja 2026", "05.05.2026", ... reference_transform=tr, hypothesis_transform=tr) 0.0 from polish_whisper_normalizer.jiwer import wer wer("piątego maja 2026", "05.05.2026") 0.0

Source code in src/polish_whisper_normalizer/jiwer.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class PolishTransform(jiwer.transforms.AbstractTransform):
    """jiwer transform that applies :class:`PolishTextNormalizer`.

    Use it **inside a Compose that ends with** ``ReduceToListOfListOfWords``,
    or use the ready-made :data:`polish_transform` / :func:`wer` helpers:

    Example:
        >>> import jiwer
        >>> from polish_whisper_normalizer.jiwer import PolishTransform
        >>> tr = jiwer.Compose([PolishTransform(), jiwer.RemoveMultipleSpaces(), jiwer.Strip(), jiwer.ReduceToListOfListOfWords()])
        >>> jiwer.wer("piątego maja 2026", "05.05.2026",
        ...           reference_transform=tr, hypothesis_transform=tr)
        0.0
        >>> from polish_whisper_normalizer.jiwer import wer
        >>> wer("piątego maja 2026", "05.05.2026")
        0.0
    """

    def __init__(
        self,
        normalizer: PolishTextNormalizer | None = None,
        date_format: str | None = None,
        **kwargs: object,
    ) -> None:
        # Support legacy `PolishTransform(date_format="...")` and explicit normalizer
        if kwargs and date_format is None:
            # allow date_format via kwargs for backwards compat
            if "date_format" in kwargs and isinstance(kwargs["date_format"], str):
                date_format = kwargs["date_format"]  # type: ignore[assignment]
                kwargs.pop("date_format")
            if kwargs:
                unexpected = ", ".join(sorted(kwargs.keys()))
                raise TypeError(f"Unexpected keyword arguments: {unexpected}")
        elif kwargs:
            unexpected = ", ".join(sorted(kwargs.keys()))
            raise TypeError(f"Unexpected keyword arguments: {unexpected}")

        self._normalizer: PolishTextNormalizer | None = normalizer
        self._date_format: str | None = date_format

    @property
    def normalizer(self) -> PolishTextNormalizer:
        if self._normalizer is None:
            if self._date_format is not None:
                self._normalizer = PolishTextNormalizer(date_format=self._date_format)
            else:
                self._normalizer = PolishTextNormalizer()
        return self._normalizer

    @normalizer.setter
    def normalizer(self, value: PolishTextNormalizer) -> None:
        self._normalizer = value

    def process_string(self, s: str) -> str:
        return self.normalizer(s)

wer(reference, hypothesis, date_format=None, **kwargs)

WER with Polish normalization (words→digits, dates, time, etc.).

Wraps :func:jiwer.wer with :data:polish_transform on both sides. Pass date_format="..." to customize dates.

Example

from polish_whisper_normalizer.jiwer import wer wer("piątego maja 2026", "05.05.2026") 0.0

Source code in src/polish_whisper_normalizer/jiwer.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def wer(
    reference: str | list[str],
    hypothesis: str | list[str],
    date_format: str | None = None,
    **kwargs: object,
) -> float:
    """WER with Polish normalization (words→digits, dates, time, etc.).

    Wraps :func:`jiwer.wer` with :data:`polish_transform` on both sides.
    Pass ``date_format="..."`` to customize dates.

    Example:
        >>> from polish_whisper_normalizer.jiwer import wer
        >>> wer("piątego maja 2026", "05.05.2026")
        0.0
    """
    if kwargs and date_format is None and "date_format" in kwargs:
        date_format = kwargs.pop("date_format")  # type: ignore[assignment]
        if not isinstance(date_format, str):
            raise TypeError("date_format must be str")
    if kwargs:
        unexpected = ", ".join(sorted(kwargs.keys()))
        raise TypeError(f"Unexpected keyword arguments: {unexpected}")

    if date_format is not None:
        tr = jiwer.Compose(
            [
                PolishTransform(date_format=date_format),
                jiwer.RemoveMultipleSpaces(),
                jiwer.Strip(),
                jiwer.ReduceToListOfListOfWords(),
            ]
        )
        return jiwer.wer(reference, hypothesis, reference_transform=tr, hypothesis_transform=tr)
    return jiwer.wer(
        reference,
        hypothesis,
        reference_transform=polish_transform,
        hypothesis_transform=polish_transform,
    )