File size: 44,694 Bytes
11919c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
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
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
#!/usr/bin/env python3
"""Reference pipeline that turns a Vietnamese speech-recognition transcript into clean written text with
Qwen2.5-Instruct (ONNX): punctuation and capitals, misheard words fixed from context, fillers and stutters
removed, and lists written as "- " bullet points.

The Kotlin (kotlin/src/main/kotlin/QwenRefiner.kt) and Swift (swift/Sources/QwenRefiner/) ports follow this file
step by step, so keep the three in sync:

1. Tokenizer: Qwen2's byte-level BPE from vocab.txt and merges.txt. Text is NFC-normalized and split with Qwen2's
   pre-tokenizer regex, each piece is mapped to byte-level characters and merged by rank. Special tokens are never
   parsed out of the transcript; the prompt inserts their ids directly, so a transcript cannot inject a turn.
2. Numbers: spoken numbers of two or more words are written in digits, then dates, decimals, percentages, units,
   phone numbers and counted single digits the Vietnamese way (20/11/2026, 3,2, 20%, 12 km, 0912345678, 2 phòng),
   only where the words can mean nothing else. A small model adds numbers up wrong far more often than it copies digits.
3. Prompt: ChatML with a fixed system message and few-shot turns. Everything up to the transcript is the same for
   every call, so its KV cache is computed once when the model loads and reused.
4. Decoding: greedy, stopping at <|im_end|> or <|endoftext|>, at most max_new_tokens(n) tokens.
5. Merge: small models reword and drop words, so the output is aligned word by word with the transcript (LCS)
   and only trusted edits are kept: punctuation, capitals, line breaks and bullets; dropped fillers, stutters and
   list markers ("thứ hai là") before a bullet; a word replaced by a similar-sounding word (a misheard word
   fixed); spoken numbers written as digits with the same value, Vietnamese style (2.300.000, 3,5, 20%,
   0912345678, 20/11/2026, 4/2025); a spoken unit of measure written as its symbol (kg, mg, km, °C). Any other
   change is undone with the transcript's own words; when less than half of the transcript's words come through,
   the model answered or rewrote it, and the transcript is kept as it is.
   Last, hesitation sounds the model kept (ờ, ừm) are left out, and a yes/no question the model closed with a full
   stop (có ... không, được không, đã ... chưa) gets a question mark.
"""

import argparse
import json
import sys
import unicodedata
from pathlib import Path

import numpy as np
import onnxruntime as ort
import regex

IM_START = "<|im_start|>"
IM_END = "<|im_end|>"
END_OF_TEXT = "<|endoftext|>"

# Qwen2's pre-tokenizer split (tokenizer.json, pre_tokenizer.pretokenizers[0].pattern.Regex).
PRETOKENIZE = regex.compile(
    r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"""
)

SYSTEM = """You correct Vietnamese speech-recognition transcripts. Copy the transcript word for word, and only:
- add punctuation and capital letters (sentence starts, names, places);
- replace a word the recognizer clearly misheard with the similar-sounding word the context needs (giáo việt → giáo viên, máy tín → máy tính);
- delete filler sounds (ờ, à, ừm, ừ) and words said twice by mistake;
- write numbers as digits: 3 việc, 15, 2026, 500.000 đồng, 3,5, 20%; a phone number as one run of digits; keep "một" when it means "a" (một chút, một số, một mình);
- write dates as day/month/year: ngày 20/11/2026, ngày 5/3, tháng 4/2025;
- write units of measure as symbols after the number: 5 kg, 200 mg, 3 m, 10 km, 2 l, 30°C;
- when the speaker lists several items, steps or points, put each one on its own line starting with "- ".
Never reword, shorten, translate, answer or add words. Keep names and foreign words you do not know as they are. Reply with the corrected transcript only."""

# (transcript, corrected) turns shown before every transcript. The transcripts are as the model sees them, after
# write_numbers.
EXAMPLES = [
    (
        "ờ hôm nay em cần làm 3 việc thứ nhất là đi chợ mua 2,5 kg gạo thứ hai là đón con lúc 5 giờ chiều và thứ "
        "ba là gọi điện cho cho mẹ",
        "Hôm nay em cần làm 3 việc:\n- Đi chợ mua 2,5 kg gạo.\n- Đón con lúc 5 giờ chiều.\n- Gọi điện cho mẹ.",
    ),
    (
        "bạn có biết bệnh viện bạch mai ở đâu không mình cần đến đó trước 8 giờ sáng ngày 12/3/2026",
        "Bạn có biết Bệnh viện Bạch Mai ở đâu không? Mình cần đến đó trước 8 giờ sáng ngày 12/3/2026.",
    ),
    (
        "chúng tôi đã ký hợp đồng với công ty vào thán trước và ừm sẽ giảm 10% nếu giao hàng trong tuần lày",
        "Chúng tôi đã ký hợp đồng với công ty vào tháng trước và sẽ giảm 10% nếu giao hàng trong tuần này.",
    ),
    (
        "nhà nghiên cứu jan rossby ở viện max planck cho biết từ tháng 6/2024 hệ số kờ rô càng lớn thì sao càng "
        "sáng",
        "Nhà nghiên cứu Jan Rossby ở Viện Max Planck cho biết từ tháng 6/2024 hệ số kờ rô càng lớn thì sao càng "
        "sáng.",
    ),
]

# Filler sounds the merge lets the model delete.
FILLERS = frozenset("ờ ơ à ừ ừm ừ hừm ưm ừa um uh ah ừhm".split())

# Words of a spoken list marker ("thứ hai là", "và ba là") the merge lets the model replace with a bullet.
LIST_MARKERS = frozenset("thứ nhất hai ba bốn năm sáu bảy tám chín mười một là và".split())

# Spoken digits, and the words that scale them, for checking a number the model wrote in digits.
DIGITS = {
    "không": 0, "một": 1, "mốt": 1, "hai": 2, "ba": 3, "bốn": 4, "tư": 4, "năm": 5, "lăm": 5, "nhăm": 5,
    "sáu": 6, "bảy": 7, "tám": 8, "chín": 9,
}
SCALES = {"nghìn": 1_000, "ngàn": 1_000, "triệu": 1_000_000, "tỷ": 1_000_000_000, "tỉ": 1_000_000_000}

# Units of measure the model may write as symbols after a number, by symbol: how they are said, with tone marks,
# spaces and hyphens removed (see plain()).
UNITS = {
    "kg": frozenset({"kilogam", "kilogram", "kilo", "ki", "can"}),
    "g": frozenset({"gam", "gram", "goram"}),
    "mg": frozenset({"miligam", "miligram"}),
    "km": frozenset({"kilomet", "cayso"}),
    "m": frozenset({"met"}),
    "cm": frozenset({"xentimet", "xangtimet", "centimet"}),
    "mm": frozenset({"milimet"}),
    "m²": frozenset({"metvuong"}),
    "km²": frozenset({"kilometvuong"}),
    "ha": frozenset({"hecta", "hectare"}),
    "l": frozenset({"lit"}),
    "ml": frozenset({"mililit"}),
    "°C": frozenset({"doc", "doxe"}),
    "km/h": frozenset({"kilomettrengio", "kilometmotgio", "caysomotgio"}),
}

def bytes_to_unicode() -> dict[int, str]:
    """GPT-2's byte-level alphabet: printable bytes map to themselves, the rest to chars from U+0100 up."""
    printable = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + list(range(ord("®"), ord("ÿ") + 1))
    chars, extra = {}, 0
    for b in range(256):
        if b in printable:
            chars[b] = chr(b)
        else:
            chars[b] = chr(256 + extra)
            extra += 1
    return chars


class Tokenizer:
    def __init__(self, repo: Path):
        tokens = (repo / "vocab.txt").read_text(encoding="utf-8").split("\n")[:-1]
        self.id_of = {t: i for i, t in enumerate(tokens)}
        self.tokens = tokens
        merges = (repo / "merges.txt").read_text(encoding="utf-8").split("\n")[:-1]
        self.rank = {tuple(m.split(" ")): r for r, m in enumerate(merges)}
        self.byte_char = bytes_to_unicode()
        self.char_byte = {c: b for b, c in self.byte_char.items()}
        self.cache: dict[str, list[int]] = {}

    def special(self, token: str) -> int:
        return self.id_of[token]

    def encode(self, text: str) -> list[int]:
        ids = []
        for piece in PRETOKENIZE.findall(unicodedata.normalize("NFC", text)):
            ids += self.bpe("".join(self.byte_char[b] for b in piece.encode("utf-8")))
        return ids

    def bpe(self, word: str) -> list[int]:
        cached = self.cache.get(word)
        if cached is not None:
            return cached
        parts = list(word)
        while len(parts) > 1:
            best, best_rank = -1, None
            for i in range(len(parts) - 1):
                r = self.rank.get((parts[i], parts[i + 1]))
                if r is not None and (best_rank is None or r < best_rank):
                    best, best_rank = i, r
            if best < 0:
                break
            merged = parts[best] + parts[best + 1]
            # Merge every occurrence of the best pair, left to right, as the reference BPE does.
            out, i = [], 0
            while i < len(parts):
                if i < len(parts) - 1 and parts[i] == parts[best] and parts[i + 1] == parts[best + 1]:
                    out.append(merged)
                    i += 2
                else:
                    out.append(parts[i])
                    i += 1
            parts = out
        ids = [self.id_of[p] for p in parts]
        self.cache[word] = ids
        return ids

    def decode(self, ids: list[int]) -> str:
        data = bytes(self.char_byte[c] for i in ids for c in self.tokens[i])
        return data.decode("utf-8", errors="replace")


def prompt_prefix(tok: Tokenizer) -> list[int]:
    """System message and few-shot turns, ending right before the transcript."""
    start, end, nl = tok.special(IM_START), tok.special(IM_END), tok.encode("\n")
    ids = [start] + tok.encode("system\n" + SYSTEM) + [end] + nl
    for transcript, edited in EXAMPLES:
        ids += [start] + tok.encode("user\n" + transcript) + [end] + nl
        ids += [start] + tok.encode("assistant\n" + edited) + [end] + nl
    return ids + [start] + tok.encode("user\n")


def prompt_suffix(tok: Tokenizer, transcript: str) -> list[int]:
    """The transcript and the opening of the assistant turn."""
    return tok.encode(transcript) + [tok.special(IM_END)] + tok.encode("\n") + [tok.special(IM_START)] + tok.encode("assistant\n")


def max_new_tokens(input_tokens: int) -> int:
    """Room for punctuation, bullet markers and line breaks on top of the input's length."""
    return min(input_tokens * 3 // 2 + 32, 1024)


def prepare(transcript: str) -> str:
    """NFC, whitespace collapsed. Whisper's punctuation is kept: it helps the model."""
    return " ".join(unicodedata.normalize("NFC", transcript).split())


class Unit:
    """A word of the text with what is written around it: punctuation before and after it, and whether it starts
    a line and a bullet. key is the word in lower case, for matching."""

    __slots__ = ("lead", "word", "trail", "newline", "bullet")

    def __init__(self, word: str, lead: str = "", trail: str = "", newline: bool = False, bullet: bool = False):
        self.word, self.lead, self.trail, self.newline, self.bullet = word, lead, trail, newline, bullet

    @property
    def key(self) -> str:
        return self.word.lower()


# A word: from its first to its last letter or digit (or a degree sign before one, as in °C); what is around it
# is its punctuation.
WORD_EDGES = regex.compile(r"^([^\p{L}\p{N}°]*)(.*?)([^\p{L}\p{N}]*)$", regex.DOTALL)
BULLETS = ("-", "*", "•", "–")


def units(text: str) -> list[Unit]:
    """Splits text into Units. Line breaks and bullet markers go to the next word, punctuation-only pieces to the
    next word's lead (or the last word's trail at the end)."""
    out: list[Unit] = []
    newline = bullet = False
    lead = ""
    for piece in regex.findall(r"\n|[^\s]+", text):
        if piece == "\n":
            if out:
                newline = True
            continue
        before, word, after = WORD_EDGES.match(piece).groups()
        if not word:
            if piece in BULLETS and not lead:
                bullet = True
            elif out and not newline and not bullet:
                out[-1].trail += piece
            else:
                lead += piece
            continue
        if before[:1] in BULLETS and len(before) == 1 and not lead:
            bullet, before = True, ""
        out.append(Unit(word, lead + before, after, newline, bullet))
        newline = bullet = False
        lead = ""
    return out


def render(items: list[Unit]) -> str:
    parts = []
    for i, u in enumerate(items):
        if i > 0:
            parts.append("\n" if u.newline else " ")
        parts.append(("- " if u.bullet else "") + u.lead + u.word + u.trail)
    return "".join(parts)


def plain(word: str) -> str:
    """Lower case without tone and vowel marks (đ is d), so similar-sounding words compare close."""
    decomposed = unicodedata.normalize("NFD", word.lower().replace("đ", "d"))
    return "".join(c for c in decomposed if not unicodedata.combining(c))


def edit_distance(a: str, b: str) -> int:
    row = list(range(len(b) + 1))
    for i in range(1, len(a) + 1):
        prev, row[0] = row[0], i
        for j in range(1, len(b) + 1):
            cur = min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] != b[j - 1]))
            prev, row[j] = row[j], cur
    return row[len(b)]


# Initial consonants Vietnamese speech merges, so a recognizer confuses them: gi-, d- and r- sound alike in the
# north, as do s- and x-, and tr- and ch-. Longest first.
SAME_SOUND_STARTS = (("gi", "d"), ("tr", "ch"), ("r", "d"), ("x", "s"))


def sound(word: str) -> str:
    """plain(word) with its first consonant folded to the one it sounds like: giục is duc, xông is song."""
    p = plain(word)
    for start, same in SAME_SOUND_STARTS:
        if p.startswith(start) and len(p) > len(start):
            return same + p[len(start):]
    return p


def sounds_alike(a: str, b: str) -> bool:
    """One edit apart without marks (and with gi/d/r, tr/ch and x/s folded) when either word is short, two when both
    are longer: lội/nội, thán/tháng, giục/dục, but not xông/đường."""
    pa, pb = sound(a), sound(b)
    return edit_distance(pa, pb) <= (1 if min(len(pa), len(pb)) <= 4 else 2)


def spoken_number(words: list[str]) -> int | None:
    """The value of a spoken Vietnamese number ("hai nghìn không trăm hai mươi sáu" is 2026), or None when the
    words are not one number."""
    total = group = 0  # group: the part below the current thousand
    digit: int | None = None
    for w in words:
        if w in DIGITS:
            if digit is not None:
                return None  # two digits in a row: a list of digits, not one number
            digit = DIGITS[w]
        elif w == "mười":
            if digit is not None:
                return None
            group += 10
        elif w == "mươi" and digit is not None:
            group, digit = group + digit * 10, None
        elif w == "trăm" and digit is not None:
            group, digit = group + digit * 100, None
        elif w in ("linh", "lẻ"):
            continue
        elif w in SCALES:
            total, group, digit = total + (group + (digit or 0)) * SCALES[w], 0, None
        else:
            return None
    if not words:
        return None
    return total + group + (digit or 0)


# Words a spoken number is built from, for writing it in digits before the model sees it.
HUNDREDS_DIGITS = frozenset("một hai ba bốn năm sáu bảy tám chín".split())  # before trăm, mươi, or a scale
TENS_DIGITS = HUNDREDS_DIGITS - {"một"}  # before mươi
AFTER_MUOI = frozenset("mốt một hai ba bốn tư lăm nhăm sáu bảy tám chín".split())  # năm after mươi is "year"
AFTER_MUOI_NUMBER = AFTER_MUOI - {"mốt", "tư"}  # after mười
NUMBER_WORDS = frozenset(DIGITS) | {"mười", "mươi", "trăm", "linh", "lẻ"} | frozenset(SCALES)
SCALE_WORDS = frozenset({"mươi", "trăm"}) | frozenset(SCALES)  # words that go on from a digit before them


def parse_below_thousand(words: list[str], i: int, after_scale: bool) -> tuple[int, int] | None:
    """(value, next index) of the longest number below 1,000 at words[i]: [digit trăm] then mười [digit],
    digit mươi [digit], or (after trăm) linh digit. "không trăm" only after a scale (2026 is "hai nghìn không
    trăm hai mươi sáu"). None when nothing parses."""
    value, j = 0, i
    hundreds = j + 1 < len(words) and words[j + 1] == "trăm" and (
        words[j] in HUNDREDS_DIGITS or (after_scale and words[j] == "không"))
    if hundreds:
        value, j = DIGITS[words[j]] * 100, j + 2
    if j < len(words) and words[j] == "mười":
        value, j = value + 10, j + 1
        if j < len(words) and words[j] in AFTER_MUOI_NUMBER:
            value, j = value + DIGITS[words[j]], j + 1
    elif j + 1 < len(words) and words[j] in TENS_DIGITS and words[j + 1] == "mươi":
        value, j = value + DIGITS[words[j]] * 10, j + 2
        if j < len(words) and words[j] in AFTER_MUOI:
            value, j = value + DIGITS[words[j]], j + 1
    elif hundreds and j + 1 < len(words) and words[j] in ("linh", "lẻ") and words[j + 1] in DIGITS:
        value, j = value + DIGITS[words[j + 1]], j + 2
    elif not hundreds and j < len(words) and words[j] in HUNDREDS_DIGITS and j + 1 < len(words) and words[j + 1] in SCALES:
        value, j = DIGITS[words[j]], j + 1  # a lone digit before nghìn, triệu, tỷ
    return (value, j) if j > i else None


def parse_number(words: list[str], i: int) -> tuple[int | None, int] | None:
    """(value, next index) of a spoken number of two or more words at words[i], with its scales falling (tỷ, then
    triệu, then nghìn), or None. The value is None when more number words follow that could belong to it ("hai
    triệu ba" is 2.3 million said short, "hai mươi ba mươi" is two numbers): the model sees those words."""
    total, j, last_scale, after_scale = 0, i, None, False
    while True:
        part = parse_below_thousand(words, j, after_scale)
        if part is None:
            break
        group, k = part
        if k < len(words) and words[k] in SCALES and (last_scale is None or SCALES[words[k]] < last_scale):
            last_scale = SCALES[words[k]]
            total, j, after_scale = total + group * last_scale, k + 1, True
            continue
        total, j = total + group, k
        break
    if j - i < 2:
        return None
    if j < len(words) and words[j] in NUMBER_WORDS:
        # "năm" after a number is "year" unless it is the digit of a number that goes on ("hai mươi năm mươi" is
        # 20-50): "tháng mười một năm 2026", "20 năm trước".
        if words[j] != "năm" or (j + 1 < len(words) and words[j + 1] in SCALE_WORDS):
            return None, j
    return total, j


def write_number(value: int) -> str:
    """Digits, with "." between thousands from five digits up (2026, 10.000, 2.300.000), as Vietnamese writes them."""
    return f"{value:,}".replace(",", ".") if value >= 10_000 else str(value)


def write_spoken_numbers(text: str) -> str:
    """Writes spoken numbers of two or more words in digits ("hai nghìn không trăm hai mươi sáu" is 2026) so the
    model only has to lay them out, which a small model does far more reliably than adding them up. Single words
    stay ("một chút", "ba việc"): only the model can tell from context whether they are numbers."""
    tokens = text.split(" ")
    edges = [WORD_EDGES.match(t).groups() for t in tokens]
    words = [w.lower() for _, w, _ in edges]
    out, i = [], 0
    while i < len(tokens):
        if words[i] not in NUMBER_WORDS:
            out.append(tokens[i])
            i += 1
            continue
        # Punctuation ends a number: "hai mươi, ba mươi" is two.
        stop = i + 1
        while stop < len(tokens) and not edges[stop - 1][2] and not edges[stop][0]:
            stop += 1
        parsed = parse_number(words[:stop], i)
        if parsed is None:
            out.append(tokens[i])
            i += 1
        elif parsed[0] is None:  # unclear where the number ends: keep the whole run of number words
            j = i
            while j < stop and words[j] in NUMBER_WORDS:
                j += 1
            out += tokens[i:j]
            i = j
        else:
            value, j = parsed
            out.append(edges[i][0] + write_number(value) + edges[j - 1][2])
            i = j
    return " ".join(out)


# A single spoken digit that is plainly a number next to tháng, phẩy, phần trăm or a unit, and its value.
SINGLE_NUMBERS = {w: v for w, v in DIGITS.items() if w not in ("mốt", "lăm", "nhăm")} | {"mười": 10}
FRACTION_DIGITS = {w: v for w, v in DIGITS.items() if w not in ("mốt", "tư")}  # read one by one after phẩy

# Spoken units of measure written as symbols after a number, as plain() forms. "kí" and "cân" are left to the
# model: alone they are often not units.
UNIT_WORDS = {form: symbol for symbol, forms in UNITS.items() for form in forms if form not in ("ki", "can")}
UNIT_MAX_WORDS = 5  # "ki lô mét trên giờ"
# Digits read one by one this many times or more in a row are a phone number or a code: "năm sáu người" (five or
# six people) is not.
PHONE_MIN_DIGITS = 7

# Words that count what a single spoken digit before them numbers: "hai phòng" is 2 phòng. Two-word counters are
# written with a space. Left out: "bạn" and "con" ("ba bạn" is also "your father"), and "năm" before "tháng" and
# "năm" ("năm tháng" is also "the years"). "một" never counts: it is also "a".
COUNTERS = frozenset("""người lần viên cái chiếc quyển cuốn phòng giờ phút giây ngày tuần tháng năm tuổi việc
    tầng lớp chai ly cốc bát hộp gói bộ đôi trang bài câu điểm muỗng thìa quả trái km kg""".split()) | {"học sinh", "sinh viên", "nhân viên"}
COUNT_DIGITS = {w: v for w, v in SINGLE_NUMBERS.items() if w not in ("một", "không", "tư")}


def number_token(word: str) -> int | None:
    """The value of a word that is a whole number on its own: digits without separators, or a single spoken digit."""
    if regex.fullmatch(r"\d+", word):
        return int(word)
    return SINGLE_NUMBERS.get(word.lower())


def write_measures(text: str) -> str:
    """After write_spoken_numbers: dates as day/month/year (ngày 20 tháng 11 năm 2026 is ngày 20/11/2026, tháng 4
    năm 2025 is tháng 4/2025), decimals with a comma (ba phẩy hai is 3,2), percentages (20 phần trăm is 20%) and
    units of measure as symbols (12 ki lô mét is 12 km), phone numbers (không chín một ... is 091...), and a single
    digit that counts something (hai phòng is 2 phòng). Each one only where the words can mean nothing else, so the model just copies them; punctuation inside a run stops it."""
    tokens = text.split(" ")
    edges = [WORD_EDGES.match(t).groups() for t in tokens]
    words = [w.lower() for _, w, _ in edges]

    def joined(i: int, j: int) -> bool:
        """Whether tokens[i:j] were said as one run: no punctuation between them."""
        return all(not edges[k][2] for k in range(i, j - 1)) and all(not edges[k][0] for k in range(i + 1, j))

    def date(i: int) -> tuple[str, int] | None:
        """D tháng M [năm YYYY] after ngày, mùng or mồng; M năm YYYY after tháng."""
        if i == 0 or words[i - 1] not in ("ngày", "mùng", "mồng", "tháng") or edges[i - 1][2]:
            return None
        first = number_token(edges[i][1])
        if first is None or i + 2 >= len(tokens):
            return None
        if words[i - 1] == "tháng":  # month/year
            year = edges[i + 2][1]
            if words[i + 1] == "năm" and regex.fullmatch(r"\d{4}", year) and 1 <= first <= 12 and joined(i, i + 3):
                return f"{first}/{year}", i + 3
            return None
        month = number_token(edges[i + 2][1])
        if words[i + 1] != "tháng" or month is None or not (1 <= first <= 31 and 1 <= month <= 12) or not joined(i, i + 3):
            return None
        if i + 4 < len(tokens) and words[i + 3] == "năm" and regex.fullmatch(r"\d{4}", edges[i + 4][1]) and joined(i, i + 5):
            return f"{first}/{month}/{edges[i + 4][1]}", i + 5
        return f"{first}/{month}", i + 3

    def amount(i: int) -> tuple[str, int] | None:
        """A number with what makes it plain: X phẩy Y, then phần trăm or a unit; or a number with phần trăm or a
        unit. A digits word alone is left as it is."""
        whole = number_token(edges[i][1])
        if whole is None and written_number(edges[i][1]) is None:
            return None
        value, j = edges[i][1] if whole is None else str(whole), i + 1
        if whole is not None and j + 1 < len(tokens) and words[j] == "phẩy" and joined(i, j + 2):
            if regex.fullmatch(r"\d+", edges[j + 1][1]):
                value, j = f"{value},{edges[j + 1][1]}", j + 2
            elif words[j + 1] in FRACTION_DIGITS:
                k = j + 1
                while k < len(tokens) and words[k] in FRACTION_DIGITS and joined(i, k + 1):
                    k += 1
                value, j = f"{value}," + "".join(str(FRACTION_DIGITS[w]) for w in words[j + 1:k]), k
        if j + 1 < len(tokens) and words[j:j + 2] == ["phần", "trăm"] and joined(i, j + 2):
            return value + "%", j + 2
        for k in range(min(len(tokens), j + UNIT_MAX_WORDS), j, -1):
            symbol = UNIT_WORDS.get(plain("".join(words[j:k]).replace("-", "")))
            # "một mét bảy" is 1.7 m said short: a number after the unit leaves it to the model.
            if symbol is not None and joined(i, k) and (k >= len(tokens) or number_token(edges[k][1]) is None
                                                        or edges[k - 1][2]):
                return value + ("" if symbol == "°C" else " ") + symbol, k
        if "," in value:
            return value, j
        return None

    def count(i: int) -> tuple[str, int] | None:
        """A single spoken digit before a counter (hai phòng is 2 phòng), or after "điểm" (điểm mười is điểm 10)."""
        value = COUNT_DIGITS.get(words[i])
        if value is None:
            return None
        if i > 0 and words[i - 1] == "điểm" and not edges[i - 1][2]:
            return str(value), i + 1
        if i + 1 >= len(tokens) or not joined(i, i + 2):
            return None
        after = words[i + 1]
        if words[i] == "năm" and after in ("tháng", "năm"):
            return None
        # Next to other digits it is a run of digits ("bốn năm sáu"), not a count.
        if (i > 0 and words[i - 1] in DIGITS and joined(i - 1, i + 1)) or (
                i + 2 < len(tokens) and words[i + 2] in DIGITS and joined(i + 1, i + 3)):
            return None
        two = after + " " + words[i + 2] if i + 2 < len(tokens) and joined(i + 1, i + 3) else ""
        if after in COUNTERS or two in COUNTERS:
            return str(value), i + 1
        return None

    def phone(i: int) -> tuple[str, int] | None:
        j = i
        while j < len(tokens) and words[j] in FRACTION_DIGITS and words[j] != "lăm" and joined(i, j + 1):
            j += 1
        if j - i < PHONE_MIN_DIGITS:
            return None
        return "".join(str(DIGITS[w]) for w in words[i:j]), j

    out, i = [], 0
    while i < len(tokens):
        found = date(i) or amount(i) or phone(i) or count(i)
        if found is None:
            out.append(tokens[i])
            i += 1
        else:
            written, j = found
            out.append(edges[i][0] + written + edges[j - 1][2])
            i = j
    return " ".join(out)


def write_numbers(text: str) -> str:
    """The transcript as the model sees it: spoken numbers, dates, decimals, percentages, units and phone numbers
    written the Vietnamese way wherever the words can mean nothing else."""
    return write_measures(write_spoken_numbers(text))


def written_number(word: str) -> int | None:
    """The value of digits the model wrote, with "." or "," as thousands separators: 15, 2026, 1.000."""
    if not regex.fullmatch(r"\d{1,3}([.,]\d{3})*|\d+", word):
        return None
    return int(word.replace(".", "").replace(",", ""))


def spoken_digits(words: list[str]) -> str | None:
    """Digits read one by one, as in a phone number ("không chín một" is "091"), or None."""
    if len(words) < 2 or any(w not in DIGITS or w in ("mốt", "lăm", "nhăm", "tư") for w in words):
        return None
    return "".join(str(DIGITS[w]) for w in words)


def number_matches(words: list[str], written: str) -> bool:
    """Whether the digits the model wrote say the same as the spoken words: a number (2026, 1.000), digits read
    one by one (0912), or a decimal with a comma ("ba phẩy năm" is 3,5)."""
    if not words:
        return False
    if len(words) == 1 and written_number(words[0]) is not None:  # digits written before the model ran
        return written_number(words[0]) == written_number(written)
    if regex.fullmatch(r"\d+", written) and spoken_digits(words) == written:
        return True
    value = written_number(written)
    if value is not None:
        return value == spoken_number(words)
    decimal = regex.fullmatch(r"(\d+),(\d+)", written)
    if decimal is None or "phẩy" not in words:
        return False
    k = words.index("phẩy")
    whole, fraction = words[:k], words[k + 1:]
    fraction_ok = spoken_digits(fraction) == decimal[2] or (
        decimal[2][0] != "0" and spoken_number(fraction) == int(decimal[2]))
    return spoken_number(whole) == int(decimal[1]) and fraction_ok


def split_matches(words: list[str], sep: str, parts: list[str]) -> bool:
    """Whether words are parts[0] sep parts[1] sep ... read aloud, trying every place sep could split them
    ("năm" is both "year" and five, so "tháng năm năm 2024" splits in more than one way)."""
    if len(parts) == 1:
        return number_matches(words, parts[0])
    return any(
        w == sep and number_matches(words[:k], parts[0]) and split_matches(words[k + 1:], sep, parts[1:])
        for k, w in enumerate(words)
    )


def date_matches(words: list[str], written: str) -> bool:
    """A spoken date written the Vietnamese way: day/month/year, day/month or (after "tháng") month/year.
    "hai mươi tháng mười một năm hai nghìn không trăm hai mươi sáu" is 20/11/2026."""
    match = regex.fullmatch(r"(\d{1,2})/(\d{1,2})(?:/(\d{4}))?", written)
    if match is None:
        return False
    if words and words[0] in ("mùng", "mồng"):
        words = words[1:]
    first, second, year = match[1], match[2], match[3]
    if not (1 <= int(first) <= 31 and 1 <= int(second) <= 12):
        return False
    if year is None:
        return split_matches(words, "tháng", [first, second])
    return any(
        w == "năm" and split_matches(words[:k], "tháng", [first, second]) and number_matches(words[k + 1:], year)
        for k, w in enumerate(words)
    )


def month_year_matches(words: list[str], written: str) -> bool:
    """A month and year after "tháng", written month/year: "tư năm hai nghìn không trăm hai mươi lăm" is 4/2025."""
    match = regex.fullmatch(r"(\d{1,2})/(\d{4})", written)
    return match is not None and 1 <= int(match[1]) <= 12 and split_matches(words, "năm", [match[1], match[2]])


def accept_number(old: list[str], new: Unit) -> bool:
    """One written token for spoken words: a number, a percentage, a date or a month and year."""
    if new.trail.startswith("%"):
        return old[-2:] == ["phần", "trăm"] and number_matches(old[:-2], new.word)
    return number_matches(old, new.word) or date_matches(old, new.word) or month_year_matches(old, new.word)


def accept_unit(old: list[str], symbol: str) -> bool:
    """A spoken unit of measure written as its symbol: "ki lô gam" is kg."""
    spoken = plain("".join(old).replace("-", ""))
    return spoken in UNITS.get(symbol, ())


def is_number(u: Unit | None) -> bool:
    """A word that ends a number: digits, or a spoken number word."""
    return u is not None and (u.key in NUMBER_WORDS or written_number(u.word) is not None)


def accept_replacement(old: list[Unit], new: list[Unit], before: Unit | None) -> bool:
    """A misheard word fixed one for one, a spoken number written as digits with the same value (a number,
    percentage, decimal, phone number or date), or a unit of measure written as its symbol after one. before is
    the transcript word before old: a unit alone is trusted only after a number ("kì" is not kg)."""
    if len(new) == 1 and new[0].word in UNITS:
        return is_number(before) and accept_unit([o.key for o in old], new[0].word)
    if len(old) == len(new) and all(sounds_alike(o.key, n.key) for o, n in zip(old, new)):
        return True
    keys = [o.key for o in old]
    if len(new) == 1:
        joined = regex.fullmatch(r"(\d[\d.,]*)(\D+)", new[0].word)  # 5kg, 30°C
        if joined is not None:
            new = [Unit(joined[1]), Unit(joined[2])]
        elif accept_number(keys, new[0]) or (is_number(before) and accept_unit(keys, new[0].word)):
            return True
    if len(new) == 2:
        return any(accept_number(keys[:k], new[0]) and accept_unit(keys[k:], new[1].word) for k in range(1, len(keys)))
    return False


def align(a: list[str], b: list[str]) -> list[tuple[str, int, int, int, int]]:
    """Longest common subsequence of a and b as difflib-style opcodes (tag, i1, i2, j1, j2); a match is taken as
    early as possible, and an unmatched run of a before one of b."""
    n, m = len(a), len(b)
    lcs = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n - 1, -1, -1):
        for j in range(m - 1, -1, -1):
            lcs[i][j] = lcs[i + 1][j + 1] + 1 if a[i] == b[j] else max(lcs[i + 1][j], lcs[i][j + 1])
    ops, i, j = [], 0, 0
    while i < n or j < m:
        if i < n and j < m and a[i] == b[j]:
            i2, j2 = i, j
            while i2 < n and j2 < m and a[i2] == b[j2]:
                i2, j2 = i2 + 1, j2 + 1
            ops.append(("equal", i, i2, j, j2))
            i, j = i2, j2
            continue
        i2, j2 = i, j
        while i2 < n or j2 < m:
            if i2 < n and j2 < m and a[i2] == b[j2]:
                break
            if j2 >= m or (i2 < n and lcs[i2 + 1][j2] >= lcs[i2][j2 + 1]):
                i2 += 1
            else:
                j2 += 1
        tag = "replace" if i2 > i and j2 > j else ("delete" if i2 > i else "insert")
        ops.append((tag, i, i2, j, j2))
        i, j = i2, j2
    return ops


def may_delete(old: list[Unit], start: int, end: int, next_unit: Unit | None) -> list[bool]:
    """For each transcript word in old[start:end] the model left out: whether leaving it out is trusted."""
    run = old[start:end]
    if next_unit is not None and next_unit.bullet and all(u.key in LIST_MARKERS for u in run):
        return [True] * len(run)
    keep = []
    for k, u in enumerate(run):
        i = start + k
        repeated = (i > 0 and old[i - 1].key == u.key) or (i + 1 < len(old) and old[i + 1].key == u.key)
        keep.append(u.key in FILLERS or repeated)
    return keep


def merge(transcript: str, edited: str) -> str:
    """The model's text, with every edit that is not trusted undone (see the module docstring)."""
    old, new = units(transcript), units(edited)
    out: list[Unit] = []
    carry: Unit | None = None  # layout of dropped words, for the next word written

    def emit(u: Unit) -> None:
        nonlocal carry
        if carry is not None:
            u.newline, u.bullet = u.newline or carry.newline, u.bullet or carry.bullet
            u.lead = carry.lead + u.lead
            carry = None
        out.append(u)

    def drop(dropped: list[Unit]) -> None:
        """Keeps the layout and the closing punctuation of words that are left out."""
        nonlocal carry
        for u in dropped:
            if u.newline or u.bullet or u.lead:
                carry = carry or Unit("")
                carry.newline, carry.bullet = carry.newline or u.newline, carry.bullet or u.bullet
                carry.lead += u.lead
            if u.trail and out and not out[-1].trail:
                out[-1].trail = u.trail

    def restore(words: list[Unit], like: list[Unit]) -> list[Unit]:
        """The transcript's words in place of the model's [like]: its layout, its first capital, its last mark."""
        back = [Unit(u.word) for u in words]
        if like and back:
            back[0].newline, back[0].bullet, back[0].lead = like[0].newline, like[0].bullet, like[0].lead
            back[-1].trail = like[-1].trail.lstrip("%°")  # a sign that belonged to the model's digits
            if like[0].word[:1].isupper():
                back[0].word = back[0].word[:1].upper() + back[0].word[1:]
        return back

    kept = 0  # transcript words the model kept or edited in a trusted way
    for tag, i1, i2, j1, j2 in align([u.key for u in old], [u.key for u in new]):
        if tag == "equal":
            kept += i2 - i1
            for k, u in enumerate(new[j1:j2]):
                # A % is trusted only for "phần trăm" the model left out right after the number.
                said = i1 + k + 1 == i2 and [o.key for o in old[i2:i2 + 2]] == ["phần", "trăm"]
                if "%" in u.trail and "%" not in old[i1 + k].trail and not said:
                    u.trail = u.trail.replace("%", "")
                emit(u)
        elif tag == "insert":
            drop(new[j1:j2])
        elif tag == "replace" and accept_replacement(old[i1:i2], new[j1:j2], old[i1 - 1] if i1 > 0 else None):
            kept += i2 - i1
            for u in new[j1:j2]:
                emit(u)
        elif tag == "replace":
            for u in restore(old[i1:i2], new[j1:j2]):
                emit(u)
        elif [u.key for u in old[i1:i2]] == ["phần", "trăm"] and out and "%" in out[-1].trail:
            drop(old[i1:i2])  # written as the % after the number
        else:  # delete
            next_unit = new[j1] if j1 < len(new) else None
            for u, ok in zip(old[i1:i2], may_delete(old, i1, i2, next_unit)):
                if not ok:
                    restored = Unit(u.word)
                    # The word belongs before the mark that ends the previous word's sentence or clause.
                    if out and out[-1].trail:
                        restored.trail, out[-1].trail = out[-1].trail, ""
                    emit(restored)
    if carry is not None and out and carry.lead:
        out[-1].trail += carry.lead
    if kept * 2 < len(old):
        return ""  # the model answered or rewrote instead of correcting: the caller keeps the transcript
    out = drop_hesitations(out)
    capitalize_sentence_starts(out)
    mark_questions(out)
    return render(out)


# Hesitation sounds the merge leaves out even when the model kept them. Not "ừ" (yes), "à" (also a question word)
# or "ơ" (surprise).
HESITATIONS = frozenset("ờ ừm ưm hừm ừhm um uh".split())


def drop_hesitations(items: list[Unit]) -> list[Unit]:
    """The units without hesitation sounds: their layout goes to the next word, a closing mark to the word before."""
    out: list[Unit] = []
    carry: Unit | None = None
    for u in items:
        if u.key in HESITATIONS:
            carry = carry or Unit("")
            carry.newline, carry.bullet = carry.newline or u.newline, carry.bullet or u.bullet
            carry.lead += u.lead
            if u.trail.rstrip(",;") and out and out[-1].trail in ("", ","):
                out[-1].trail = u.trail.rstrip(",;")
            elif out and out[-1].trail == "," and u.trail in ("", ","):
                out[-1].trail = ""  # "đi, ờ, chợ" is "đi chợ": the commas set off the sound
            continue
        if carry is not None:
            u.newline, u.bullet, u.lead = u.newline or carry.newline, u.bullet or carry.bullet, carry.lead + u.lead
            carry = None
        out.append(u)
    return out if out else items


# How a yes/no question ends: "có ... không", "... được không", "... phải không", "đã ... chưa". "là không" and
# "cũng không" end statements.
QUESTION_OPENERS = {"không": ("có",), "chưa": ("đã", "có")}
QUESTION_ENDINGS = (("được", "không"), ("phải", "không"))
NOT_QUESTIONS = frozenset({"là", "cũng", "đều", "vẫn", "chẳng"})


def mark_questions(items: list[Unit]) -> None:
    """A sentence the model ended with a full stop that is a yes/no question gets a question mark: small models
    often miss "Bạn có biết mấy giờ rồi không?"."""
    start = 0
    for i, u in enumerate(items):
        if i > start and (u.newline or u.bullet):
            start = i
        if not u.trail.endswith((".", "?", "!")):
            continue
        sentence = [v.key for v in items[start:i + 1]]
        start = i + 1
        if not u.trail.endswith(".") or u.trail.endswith("..") or len(sentence) < 3 or sentence[-2] in NOT_QUESTIONS:
            continue
        if tuple(sentence[-2:]) in QUESTION_ENDINGS or any(w in sentence[:-1] for w in QUESTION_OPENERS.get(sentence[-1], ())):
            u.trail = u.trail[:-1] + "?"


def capitalize_sentence_starts(items: list[Unit]) -> None:
    """Restored words keep the transcript's lower case; one that now starts a sentence, line or bullet gets a capital."""
    for i, u in enumerate(items):
        starts = i == 0 or u.newline or u.bullet or items[i - 1].trail.rstrip("\"')»”").endswith((".", "?", "!"))
        if starts and u.word[:1].islower() and not u.lead:
            u.word = u.word[:1].upper() + u.word[1:]


class Refiner:
    def __init__(self, repo: Path, threads: int = 4):
        self.tok = Tokenizer(repo)
        options = ort.SessionOptions()
        options.intra_op_num_threads = threads
        self.session = ort.InferenceSession(str(repo / "onnx" / "model.onnx"), options, providers=["CPUExecutionProvider"])
        names = [i.name for i in self.session.get_inputs()]
        self.past_names = [n for n in names if n.startswith("past_key_values.")]
        self.uses_position_ids = "position_ids" in names
        config = json.loads((repo / "onnx" / "genai_config.json").read_text(encoding="utf-8"))["model"]["decoder"]
        self.kv_heads, self.head_size = config["num_key_value_heads"], config["head_size"]
        self.stop = {self.tok.special(IM_END), self.tok.special(END_OF_TEXT)}
        self.prefix = prompt_prefix(self.tok)
        empty = np.zeros((1, self.kv_heads, 0, self.head_size), np.float32)
        _, self.prefix_cache = self.forward(self.prefix, {n: empty for n in self.past_names}, 0)

    def forward(self, ids: list[int], cache: dict[str, np.ndarray], past_len: int) -> tuple[np.ndarray, dict[str, np.ndarray]]:
        """Runs ids after past_len cached tokens; returns the last position's logits and the grown cache."""
        total = past_len + len(ids)
        feed = {
            "input_ids": np.array([ids], np.int64),
            "attention_mask": np.ones((1, total), np.int64),
            **cache,
        }
        if self.uses_position_ids:
            feed["position_ids"] = np.arange(past_len, total, dtype=np.int64)[None, :]
        outputs = self.session.run(None, feed)
        out_names = [o.name for o in self.session.get_outputs()]
        result = dict(zip(out_names, outputs))
        new_cache = {n: result[n.replace("past_key_values.", "present.")] for n in self.past_names}
        return result["logits"][0, -1], new_cache

    def generate(self, transcript: str) -> list[int]:
        suffix = prompt_suffix(self.tok, transcript)
        logits, cache = self.forward(suffix, self.prefix_cache, len(self.prefix))
        length = len(self.prefix) + len(suffix)
        out: list[int] = []
        for _ in range(max_new_tokens(len(suffix))):
            nxt = int(np.argmax(logits))
            if nxt in self.stop:
                break
            out.append(nxt)
            logits, cache = self.forward([nxt], cache, length)
            length += 1
        return out

    def refine(self, transcript: str) -> str:
        text = write_numbers(prepare(transcript))
        if not text:
            return transcript
        edited = self.tok.decode(self.generate(text)).strip()
        merged = merge(text, edited) if edited else ""
        return merged if merged.strip() else text


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("repo", type=Path)
    parser.add_argument("text", nargs="+")
    args = parser.parse_args()
    refiner = Refiner(args.repo)
    for text in args.text:
        print(refiner.refine(text))
        sys.stdout.flush()


if __name__ == "__main__":
    main()