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.
    """
    s = unicodedata.normalize("NFKC", s).replace("⁄", "/")
    return "".join(
        c if c in keep else (" " if unicodedata.category(c)[0] in "MSP" else c) for c in 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
105
106
107
108
109
110
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
        if len(self._cache) > 20000:
            for k in list(self._cache.keys())[:10000]:
                del self._cache[k]
        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))
        if len(self._gen_cache) > 5000:
            for k in list(self._gen_cache.keys())[:2500]:
                del self._gen_cache[k]
        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
81
82
83
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
    if len(self._cache) > 20000:
        for k in list(self._cache.keys())[:10000]:
            del self._cache[k]
    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
 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
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))
    if len(self._gen_cache) > 5000:
        for k in list(self._gen_cache.keys())[:2500]:
            del self._gen_cache[k]
    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
  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
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
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)
            # NOTE: "sie" (sierpień) removed – collides with the reflexive
            # pronoun "się"/"sie" (diacritic-less ASR), causing false dates
            # ("5 sie" pronoun -> "05.08"). Use full "sierpień"/"sierpnia".
            "sty": "1",
            "lut": "2",
            "mar": "3",
            "kwi": "4",
            "cze": "6",
            "lip": "7",
            "sier": "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]
        if len(self._canon_cache) > 20000:
            for k in list(self._canon_cache.keys())[:10000]:
                del self._canon_cache[k]
        self._canon_cache[word] = result
        return result

    def process_words(self, words: list[str], orig_words: list[str] | None = None) -> 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

        padded: list[str | None] = [None, *words, None]
        orig_padded: list[str | None] = (
            [None, *orig_words, None] if orig_words is not None else padded
        )
        for idx in range(len(padded) - 2):
            prev, current, next = padded[idx], padded[idx + 1], padded[idx + 2]
            orig_current = orig_padded[idx + 1]
            if orig_current is None:
                orig_current = current
            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:
                if current == "piec":
                    # "piec" collides with verb (to bake) and noun (oven).
                    # Only treat as 5 in a numeric context or when single-word utterance.
                    is_numeric_ctx = (
                        (
                            prev is not None
                            and (
                                prev in self.words
                                or self._NUMERIC_PREFIX_RE.match(prev) is not None
                            )
                        )
                        or (next is not None and (next in self.words or next_is_numeric))
                        or len(words) == 1
                    )
                    if not is_numeric_ctx:
                        if value is not None:
                            yield output(value)  # type: ignore[arg-type]
                        yield output(current)  # type: ignore[arg-type]
                        continue
                # Pronominal "jeden" (not numeral): "wszystko mi było jedno",
                # "chciałem tylko jednego" stays a word (bug 7, like
                # "Maja"/"na północ" guards). Preserve original inflection
                # ("jedną" stays "jedną", not lemmatized to "jeden").
                if current in {"jeden", "jedna", "jedno"} and prev in {
                    "było",
                    "bylo",
                    "była",
                    "byla",
                    "jest",
                    "tylko",
                    "mi",
                }:
                    if value is not None:
                        yield output(value)  # type: ignore[arg-type]
                    yield output(orig_current)  # type: ignore[arg-type]
                    continue
                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]
                    elif isinstance(value, str) and "." in value:
                        # decimal fraction after "przecinek/kropka"
                        # ("3."+"14" -> "3.14", "0."+"5" -> "0.5",
                        # "3.1"+"4" -> "3.14"): always glue
                        value = str(value) + str(ones)
                    elif ones < 10:
                        # digit dictation ("jeden dwa trzy" -> "123") only for
                        # single digits with small values; larger chunks start
                        # new numerals (bug 5: "701"+"12" -> "701 12").
                        try:
                            iv = int(value)  # type: ignore[arg-type]
                        except (ValueError, TypeError):
                            iv = None
                        if iv is not None and iv < 100:
                            value = str(value) + str(ones)
                        else:
                            yield output(value)  # type: ignore[arg-type]
                            value = ones
                    else:
                        # teens (10-19) never glue as dictation; separate
                        yield output(value)  # type: ignore[arg-type]
                        value = ones
                elif ones < 10:
                    if value % 10 == 0:
                        value += ones
                    elif value < 100:  # type: ignore[operator]
                        value = str(value) + str(ones)
                    else:
                        yield output(value)  # type: ignore[arg-type]
                        value = ones
                else:  # eleven to nineteen
                    if value % 100 == 0:
                        value += ones
                    else:
                        yield output(value)  # type: ignore[arg-type]
                        value = ones
            elif current in self.tens:
                tens = self.tens[current]
                if value is None:
                    value = tens
                elif isinstance(value, str):
                    if "." in value:
                        # decimal ("0."+"20" -> "0.20"): glue
                        value = str(value) + str(tens)
                    else:
                        # dictation string + tens starts a new numeral
                        # (bug 5: avoid "12"+"40" -> "1240")
                        yield output(value)  # type: ignore[arg-type]
                        value = 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):
                    if "." in value:
                        value = str(value) + str(hundred)
                    else:
                        # dictation string + hundreds starts a new numeral
                        yield output(value)  # type: ignore[arg-type]
                        value = hundred
                elif value % 1000 == 0:
                    value += hundred
                elif value >= 100:
                    # invalid order with large value ("340"+"800") -> separate
                    # (bug 5: avoid 21-digit tokens; "340 800" not "340800")
                    yield output(value)  # type: ignore[arg-type]
                    value = 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
                    if residual == 0:
                        # bare multiplier after a complete thousand-group
                        # ("tysiąc tysięcy", "dwa miliony tysiąc"): do not
                        # drop the second multiplier (old code computed
                        # before+0*M == before). Split into separate numerals
                        # to preserve information and avoid inventing huge
                        # tokens (bug 5 philosophy).
                        yield output(value)  # type: ignore[arg-type]
                        value = multiplier
                    else:
                        value = before + residual * multiplier
            elif current in self.ones_ordinal:
                ones = self.ones_ordinal[current]
                if value is None:
                    ordinal = True
                    yield output(ones)
                elif isinstance(value, str):
                    ordinal = True
                    yield output(str(value) + str(ones))
                elif ones < 10:
                    if value % 10 == 0 and value >= 10:
                        ordinal = True
                        yield output(str(value + ones))
                    else:
                        yield output(value)
                        ordinal = True
                        yield output(ones)
                else:  # 11-19
                    if value % 100 == 0 and value >= 100:
                        ordinal = True
                        yield output(str(value + ones))
                    else:
                        yield output(value)
                        ordinal = True
                        yield output(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():
                    # standalone currency without amount: keep original
                    # inflection ("złotówkę" stays, "procentach" stays) instead
                    # of lemmatized artifact.
                    yield output(orig_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(orig_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:
        s = normalize_vulgar_fractions(s)
        # join hyphenated compound words with prefixes "pół-", "ćwierć-":
        # "pół-finał" -> "półfinał", "ćwierć-finał" -> "ćwierćfinał", "pół-żartem" -> "półżartem"
        s = re.sub(r"\b(pół|pol|ćwierć|cwierc)-\s*([a-ząćęłńóśźż]+)\b", r"\1\2", s)
        # 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*\.){2,}\s*|\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(?!-[a-ząćęłńóśźż])", "jeden przecinek pięć", s)
        s = re.sub(r"\b(?:półtorej|poltorej)\b(?!-[a-ząćęłńóśźż])", "jeden przecinek pięć", s)
        # standalone "pół" (half) -> "0.5"
        # Protect hyphenated compound words (e.g. "pół-finał", "pół-żartem", "pół-serio")
        s = re.sub(r"(?<!-)\b(?:pół|pol)\b(?!-[a-ząćęłńóśźż])", "zero przecinek pięć", s)
        # quarter: "ćwierć" (one quarter) -> "0.25", "i ćwierć" -> "+0.25"
        # (ASCII "cwierc" for diacritic-less ASR). Only singular nominative;
        # plural "ćwierci" stays a word (like "zerowych").
        # Protect hyphenated compounds (e.g. "ćwierć-finał")
        s = re.sub(r"\bi\s+(?:ćwierć|cwierc)\b(?!-)", "przecinek dwa pięć", s)
        s = re.sub(r"(?<!-)\b(?:ćwierć|cwierc)\b(?!-[a-ząćęłńóśźż])", "zero przecinek dwa pięć", s)

        # normalize currency symbols to follow the amount ("€10" -> "10 €",
        # "10€" -> "10 €", "$1.50" -> "1.50 $").
        # Only when the symbol is a true prefix (not already a suffix of a
        # previous amount): "5 $ 18 ¢" must NOT become "5 18 $ ¢" (bug 3).
        def _currency_prefix_repl(m: re.Match[str]) -> str:
            prev = m.string[: m.start()].rstrip()[-1:] if m.string[: m.start()].rstrip() else ""
            if prev.isdigit():
                return m.group(0)
            return f"{m.group(2)} {m.group(1)}"

        s = re.sub(r"([€$£¢])\s*(\d+(?:\.\d+)?)", _currency_prefix_repl, 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)
        # Normalize signed zero ("-0", "+0", "-0.0" -> "0"): negative zero
        # equals zero and otherwise breaks WER convergence ("minus zero").
        s = re.sub(r"(?<![\d.:/-])([+-])0+(?:\.0+)?(?![\d.:/-])", "0", s)
        # Strip leading zeros for idempotency ("007" -> "7", "005" -> "5"):
        # word-form "zero zero siedem" glues to "007" on pass 1 but digit
        # "007" goes via Fraction to 7, so N(N(x)) != N(x) (bug 6a).
        # Preserve dates (05.05, 05.05.2026, 2026-05-05, 5/5/2026) and times
        # (0:00, 12:00): no stripping adjacent to ".", ":", "-", "/".
        s = re.sub(r"(?<![\d.:/-])([+-]?)0+(\d)(?![\d.:/-])", r"\1\2", s)
        # Collapse "00" alone -> "0" (e.g. "00" from "zero zero").
        s = re.sub(r"(?<![\d.:/-])([+-]?)0{2,}(?![\d.:/-])", r"\g<1>0", s)
        # Normalize time hours ("00:00" -> "0:00", "07:05" -> "7:05")
        # to converge with word-form times ("0:00", "7:05"); minutes stay padded.
        s = re.sub(r"\b0+(\d+:\d{2})\b", r"\1", s)
        return s

    def _fraction_denominator(self, word: str) -> int | None:
        """Return the ordinal value of `word` if it is a fraction denominator."""
        # Cardinals are never denominators: "czterdzieści" (40, cardinal) must
        # not become denominator 40 via its adj analysis "czterdziesty"
        # (bug 4: "dwadzieścia trzy czterdzieści pięć" -> "23/40 5").
        if (
            word in self.ones
            or word in self.tens
            or word in self.hundreds
            or word in self.zeros
            or word in self.fraction_numerators
        ):
            return None
        # In Polish, fraction denominators are feminine or plural forms
        # (e.g. "druga", "trzecia", "czwarta", "trzecie", "czwarte", "piątych", "setnych").
        # Masculine nominative singular forms ("drugi", "trzeci", "czwarty", "piąty")
        # are never fraction denominators in Polish and must not collide (e.g. "jeden drugi").
        if word in {
            "drugi",
            "trzeci",
            "czwarty",
            "piąty",
            "szósty",
            "siódmy",
            "ósmy",
            "dziewiąty",
            "dziesiąty",
            "piaty",
            "szosty",
            "siodmy",
            "osmy",
            "dziewiaty",
            "dziesiaty",
        }:
            return None
        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.
        """
        # Fractions with value 1 in Polish are always feminine ("jedna", "jednej"),
        # never masculine ("jeden") or neuter ("jedno"), preventing false
        # fractions from counting sequences ("jeden drugi").
        if word in {"jeden", "jedno"}:
            return None
        # 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:
        if not isinstance(s, str):
            raise TypeError(f"Expected str, got {type(s).__name__}")
        s = self.preprocess(s)
        frac_words = self._convert_fractions(s.split())
        canon_words = [self._canonicalize(w) for w in frac_words]
        s = " ".join(
            word
            for word in self.process_words(canon_words, frac_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
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
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 = {
            "zerowa": 0,
            "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 = {
            "zerowej": 0,
            "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")
        # spaced "w pół do ósmej" (Whisper often splits wpół) -> same as wpół
        self._re_wpol_spaced = re.compile(r"\bw\s+(?:pół|pol)\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_card = re.compile(
            r"\bo\s+godzinie\s+(" + hours_gen_alt + r")\s+(" + minutes_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"|"
            + minutes_alt
            + r")\b)"
        )
        # "godzina" + declined forms (godziny/godzinie/godzinę/…) + digits.
        # Whisper usually omits "godzina" but corpus hits like
        # "do godziny 18.00" need the genitive trigger (bug 2).
        self._re_godzina_digits = re.compile(r"\bgodzin\w*\s+(\d{1,2})[.:,](\d{2})\b")
        self._re_o_godzinie_digits = re.compile(r"\bo\s+godzin\w*\s+(\d{1,2})[.:,](\d{2})\b")
        self._re_godzina_digit_hour = re.compile(r"\bgodzin\w*\s+(\d{1,2})\b(?!\s*[:.,]\d)")
        self._re_o_godzinie_digit_hour = re.compile(r"\bo\s+godzin\w*\s+(\d{1,2})\b(?!\s*[:.,]\d)")
        # Bare digit dot-times: "18.30" -> "18:30" (Whisper omits "godzina").
        # Dot only (comma is Polish decimal, colon already time). Excludes dates
        # (DD.MM with MM 01-12) and currency/percent contexts; requires round
        # minutes (M%5==0) or PM hour (H>=13) to preserve decimals like 3.14.
        # The (?!\.\d) lookahead keeps DD.MM.YYYY dates intact.
        self._re_bare_dot_time = re.compile(r"\b(\d{1,2})\.(\d{2})\b(?!\.\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")
        # genitive hour + ordinal minutes with leading "o"
        # ("o piątej trzydziestej" -> "o 5:30"). Bare (no "o") stays ordinal
        # to avoid hijacking compound ordinals ("dwudziestej pierwszej" -> 21).
        self._re_o_hour_gen_ord_min = re.compile(
            r"\bo\s+(" + hours_gen_alt + r")\s+(" + minutes_ordinal_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ąćęłńóśźż])")
        # digit hours: "o 11" -> "o 11:00" (not followed by word/time suffix),
        # "o 11 rano"/"11 rano" with time-of-day markers (marker_alt defined below,
        # placeholders wired after marker construction).
        # 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"
        )
        # digit hours with markers ("11 rano" -> "11:00 rano",
        # "o 11 rano" -> "o 11:00 rano"); lookbehind avoids matching minutes
        # inside existing times ("5:15 rano" must not match "15 rano").
        self._re_digit_marker = re.compile(
            r"(?<![\d:])(?<!\d\s)(\d{1,2})\s+(" + marker_alt + r")\b"
        )
        self._re_o_digit_marker = re.compile(r"\bo\s+(\d{1,2})\s+(" + marker_alt + r")\b")
        # digit hour after "o" ("o 11" -> "o 11:00", not followed by word)
        self._re_o_digit_hour = re.compile(r"\bo\s+(\d{1,2})\b(?!\s*[:.,]\d)(?!\s*[a-ząćęłńóśźż])")
        # 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)
        # 'w południe' is temporal (at 12:00), never geographic in Polish (geographic is 'na południu'/'na południe')
        geo_preps_polnoc = r"(?:na|z|od|do|w|ku|kierunek|strona|część|czesc|północno|polnocno|pod)"
        geo_preps_poludnie = (
            r"(?:na|z|od|do|ku|kierunek|strona|część|czesc|południowo|poludniowo|pod)"
        )
        self._re_geo_polnoc = re.compile(r"\b" + geo_preps_polnoc + r"\s+(?:" + polnoc_alt + r")\b")
        self._re_geo_poludnie = re.compile(
            r"\b" + geo_preps_poludnie + 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, "kwadransa": 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:
        if not isinstance(s, str):
            raise TypeError(f"Expected str, got {type(s).__name__}")
        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_bare_dot_time.sub(self._bare_dot_time_repl, s)
        # "o godzinie" hour-alone before hour+minutes so compound hours
        # ("dwudziestej pierwszej" = 21) claim before short hour+minutes
        # ("dwudziestej" + "pierwszej" = 20:01); hour-alone lookahead blocks
        # both cardinal and ordinal minutes.
        s = self._re_o_godzinie_hour.sub(self._o_godzinie_hour_repl, s)
        s = self._re_o_godzinie_card.sub(self._o_godzinie_ord_repl, s)
        s = self._re_o_godzinie_ord.sub(self._o_godzinie_ord_repl, s)
        s = self._re_wpol_spaced.sub(
            lambda m: f"{(self.hours_gen[self._norm_phrase(m.group(1))] - 1) % 24}:30", 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
        # digit markers first (most specific), then word markers
        s = self._re_o_digit_marker.sub(self._o_digit_marker_repl, s)
        s = self._re_digit_marker.sub(self._digit_marker_repl, s)
        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|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
        # protect redundant time phrases like "12:00 w południe" / "0:00 o północy"
        s = re.sub(r"\b(?:12:00|12)\s+w\s+(?:południe|poludnie)\b", _protect_geo, s)
        s = re.sub(
            r"\b(?:0:00|24:00|0|24)\s+(?:o\s+)?(?:północy|polnocy|północ|polnoc)\b",
            _protect_geo,
            s,
        )
        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_o_hour_gen_ord_min.sub(self._o_hour_gen_ord_min_repl, s)
        s = self._re_o_digit_hour.sub(self._o_digit_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 _bare_dot_time_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)

        # context for disambiguation (computed early so hour-0 and date
        # guards can be overridden by explicit time prepositions)
        full = m.string
        start, end = m.start(), m.end()
        after = full[end : end + 15].lower()
        before = full[max(0, start - 15) : start].lower()
        after_stripped = after.lstrip()
        before_stripped = before.rstrip()
        has_time_prep = bool(re.search(r"\b(?:o|od|do|około|okolo|koło|kolo)\s*$", before_stripped))
        # time-of-day marker after the number ("5.30 rano") also signals time
        has_marker_after = bool(
            re.match(
                r"(?:rano|wieczorem|w\s+nocy|nocą|noca|nad\s+ranem|w\s+dzień|w\s+dzien)\b",
                after_stripped,
            )
        )
        has_time_context = has_time_prep or has_marker_after

        # Preposition "o" specifically signals clock time ("o 5.05"), whereas "od", "do",
        # "około" frequently introduce date ranges ("od 5.05 do 10.05") where MM 01-12 must stay dates.
        has_o_prep = bool(re.search(r"\bo\s*$", before_stripped))
        has_date_override = has_o_prep or has_marker_after

        # Hour 0 needs explicit context ("godzina 0.00" handled elsewhere,
        # "o 0.00" here). Bare "0.00" stays decimal/number.
        if hour == 0 and not has_time_context:
            return m.group(0)
        # Preserve day.month dates: DD.MM with MM 01-12 stays dotted
        # (e.g. 21.05 May 21, 05.05, 01.01, od 5.05 do 10.05). Only explicit
        # time context ("o 5.05", "5.05 rano") overrides the date guard.
        if 1 <= hour <= 31 and 1 <= minute <= 12 and not has_date_override:
            return m.group(0)

        # Preserve units of measurement, quantities, and currencies

        # Check for currency or percent symbols
        if after_stripped[:1] in {"$", "€", "£", "¢", "%"}:
            return m.group(0)
        if before_stripped[-1:] in {"$", "€", "£", "¢"}:
            return m.group(0)

        # Check for currency and measurement units following the number
        units = (
            "zł",
            "gr",
            "euro",
            "eur",
            "dolar",
            "usd",
            "cent",
            "funt",
            "gbp",
            "pln",
            "złoty",
            "złotych",
            "złote",
            "złotego",
            "grosz",
            "groszy",
            "grosze",
            "grosza",
            "procent",
            "proc",
            "kg",
            "g",
            "mg",
            "dag",
            "t",
            "tona",
            "tony",
            "ton",
            "m",
            "km",
            "cm",
            "mm",
            "metr",
            "metra",
            "metry",
            "metrów",
            "l",
            "ml",
            "litr",
            "litra",
            "litry",
            "litrów",
            "s",
            "sek",
            "sekund",
            "sekundy",
            "sekunda",
            "stopni",
            "stopnie",
            "stopnia",
            "punkt",
            "punkty",
            "punktów",
            "pkt",
        )
        for u in units:
            if after_stripped.startswith(u):
                rest = after_stripped[len(u) :]
                if not rest or not rest[0].isalpha():
                    return m.group(0)

        # Bare times require round minutes (M % 5 == 0) and daytime/evening hour (H >= 6)
        # unless explicit time context (preposition or marker) is present.
        if not has_time_context:
            if hour < 6:
                return m.group(0)
            if minute % 5 != 0:
                return m.group(0)

        return f"{hour}:{minute:02d}"

    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_phrase = self._norm_phrase(m.group(2))
        # shared by ordinal ("o godzinie szesnastej piątej") and cardinal
        # ("o godzinie piątej trzydzieści") minute variants
        minute = self.minutes_ordinal.get(minute_phrase)
        if minute is None:
            minute = self.minutes.get(minute_phrase)
        if minute is None:  # pragma: no cover – regex guarantees a hit
            return m.group(0)
        return f"o {hour}:{minute:02d}"

    def _hour_gen_ord_min_repl(self, m: re.Match[str]) -> str:
        # kept for backwards compat (bare genitive+ordinal now handled via
        # o-prefixed version to avoid ordinal hijack); not used in pipeline
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        minute = self.minutes_ordinal[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _o_hour_gen_ord_min_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_digit_marker_repl(self, m: re.Match[str]) -> str:
        hour = int(m.group(1))
        if hour > 24:
            return m.group(0)
        marker = self._norm_phrase(m.group(2))
        return f"o {hour}:00 {marker}"

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

    def _o_digit_hour_repl(self, m: re.Match[str]) -> str:
        hour = int(m.group(1))
        if hour > 24:
            return m.group(0)
        # Check context before "o <hour>":
        full = m.string
        start = m.start()
        before = full[max(0, start - 25) : start].lower()
        # Words indicating difference, increase/decrease, count, or prepositional usage:
        # e.g. "zwiększyć o 5", "mniej o 2", "chodzi o 1", "pomylił się o 3", "różnica o 4"
        if re.search(
            r"\b(?:mniej|więcej|zwiększ\w*|zmniejsz\w*|wzros\w*|spad\w*|podn\w*|obniż\w*|"
            r"pomyl\w*|spóźn\w*|różnic\w*|błąd\w*|chodz\w*|pyta\w*|prosi\w*|walcz\w*|"
            r"apel\w*|popraw\w*|zmień\w*|zmien\w*|skróć\w*|skroc\w*|wydłuż\w*|wydluz\w*|"
            r"wygra\w*|przegra\w*)\s*$",
            before,
        ):
            return m.group(0)
        # Hours 1-5 without explicit time markers (e.g. "rano", "godzina")
        # are almost exclusively delta/counting tokens in Polish ("o 1", "o 2", "o 3", "o 4", "o 5")
        if hour < 6:
            return m.group(0)
        return f"o {hour}:00"

    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
 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
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
class PolishTextNormalizer:
    # pre-compiled patterns shared across instances
    # NOTE: brackets (), [], <>, {} are punctuation – only the bracket
    # characters are dropped (via remove_symbols below), enclosed words kept.
    _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*\.){2,}\s*|\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]+\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")
    # standalone year with trailing "r."/"roku" ("1860 r." -> "1860")
    _YEAR_TRAILING_R_RE = re.compile(r"\b(\d{3,4})\s+(?:r\.?|roku)\b")
    # day.month without year requires a space ("5. 5" -> "05.05") to avoid
    # turning decimals ("2.5", "3.14") into dates; full dates with 4-digit
    # year allow no space ("5.5.2026" -> "05.05.2026", safe: no decimal has year)
    _DAY_MONTH_RE = re.compile(r"(\d+)\.\s+(\d+)\b(?!\.)")
    _PERCENT_GLUE_RE = re.compile(r"(\d)\s+%")
    _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")
    # Polish abbreviations <-> spoken forms (bug 8): contract full forms to
    # short (gender/case-neutral: "tak zwany/tak zwana" -> "tzw") except
    # "godz." which expands to "godzina" to enable time conversion.
    _ABBREV_TZW_RE = re.compile(r"\btak\s+zwan\w*\b")
    _ABBREV_NP_RE = re.compile(r"\bna\s+przyk[lł]ad\b")
    _ABBREV_ITD_RE = re.compile(r"\bi\s+tak\s+dalej\b")
    _ABBREV_ITP_RE = re.compile(r"\bi\s+(?:temu|tym)\s+podobn\w*\b")
    _ABBREV_MIN_SHORT_RE = re.compile(r"\bm\s*\.\s*in\s*\.?(?=\s|$)")
    # "tj."/"tzn." (i.e.) expand to full; "to jest"/"to znaczy" (it is)
    # stay full (don't contract copula "to jest złoty" -> must stay).
    _ABBREV_TJ_SHORT_RE = re.compile(r"\btj\s*\.?(?=\s|$)")
    _ABBREV_TZN_SHORT_RE = re.compile(r"\btzn\s*\.?(?=\s|$)")
    _ABBREV_TZW_SHORT_RE = re.compile(r"\btzw\s*\.?(?=\s|$)")
    _ABBREV_NP_SHORT_RE = re.compile(r"\bnp\s*\.?(?=\s|$)")
    _ABBREV_ITD_SHORT_RE = re.compile(r"\bitd\s*\.?(?=\s|$)")
    _ABBREV_ITP_SHORT_RE = re.compile(r"\bitp\s*\.?(?=\s|$)")
    _ABBREV_GODZ_RE = re.compile(r"\bgodz\.(?=\s|$)|\bgodz\b")
    _ABBREV_UL_FULL_RE = re.compile(r"\bulic(?:a|y|e|ę|o|ą|om|ami|ach)\b")
    _ABBREV_UL_SHORT_RE = re.compile(r"\bul\.(?=\s|$)")
    _ABBREV_NR_FULL_RE = re.compile(r"\bnumer(?:u|owi|em|ze|y|ów|om|ami|ach)?\b")
    _ABBREV_NR_SHORT_RE = re.compile(r"\bnr\.(?=\s|$)")
    _ABBREV_DR_FULL_RE = re.compile(r"\bdoktor(?:a|owi|em|ze|zy|ów|om|ami|ach)?\b")
    _ABBREV_DR_SHORT_RE = re.compile(r"\bdr\.(?=\s|$)")
    _ABBREV_PROF_FULL_RE = re.compile(r"\bprofesor(?:a|owi|em|ze|owie|ów|om|ami|ach)?\b")
    _ABBREV_PROF_SHORT_RE = re.compile(r"\bprof\.(?=\s|$)")
    _ABBREV_OK_RE = re.compile(
        r"\bok\.\s*(?=\d|\b(?:godz|południ|poludni|północ|polnoc|st|lut|mar|kwi|maj|cze|lip|sie|wrz|paź|paz|lis|gru|jed|dw|trz|czt|pię|pie|sze|sie|osi|dzi|sto|tys|mil|bil|pół|pol)\w*)"
    )

    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

        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 has_percent:
            try:
                out = datetime.datetime(year, month, day).strftime(fmt)
                # unknown/invalid strftime codes leak "%" (e.g. "%Q" -> "%Q");
                # fall back to the default instead of leaking the format string
                if "%" in out:
                    raise ValueError(f"strftime leaked % for {fmt!r} -> {out!r}")
                return out
            except Exception as exc:
                logger.debug("strftime date_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:
        if not isinstance(s, str):
            raise TypeError(f"Expected str, got {type(s).__name__}")
        s = normalize_vulgar_fractions(s.lower())
        s = re.sub(r"\b(pół|pol|ćwierć|cwierc)-\s*([a-ząćęłńóśźż]+)\b", r"\1\2", s)

        # Polish abbreviations <-> spoken forms (bug 8): normalize both sides
        # to the same token ("tak zwany" <-> "tzw" -> "tzw", "np." -> "np",
        # "tj." -> "to jest" to preserve copula "to jest złoty").
        s = self._ABBREV_TZW_RE.sub("tzw", s)
        s = self._ABBREV_NP_RE.sub("np", s)
        s = self._ABBREV_ITD_RE.sub("itd", s)
        s = self._ABBREV_ITP_RE.sub("itp", s)
        s = self._ABBREV_MIN_SHORT_RE.sub("między innymi", s)
        s = self._ABBREV_TJ_SHORT_RE.sub("to jest", s)
        s = self._ABBREV_TZN_SHORT_RE.sub("to znaczy", s)
        s = self._ABBREV_TZW_SHORT_RE.sub("tzw", s)
        s = self._ABBREV_NP_SHORT_RE.sub("np", s)
        s = self._ABBREV_ITD_SHORT_RE.sub("itd", s)
        s = self._ABBREV_ITP_SHORT_RE.sub("itp", s)
        s = self._ABBREV_GODZ_RE.sub("godzina", s)
        s = self._ABBREV_UL_FULL_RE.sub("ul", s)
        s = self._ABBREV_UL_SHORT_RE.sub("ul", s)
        s = self._ABBREV_NR_FULL_RE.sub("nr", s)
        s = self._ABBREV_NR_SHORT_RE.sub("nr", s)
        s = self._ABBREV_DR_FULL_RE.sub("dr", s)
        s = self._ABBREV_DR_SHORT_RE.sub("dr", s)
        s = self._ABBREV_PROF_FULL_RE.sub("prof", s)
        s = self._ABBREV_PROF_SHORT_RE.sub("prof", s)
        s = self._ABBREV_OK_RE.sub("około", s)

        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
        # Strip non-time colons before number conversion so "drugi:" -> "drugi"
        # converts to "2" (bug 6b: punctuation suppressed ordinal conversion).
        # _COLON_RE preserves digit:digit times ("18:30").
        s = self._COLON_RE.sub(" ", s)

        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)
        # digit dates without spaces ("5.5.2026") also converge; month 1-12
        # guard avoids turning decimals ("3.14", month 14) into dates.
        def _full_date_roku_before(m: re.Match[str]) -> str:
            day, month, year = m.group(1), m.group(2), m.group(3)
            if not 1 <= int(month) <= 12:
                return m.group(0)
            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)
            if not 1 <= int(month) <= 12:
                return m.group(0)
            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)
            if not 1 <= int(month) <= 12:
                return m.group(0)
            return self._format_date(int(day), int(month), int(year))

        def _day_month(m: re.Match[str]) -> str:
            day, month = int(m.group(1)), int(m.group(2))
            # validate to avoid decimals ("3.14" month 14) becoming dates
            if not 1 <= month <= 12 or not 1 <= day <= 31:
                return m.group(0)
            return f"{day:02d}.{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)
            # standalone year with trailing r/roku ("1860 r." -> "1860")
            s = self._YEAR_TRAILING_R_RE.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)

        # glue digit-space-percent ("5 %" -> "5%") before dropping stray "%"
        # ("a%b" -> "a b", but "5%" stays)
        s = self._PERCENT_GLUE_RE.sub(r"\1%", 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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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.

normalize_vulgar_fractions(s)

Replace vulgar fractions with ASCII num/den forms.

Digit-prefixed forms (1½) become mixed numbers (1 i 1/2) so the existing X i Y/Z postprocess yields 1.5 like dwa i pół. Also normalizes U+2044 fraction slash to /.

Source code in src/polish_whisper_normalizer/utils.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def normalize_vulgar_fractions(s: str) -> str:
    """Replace vulgar fractions with ASCII ``num/den`` forms.

    Digit-prefixed forms (``1½``) become mixed numbers (``1 i 1/2``) so the
    existing ``X i Y/Z`` postprocess yields ``1.5`` like ``dwa i pół``.
    Also normalizes U+2044 fraction slash to ``/``.
    """
    if _FRACTION_SLASH in s:
        s = s.replace(_FRACTION_SLASH, "/")
    if not any(v in s for v in _VULGAR_FRACTIONS):
        return s
    # digit + vulgar (no space required): "1½" -> "1 i 1/2"
    for vulg, frac in _VULGAR_FRACTIONS.items():
        if vulg in s:
            s = re.sub(r"(\d+)\s*" + re.escape(vulg), r"\1 i " + frac, s)
            s = s.replace(vulg, frac)
    return s

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
14
15
16
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
19
20
21
22
23
24
25
26
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
29
30
31
32
33
34
35
36
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
105
106
107
108
109
110
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
        if len(self._cache) > 20000:
            for k in list(self._cache.keys())[:10000]:
                del self._cache[k]
        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))
        if len(self._gen_cache) > 5000:
            for k in list(self._gen_cache.keys())[:2500]:
                del self._gen_cache[k]
        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
81
82
83
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
    if len(self._cache) > 20000:
        for k in list(self._cache.keys())[:10000]:
            del self._cache[k]
    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
 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
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))
    if len(self._gen_cache) > 5000:
        for k in list(self._gen_cache.keys())[:2500]:
            del self._gen_cache[k]
    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
  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
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
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)
            # NOTE: "sie" (sierpień) removed – collides with the reflexive
            # pronoun "się"/"sie" (diacritic-less ASR), causing false dates
            # ("5 sie" pronoun -> "05.08"). Use full "sierpień"/"sierpnia".
            "sty": "1",
            "lut": "2",
            "mar": "3",
            "kwi": "4",
            "cze": "6",
            "lip": "7",
            "sier": "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]
        if len(self._canon_cache) > 20000:
            for k in list(self._canon_cache.keys())[:10000]:
                del self._canon_cache[k]
        self._canon_cache[word] = result
        return result

    def process_words(self, words: list[str], orig_words: list[str] | None = None) -> 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

        padded: list[str | None] = [None, *words, None]
        orig_padded: list[str | None] = (
            [None, *orig_words, None] if orig_words is not None else padded
        )
        for idx in range(len(padded) - 2):
            prev, current, next = padded[idx], padded[idx + 1], padded[idx + 2]
            orig_current = orig_padded[idx + 1]
            if orig_current is None:
                orig_current = current
            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:
                if current == "piec":
                    # "piec" collides with verb (to bake) and noun (oven).
                    # Only treat as 5 in a numeric context or when single-word utterance.
                    is_numeric_ctx = (
                        (
                            prev is not None
                            and (
                                prev in self.words
                                or self._NUMERIC_PREFIX_RE.match(prev) is not None
                            )
                        )
                        or (next is not None and (next in self.words or next_is_numeric))
                        or len(words) == 1
                    )
                    if not is_numeric_ctx:
                        if value is not None:
                            yield output(value)  # type: ignore[arg-type]
                        yield output(current)  # type: ignore[arg-type]
                        continue
                # Pronominal "jeden" (not numeral): "wszystko mi było jedno",
                # "chciałem tylko jednego" stays a word (bug 7, like
                # "Maja"/"na północ" guards). Preserve original inflection
                # ("jedną" stays "jedną", not lemmatized to "jeden").
                if current in {"jeden", "jedna", "jedno"} and prev in {
                    "było",
                    "bylo",
                    "była",
                    "byla",
                    "jest",
                    "tylko",
                    "mi",
                }:
                    if value is not None:
                        yield output(value)  # type: ignore[arg-type]
                    yield output(orig_current)  # type: ignore[arg-type]
                    continue
                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]
                    elif isinstance(value, str) and "." in value:
                        # decimal fraction after "przecinek/kropka"
                        # ("3."+"14" -> "3.14", "0."+"5" -> "0.5",
                        # "3.1"+"4" -> "3.14"): always glue
                        value = str(value) + str(ones)
                    elif ones < 10:
                        # digit dictation ("jeden dwa trzy" -> "123") only for
                        # single digits with small values; larger chunks start
                        # new numerals (bug 5: "701"+"12" -> "701 12").
                        try:
                            iv = int(value)  # type: ignore[arg-type]
                        except (ValueError, TypeError):
                            iv = None
                        if iv is not None and iv < 100:
                            value = str(value) + str(ones)
                        else:
                            yield output(value)  # type: ignore[arg-type]
                            value = ones
                    else:
                        # teens (10-19) never glue as dictation; separate
                        yield output(value)  # type: ignore[arg-type]
                        value = ones
                elif ones < 10:
                    if value % 10 == 0:
                        value += ones
                    elif value < 100:  # type: ignore[operator]
                        value = str(value) + str(ones)
                    else:
                        yield output(value)  # type: ignore[arg-type]
                        value = ones
                else:  # eleven to nineteen
                    if value % 100 == 0:
                        value += ones
                    else:
                        yield output(value)  # type: ignore[arg-type]
                        value = ones
            elif current in self.tens:
                tens = self.tens[current]
                if value is None:
                    value = tens
                elif isinstance(value, str):
                    if "." in value:
                        # decimal ("0."+"20" -> "0.20"): glue
                        value = str(value) + str(tens)
                    else:
                        # dictation string + tens starts a new numeral
                        # (bug 5: avoid "12"+"40" -> "1240")
                        yield output(value)  # type: ignore[arg-type]
                        value = 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):
                    if "." in value:
                        value = str(value) + str(hundred)
                    else:
                        # dictation string + hundreds starts a new numeral
                        yield output(value)  # type: ignore[arg-type]
                        value = hundred
                elif value % 1000 == 0:
                    value += hundred
                elif value >= 100:
                    # invalid order with large value ("340"+"800") -> separate
                    # (bug 5: avoid 21-digit tokens; "340 800" not "340800")
                    yield output(value)  # type: ignore[arg-type]
                    value = 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
                    if residual == 0:
                        # bare multiplier after a complete thousand-group
                        # ("tysiąc tysięcy", "dwa miliony tysiąc"): do not
                        # drop the second multiplier (old code computed
                        # before+0*M == before). Split into separate numerals
                        # to preserve information and avoid inventing huge
                        # tokens (bug 5 philosophy).
                        yield output(value)  # type: ignore[arg-type]
                        value = multiplier
                    else:
                        value = before + residual * multiplier
            elif current in self.ones_ordinal:
                ones = self.ones_ordinal[current]
                if value is None:
                    ordinal = True
                    yield output(ones)
                elif isinstance(value, str):
                    ordinal = True
                    yield output(str(value) + str(ones))
                elif ones < 10:
                    if value % 10 == 0 and value >= 10:
                        ordinal = True
                        yield output(str(value + ones))
                    else:
                        yield output(value)
                        ordinal = True
                        yield output(ones)
                else:  # 11-19
                    if value % 100 == 0 and value >= 100:
                        ordinal = True
                        yield output(str(value + ones))
                    else:
                        yield output(value)
                        ordinal = True
                        yield output(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():
                    # standalone currency without amount: keep original
                    # inflection ("złotówkę" stays, "procentach" stays) instead
                    # of lemmatized artifact.
                    yield output(orig_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(orig_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:
        s = normalize_vulgar_fractions(s)
        # join hyphenated compound words with prefixes "pół-", "ćwierć-":
        # "pół-finał" -> "półfinał", "ćwierć-finał" -> "ćwierćfinał", "pół-żartem" -> "półżartem"
        s = re.sub(r"\b(pół|pol|ćwierć|cwierc)-\s*([a-ząćęłńóśźż]+)\b", r"\1\2", s)
        # 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*\.){2,}\s*|\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(?!-[a-ząćęłńóśźż])", "jeden przecinek pięć", s)
        s = re.sub(r"\b(?:półtorej|poltorej)\b(?!-[a-ząćęłńóśźż])", "jeden przecinek pięć", s)
        # standalone "pół" (half) -> "0.5"
        # Protect hyphenated compound words (e.g. "pół-finał", "pół-żartem", "pół-serio")
        s = re.sub(r"(?<!-)\b(?:pół|pol)\b(?!-[a-ząćęłńóśźż])", "zero przecinek pięć", s)
        # quarter: "ćwierć" (one quarter) -> "0.25", "i ćwierć" -> "+0.25"
        # (ASCII "cwierc" for diacritic-less ASR). Only singular nominative;
        # plural "ćwierci" stays a word (like "zerowych").
        # Protect hyphenated compounds (e.g. "ćwierć-finał")
        s = re.sub(r"\bi\s+(?:ćwierć|cwierc)\b(?!-)", "przecinek dwa pięć", s)
        s = re.sub(r"(?<!-)\b(?:ćwierć|cwierc)\b(?!-[a-ząćęłńóśźż])", "zero przecinek dwa pięć", s)

        # normalize currency symbols to follow the amount ("€10" -> "10 €",
        # "10€" -> "10 €", "$1.50" -> "1.50 $").
        # Only when the symbol is a true prefix (not already a suffix of a
        # previous amount): "5 $ 18 ¢" must NOT become "5 18 $ ¢" (bug 3).
        def _currency_prefix_repl(m: re.Match[str]) -> str:
            prev = m.string[: m.start()].rstrip()[-1:] if m.string[: m.start()].rstrip() else ""
            if prev.isdigit():
                return m.group(0)
            return f"{m.group(2)} {m.group(1)}"

        s = re.sub(r"([€$£¢])\s*(\d+(?:\.\d+)?)", _currency_prefix_repl, 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)
        # Normalize signed zero ("-0", "+0", "-0.0" -> "0"): negative zero
        # equals zero and otherwise breaks WER convergence ("minus zero").
        s = re.sub(r"(?<![\d.:/-])([+-])0+(?:\.0+)?(?![\d.:/-])", "0", s)
        # Strip leading zeros for idempotency ("007" -> "7", "005" -> "5"):
        # word-form "zero zero siedem" glues to "007" on pass 1 but digit
        # "007" goes via Fraction to 7, so N(N(x)) != N(x) (bug 6a).
        # Preserve dates (05.05, 05.05.2026, 2026-05-05, 5/5/2026) and times
        # (0:00, 12:00): no stripping adjacent to ".", ":", "-", "/".
        s = re.sub(r"(?<![\d.:/-])([+-]?)0+(\d)(?![\d.:/-])", r"\1\2", s)
        # Collapse "00" alone -> "0" (e.g. "00" from "zero zero").
        s = re.sub(r"(?<![\d.:/-])([+-]?)0{2,}(?![\d.:/-])", r"\g<1>0", s)
        # Normalize time hours ("00:00" -> "0:00", "07:05" -> "7:05")
        # to converge with word-form times ("0:00", "7:05"); minutes stay padded.
        s = re.sub(r"\b0+(\d+:\d{2})\b", r"\1", s)
        return s

    def _fraction_denominator(self, word: str) -> int | None:
        """Return the ordinal value of `word` if it is a fraction denominator."""
        # Cardinals are never denominators: "czterdzieści" (40, cardinal) must
        # not become denominator 40 via its adj analysis "czterdziesty"
        # (bug 4: "dwadzieścia trzy czterdzieści pięć" -> "23/40 5").
        if (
            word in self.ones
            or word in self.tens
            or word in self.hundreds
            or word in self.zeros
            or word in self.fraction_numerators
        ):
            return None
        # In Polish, fraction denominators are feminine or plural forms
        # (e.g. "druga", "trzecia", "czwarta", "trzecie", "czwarte", "piątych", "setnych").
        # Masculine nominative singular forms ("drugi", "trzeci", "czwarty", "piąty")
        # are never fraction denominators in Polish and must not collide (e.g. "jeden drugi").
        if word in {
            "drugi",
            "trzeci",
            "czwarty",
            "piąty",
            "szósty",
            "siódmy",
            "ósmy",
            "dziewiąty",
            "dziesiąty",
            "piaty",
            "szosty",
            "siodmy",
            "osmy",
            "dziewiaty",
            "dziesiaty",
        }:
            return None
        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.
        """
        # Fractions with value 1 in Polish are always feminine ("jedna", "jednej"),
        # never masculine ("jeden") or neuter ("jedno"), preventing false
        # fractions from counting sequences ("jeden drugi").
        if word in {"jeden", "jedno"}:
            return None
        # 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:
        if not isinstance(s, str):
            raise TypeError(f"Expected str, got {type(s).__name__}")
        s = self.preprocess(s)
        frac_words = self._convert_fractions(s.split())
        canon_words = [self._canonicalize(w) for w in frac_words]
        s = " ".join(
            word
            for word in self.process_words(canon_words, frac_words)
            if word is not None and word != "."
        )
        s = self.postprocess(s)
        return s

PolishTextNormalizer

Source code in src/polish_whisper_normalizer/text.py
 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
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
class PolishTextNormalizer:
    # pre-compiled patterns shared across instances
    # NOTE: brackets (), [], <>, {} are punctuation – only the bracket
    # characters are dropped (via remove_symbols below), enclosed words kept.
    _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*\.){2,}\s*|\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]+\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")
    # standalone year with trailing "r."/"roku" ("1860 r." -> "1860")
    _YEAR_TRAILING_R_RE = re.compile(r"\b(\d{3,4})\s+(?:r\.?|roku)\b")
    # day.month without year requires a space ("5. 5" -> "05.05") to avoid
    # turning decimals ("2.5", "3.14") into dates; full dates with 4-digit
    # year allow no space ("5.5.2026" -> "05.05.2026", safe: no decimal has year)
    _DAY_MONTH_RE = re.compile(r"(\d+)\.\s+(\d+)\b(?!\.)")
    _PERCENT_GLUE_RE = re.compile(r"(\d)\s+%")
    _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")
    # Polish abbreviations <-> spoken forms (bug 8): contract full forms to
    # short (gender/case-neutral: "tak zwany/tak zwana" -> "tzw") except
    # "godz." which expands to "godzina" to enable time conversion.
    _ABBREV_TZW_RE = re.compile(r"\btak\s+zwan\w*\b")
    _ABBREV_NP_RE = re.compile(r"\bna\s+przyk[lł]ad\b")
    _ABBREV_ITD_RE = re.compile(r"\bi\s+tak\s+dalej\b")
    _ABBREV_ITP_RE = re.compile(r"\bi\s+(?:temu|tym)\s+podobn\w*\b")
    _ABBREV_MIN_SHORT_RE = re.compile(r"\bm\s*\.\s*in\s*\.?(?=\s|$)")
    # "tj."/"tzn." (i.e.) expand to full; "to jest"/"to znaczy" (it is)
    # stay full (don't contract copula "to jest złoty" -> must stay).
    _ABBREV_TJ_SHORT_RE = re.compile(r"\btj\s*\.?(?=\s|$)")
    _ABBREV_TZN_SHORT_RE = re.compile(r"\btzn\s*\.?(?=\s|$)")
    _ABBREV_TZW_SHORT_RE = re.compile(r"\btzw\s*\.?(?=\s|$)")
    _ABBREV_NP_SHORT_RE = re.compile(r"\bnp\s*\.?(?=\s|$)")
    _ABBREV_ITD_SHORT_RE = re.compile(r"\bitd\s*\.?(?=\s|$)")
    _ABBREV_ITP_SHORT_RE = re.compile(r"\bitp\s*\.?(?=\s|$)")
    _ABBREV_GODZ_RE = re.compile(r"\bgodz\.(?=\s|$)|\bgodz\b")
    _ABBREV_UL_FULL_RE = re.compile(r"\bulic(?:a|y|e|ę|o|ą|om|ami|ach)\b")
    _ABBREV_UL_SHORT_RE = re.compile(r"\bul\.(?=\s|$)")
    _ABBREV_NR_FULL_RE = re.compile(r"\bnumer(?:u|owi|em|ze|y|ów|om|ami|ach)?\b")
    _ABBREV_NR_SHORT_RE = re.compile(r"\bnr\.(?=\s|$)")
    _ABBREV_DR_FULL_RE = re.compile(r"\bdoktor(?:a|owi|em|ze|zy|ów|om|ami|ach)?\b")
    _ABBREV_DR_SHORT_RE = re.compile(r"\bdr\.(?=\s|$)")
    _ABBREV_PROF_FULL_RE = re.compile(r"\bprofesor(?:a|owi|em|ze|owie|ów|om|ami|ach)?\b")
    _ABBREV_PROF_SHORT_RE = re.compile(r"\bprof\.(?=\s|$)")
    _ABBREV_OK_RE = re.compile(
        r"\bok\.\s*(?=\d|\b(?:godz|południ|poludni|północ|polnoc|st|lut|mar|kwi|maj|cze|lip|sie|wrz|paź|paz|lis|gru|jed|dw|trz|czt|pię|pie|sze|sie|osi|dzi|sto|tys|mil|bil|pół|pol)\w*)"
    )

    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

        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 has_percent:
            try:
                out = datetime.datetime(year, month, day).strftime(fmt)
                # unknown/invalid strftime codes leak "%" (e.g. "%Q" -> "%Q");
                # fall back to the default instead of leaking the format string
                if "%" in out:
                    raise ValueError(f"strftime leaked % for {fmt!r} -> {out!r}")
                return out
            except Exception as exc:
                logger.debug("strftime date_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:
        if not isinstance(s, str):
            raise TypeError(f"Expected str, got {type(s).__name__}")
        s = normalize_vulgar_fractions(s.lower())
        s = re.sub(r"\b(pół|pol|ćwierć|cwierc)-\s*([a-ząćęłńóśźż]+)\b", r"\1\2", s)

        # Polish abbreviations <-> spoken forms (bug 8): normalize both sides
        # to the same token ("tak zwany" <-> "tzw" -> "tzw", "np." -> "np",
        # "tj." -> "to jest" to preserve copula "to jest złoty").
        s = self._ABBREV_TZW_RE.sub("tzw", s)
        s = self._ABBREV_NP_RE.sub("np", s)
        s = self._ABBREV_ITD_RE.sub("itd", s)
        s = self._ABBREV_ITP_RE.sub("itp", s)
        s = self._ABBREV_MIN_SHORT_RE.sub("między innymi", s)
        s = self._ABBREV_TJ_SHORT_RE.sub("to jest", s)
        s = self._ABBREV_TZN_SHORT_RE.sub("to znaczy", s)
        s = self._ABBREV_TZW_SHORT_RE.sub("tzw", s)
        s = self._ABBREV_NP_SHORT_RE.sub("np", s)
        s = self._ABBREV_ITD_SHORT_RE.sub("itd", s)
        s = self._ABBREV_ITP_SHORT_RE.sub("itp", s)
        s = self._ABBREV_GODZ_RE.sub("godzina", s)
        s = self._ABBREV_UL_FULL_RE.sub("ul", s)
        s = self._ABBREV_UL_SHORT_RE.sub("ul", s)
        s = self._ABBREV_NR_FULL_RE.sub("nr", s)
        s = self._ABBREV_NR_SHORT_RE.sub("nr", s)
        s = self._ABBREV_DR_FULL_RE.sub("dr", s)
        s = self._ABBREV_DR_SHORT_RE.sub("dr", s)
        s = self._ABBREV_PROF_FULL_RE.sub("prof", s)
        s = self._ABBREV_PROF_SHORT_RE.sub("prof", s)
        s = self._ABBREV_OK_RE.sub("około", s)

        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
        # Strip non-time colons before number conversion so "drugi:" -> "drugi"
        # converts to "2" (bug 6b: punctuation suppressed ordinal conversion).
        # _COLON_RE preserves digit:digit times ("18:30").
        s = self._COLON_RE.sub(" ", s)

        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)
        # digit dates without spaces ("5.5.2026") also converge; month 1-12
        # guard avoids turning decimals ("3.14", month 14) into dates.
        def _full_date_roku_before(m: re.Match[str]) -> str:
            day, month, year = m.group(1), m.group(2), m.group(3)
            if not 1 <= int(month) <= 12:
                return m.group(0)
            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)
            if not 1 <= int(month) <= 12:
                return m.group(0)
            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)
            if not 1 <= int(month) <= 12:
                return m.group(0)
            return self._format_date(int(day), int(month), int(year))

        def _day_month(m: re.Match[str]) -> str:
            day, month = int(m.group(1)), int(m.group(2))
            # validate to avoid decimals ("3.14" month 14) becoming dates
            if not 1 <= month <= 12 or not 1 <= day <= 31:
                return m.group(0)
            return f"{day:02d}.{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)
            # standalone year with trailing r/roku ("1860 r." -> "1860")
            s = self._YEAR_TRAILING_R_RE.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)

        # glue digit-space-percent ("5 %" -> "5%") before dropping stray "%"
        # ("a%b" -> "a b", but "5%" stays)
        s = self._PERCENT_GLUE_RE.sub(r"\1%", 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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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
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
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 = {
            "zerowa": 0,
            "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 = {
            "zerowej": 0,
            "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")
        # spaced "w pół do ósmej" (Whisper often splits wpół) -> same as wpół
        self._re_wpol_spaced = re.compile(r"\bw\s+(?:pół|pol)\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_card = re.compile(
            r"\bo\s+godzinie\s+(" + hours_gen_alt + r")\s+(" + minutes_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"|"
            + minutes_alt
            + r")\b)"
        )
        # "godzina" + declined forms (godziny/godzinie/godzinę/…) + digits.
        # Whisper usually omits "godzina" but corpus hits like
        # "do godziny 18.00" need the genitive trigger (bug 2).
        self._re_godzina_digits = re.compile(r"\bgodzin\w*\s+(\d{1,2})[.:,](\d{2})\b")
        self._re_o_godzinie_digits = re.compile(r"\bo\s+godzin\w*\s+(\d{1,2})[.:,](\d{2})\b")
        self._re_godzina_digit_hour = re.compile(r"\bgodzin\w*\s+(\d{1,2})\b(?!\s*[:.,]\d)")
        self._re_o_godzinie_digit_hour = re.compile(r"\bo\s+godzin\w*\s+(\d{1,2})\b(?!\s*[:.,]\d)")
        # Bare digit dot-times: "18.30" -> "18:30" (Whisper omits "godzina").
        # Dot only (comma is Polish decimal, colon already time). Excludes dates
        # (DD.MM with MM 01-12) and currency/percent contexts; requires round
        # minutes (M%5==0) or PM hour (H>=13) to preserve decimals like 3.14.
        # The (?!\.\d) lookahead keeps DD.MM.YYYY dates intact.
        self._re_bare_dot_time = re.compile(r"\b(\d{1,2})\.(\d{2})\b(?!\.\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")
        # genitive hour + ordinal minutes with leading "o"
        # ("o piątej trzydziestej" -> "o 5:30"). Bare (no "o") stays ordinal
        # to avoid hijacking compound ordinals ("dwudziestej pierwszej" -> 21).
        self._re_o_hour_gen_ord_min = re.compile(
            r"\bo\s+(" + hours_gen_alt + r")\s+(" + minutes_ordinal_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ąćęłńóśźż])")
        # digit hours: "o 11" -> "o 11:00" (not followed by word/time suffix),
        # "o 11 rano"/"11 rano" with time-of-day markers (marker_alt defined below,
        # placeholders wired after marker construction).
        # 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"
        )
        # digit hours with markers ("11 rano" -> "11:00 rano",
        # "o 11 rano" -> "o 11:00 rano"); lookbehind avoids matching minutes
        # inside existing times ("5:15 rano" must not match "15 rano").
        self._re_digit_marker = re.compile(
            r"(?<![\d:])(?<!\d\s)(\d{1,2})\s+(" + marker_alt + r")\b"
        )
        self._re_o_digit_marker = re.compile(r"\bo\s+(\d{1,2})\s+(" + marker_alt + r")\b")
        # digit hour after "o" ("o 11" -> "o 11:00", not followed by word)
        self._re_o_digit_hour = re.compile(r"\bo\s+(\d{1,2})\b(?!\s*[:.,]\d)(?!\s*[a-ząćęłńóśźż])")
        # 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)
        # 'w południe' is temporal (at 12:00), never geographic in Polish (geographic is 'na południu'/'na południe')
        geo_preps_polnoc = r"(?:na|z|od|do|w|ku|kierunek|strona|część|czesc|północno|polnocno|pod)"
        geo_preps_poludnie = (
            r"(?:na|z|od|do|ku|kierunek|strona|część|czesc|południowo|poludniowo|pod)"
        )
        self._re_geo_polnoc = re.compile(r"\b" + geo_preps_polnoc + r"\s+(?:" + polnoc_alt + r")\b")
        self._re_geo_poludnie = re.compile(
            r"\b" + geo_preps_poludnie + 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, "kwadransa": 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:
        if not isinstance(s, str):
            raise TypeError(f"Expected str, got {type(s).__name__}")
        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_bare_dot_time.sub(self._bare_dot_time_repl, s)
        # "o godzinie" hour-alone before hour+minutes so compound hours
        # ("dwudziestej pierwszej" = 21) claim before short hour+minutes
        # ("dwudziestej" + "pierwszej" = 20:01); hour-alone lookahead blocks
        # both cardinal and ordinal minutes.
        s = self._re_o_godzinie_hour.sub(self._o_godzinie_hour_repl, s)
        s = self._re_o_godzinie_card.sub(self._o_godzinie_ord_repl, s)
        s = self._re_o_godzinie_ord.sub(self._o_godzinie_ord_repl, s)
        s = self._re_wpol_spaced.sub(
            lambda m: f"{(self.hours_gen[self._norm_phrase(m.group(1))] - 1) % 24}:30", 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
        # digit markers first (most specific), then word markers
        s = self._re_o_digit_marker.sub(self._o_digit_marker_repl, s)
        s = self._re_digit_marker.sub(self._digit_marker_repl, s)
        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|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
        # protect redundant time phrases like "12:00 w południe" / "0:00 o północy"
        s = re.sub(r"\b(?:12:00|12)\s+w\s+(?:południe|poludnie)\b", _protect_geo, s)
        s = re.sub(
            r"\b(?:0:00|24:00|0|24)\s+(?:o\s+)?(?:północy|polnocy|północ|polnoc)\b",
            _protect_geo,
            s,
        )
        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_o_hour_gen_ord_min.sub(self._o_hour_gen_ord_min_repl, s)
        s = self._re_o_digit_hour.sub(self._o_digit_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 _bare_dot_time_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)

        # context for disambiguation (computed early so hour-0 and date
        # guards can be overridden by explicit time prepositions)
        full = m.string
        start, end = m.start(), m.end()
        after = full[end : end + 15].lower()
        before = full[max(0, start - 15) : start].lower()
        after_stripped = after.lstrip()
        before_stripped = before.rstrip()
        has_time_prep = bool(re.search(r"\b(?:o|od|do|około|okolo|koło|kolo)\s*$", before_stripped))
        # time-of-day marker after the number ("5.30 rano") also signals time
        has_marker_after = bool(
            re.match(
                r"(?:rano|wieczorem|w\s+nocy|nocą|noca|nad\s+ranem|w\s+dzień|w\s+dzien)\b",
                after_stripped,
            )
        )
        has_time_context = has_time_prep or has_marker_after

        # Preposition "o" specifically signals clock time ("o 5.05"), whereas "od", "do",
        # "około" frequently introduce date ranges ("od 5.05 do 10.05") where MM 01-12 must stay dates.
        has_o_prep = bool(re.search(r"\bo\s*$", before_stripped))
        has_date_override = has_o_prep or has_marker_after

        # Hour 0 needs explicit context ("godzina 0.00" handled elsewhere,
        # "o 0.00" here). Bare "0.00" stays decimal/number.
        if hour == 0 and not has_time_context:
            return m.group(0)
        # Preserve day.month dates: DD.MM with MM 01-12 stays dotted
        # (e.g. 21.05 May 21, 05.05, 01.01, od 5.05 do 10.05). Only explicit
        # time context ("o 5.05", "5.05 rano") overrides the date guard.
        if 1 <= hour <= 31 and 1 <= minute <= 12 and not has_date_override:
            return m.group(0)

        # Preserve units of measurement, quantities, and currencies

        # Check for currency or percent symbols
        if after_stripped[:1] in {"$", "€", "£", "¢", "%"}:
            return m.group(0)
        if before_stripped[-1:] in {"$", "€", "£", "¢"}:
            return m.group(0)

        # Check for currency and measurement units following the number
        units = (
            "zł",
            "gr",
            "euro",
            "eur",
            "dolar",
            "usd",
            "cent",
            "funt",
            "gbp",
            "pln",
            "złoty",
            "złotych",
            "złote",
            "złotego",
            "grosz",
            "groszy",
            "grosze",
            "grosza",
            "procent",
            "proc",
            "kg",
            "g",
            "mg",
            "dag",
            "t",
            "tona",
            "tony",
            "ton",
            "m",
            "km",
            "cm",
            "mm",
            "metr",
            "metra",
            "metry",
            "metrów",
            "l",
            "ml",
            "litr",
            "litra",
            "litry",
            "litrów",
            "s",
            "sek",
            "sekund",
            "sekundy",
            "sekunda",
            "stopni",
            "stopnie",
            "stopnia",
            "punkt",
            "punkty",
            "punktów",
            "pkt",
        )
        for u in units:
            if after_stripped.startswith(u):
                rest = after_stripped[len(u) :]
                if not rest or not rest[0].isalpha():
                    return m.group(0)

        # Bare times require round minutes (M % 5 == 0) and daytime/evening hour (H >= 6)
        # unless explicit time context (preposition or marker) is present.
        if not has_time_context:
            if hour < 6:
                return m.group(0)
            if minute % 5 != 0:
                return m.group(0)

        return f"{hour}:{minute:02d}"

    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_phrase = self._norm_phrase(m.group(2))
        # shared by ordinal ("o godzinie szesnastej piątej") and cardinal
        # ("o godzinie piątej trzydzieści") minute variants
        minute = self.minutes_ordinal.get(minute_phrase)
        if minute is None:
            minute = self.minutes.get(minute_phrase)
        if minute is None:  # pragma: no cover – regex guarantees a hit
            return m.group(0)
        return f"o {hour}:{minute:02d}"

    def _hour_gen_ord_min_repl(self, m: re.Match[str]) -> str:
        # kept for backwards compat (bare genitive+ordinal now handled via
        # o-prefixed version to avoid ordinal hijack); not used in pipeline
        hour = self.hours_gen[self._norm_phrase(m.group(1))]
        minute = self.minutes_ordinal[self._norm_phrase(m.group(2))]
        return f"{hour}:{minute:02d}"

    def _o_hour_gen_ord_min_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_digit_marker_repl(self, m: re.Match[str]) -> str:
        hour = int(m.group(1))
        if hour > 24:
            return m.group(0)
        marker = self._norm_phrase(m.group(2))
        return f"o {hour}:00 {marker}"

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

    def _o_digit_hour_repl(self, m: re.Match[str]) -> str:
        hour = int(m.group(1))
        if hour > 24:
            return m.group(0)
        # Check context before "o <hour>":
        full = m.string
        start = m.start()
        before = full[max(0, start - 25) : start].lower()
        # Words indicating difference, increase/decrease, count, or prepositional usage:
        # e.g. "zwiększyć o 5", "mniej o 2", "chodzi o 1", "pomylił się o 3", "różnica o 4"
        if re.search(
            r"\b(?:mniej|więcej|zwiększ\w*|zmniejsz\w*|wzros\w*|spad\w*|podn\w*|obniż\w*|"
            r"pomyl\w*|spóźn\w*|różnic\w*|błąd\w*|chodz\w*|pyta\w*|prosi\w*|walcz\w*|"
            r"apel\w*|popraw\w*|zmień\w*|zmien\w*|skróć\w*|skroc\w*|wydłuż\w*|wydluz\w*|"
            r"wygra\w*|przegra\w*)\s*$",
            before,
        ):
            return m.group(0)
        # Hours 1-5 without explicit time markers (e.g. "rano", "godzina")
        # are almost exclusively delta/counting tokens in Polish ("o 1", "o 2", "o 3", "o 4", "o 5")
        if hour < 6:
            return m.group(0)
        return f"o {hour}:00"

    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
14
15
16
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
19
20
21
22
23
24
25
26
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
29
30
31
32
33
34
35
36
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,
    )