"""One canonical spelling for every dataset, shared by training and scoring. The sources disagree: the Bible text is truecase with punctuation, the radio transcripts are lowercase with none, `history-data` is lowercase with glued clitics. A model trained on the mix has to guess which convention a given recording wants, and every wrong guess counts as an error. So the training targets and the metric both go through `normalize`, and the model only ever has to learn one form: lowercase, no punctuation, digits spelled out, modern spelling without apostrophes. Casing and punctuation are a separate job for a text model on top of the transcript. """ import re import unicodedata # Kreyòl numerals. 0-19 are irregular; 20-99 is built below; the forms follow common usage # (25 vennsenk, 75 swasannkenz, 98 katrevendizuit, 2026 de mil vennsis). UNITS = ["zewo", "en", "de", "twa", "kat", "senk", "sis", "sèt", "uit", "nèf", "dis", "onz", "douz", "trèz", "katòz", "kenz", "sèz", "disèt", "dizuit", "diznèf"] TENS = {20: "ven", 30: "trant", 40: "karant", 50: "senkant", 60: "swasant"} NASAL = {20: "venn", 30: "trann", 40: "karann", 50: "senkann", 60: "swasann"} HARD = {20: "vent", 30: "trant", 40: "karant", 50: "senkant", 60: "swasant"} HUNDREDS = [None, "san", "desan", "twasan", "katsan", "senksan", "sisan", "sètsan", "uisan", "nèfsan"] LETTER = re.compile(r"[^\W\d_]", re.UNICODE) # Tokens that appear in French but not in Kreyòl, used to flag French-language rows FRENCH = {"les", "des", "est", "sont", "nous", "vous", "dans", "avec", "qui", "que", "qu", "une", "cette", "ces", "il", "elle", "ils", "elles", "leur", "notre", "votre", "etait", "était", "peut", "faire", "aussi", "donc", "alors", "tres", "très", "beaucoup", "toujours", "jamais", "français", "francais", "haïti", "sur", "plus", "cet", "vers", "chez", "quelque", "le", "du", "au", "aux", "ce", "vient", "suis", "sais", "veux", "avez", "avons", "ont", "été", "comment", "pourquoi", "aujourd", "hui", "parce", "puis", "ensuite", "toute", "autre", "meme", "même", "c", "j", "quand", "notamment", "ainsi"} # Kreyòl writes only à, è and ò; any other accent is a French (or English) spelling NON_KREYOL = re.compile(r"[éêëçùûîïôœæáíúñ]") def spell_int(n): """Spell a non-negative integer below one million.""" if n < 20: return UNITS[n] if n < 100: ten, unit = n // 10 * 10, n % 10 if ten == 70: return "swasann" + UNITS[10 + unit] if ten == 80: return "katreven" if unit == 0 else f"katreven {UNITS[unit]}" if ten == 90: return "katreven" + UNITS[10 + unit] if unit == 0: return TENS[ten] if unit == 1: return HARD[ten] + "eyen" if unit in (8, 9): return HARD[ten] + UNITS[unit] return NASAL[ten] + UNITS[unit] if n < 1000: hund, rest = n // 100, n % 100 return HUNDREDS[hund] + ("" if rest == 0 else " " + spell_int(rest)) thou, rest = n // 1000, n % 1000 head = "mil" if thou == 1 else f"{spell_int(thou)} mil" return head + ("" if rest == 0 else " " + spell_int(rest)) def spell_digits(text): """Replace every run of digits with its Kreyòl words, plus the symbols that read as words.""" text = re.sub(r"(\d)\s*%", r"\1 pousan", text) text = re.sub(r"(?<=\d)[ ,](?=\d{3}\b)", "", text) # 1 000 / 1,000 -> 1000 def one(m): whole, frac = m.group(1), m.group(2) n = int(whole) out = spell_int(n) if n < 1_000_000 else " ".join(spell_int(int(c)) for c in whole) if frac: out += " pwen " + " ".join(spell_int(int(c)) for c in frac) return out return re.sub(r"(\d+)(?:[.,](\d+))?", one, text) def modern_spelling(text): """Drop the apostrophes the older orthography used: `pa t' gen` -> `pa t gen`, `t'ap` -> `t ap`.""" t = text.replace("’", "'").replace("‘", "'").replace("ʼ", "'") t = re.sub(r"(\w)'(?=[\s,.;:!?\"»”)\]]|$)", r"\1", t) t = re.sub(r"(\w)'(?=\w)", r"\1 ", t) return t def normalize(text, lower=True, punct=False): """The canonical form used for both training targets and error rates.""" if not text: return "" t = unicodedata.normalize("NFC", text) t = t.replace("​", " ").replace("\xa0", " ") t = re.sub(r"[“”«»]", '"', t).replace("–", "-").replace("—", "-").replace("…", " ") t = modern_spelling(t) t = spell_digits(t) if lower: t = t.lower() if not punct: t = re.sub(r"[^\w\s'-]", " ", t, flags=re.UNICODE) t = re.sub(r"(?= min_share or len(NON_KREYOL.findall(" ".join(ordinary))) >= 2 def suspect(text, duration=None): """Reasons a row is bad training data. Empty list means the row looks usable.""" why, norm = [], normalize(text) if len(norm) < 2: why.append("empty") if re.search(r"\d", norm): why.append("digits_left") if is_french(text): why.append("french") letters = len(LETTER.findall(norm)) if letters and len(norm) and letters / len(norm) < 0.7: why.append("low_letter_ratio") if norm and max((len(w) for w in norm.split()), default=0) > 24: why.append("long_token") if duration: rate = letters / duration if rate < 5 or rate > 25: why.append(f"rate_{rate:.0f}") return why if __name__ == "__main__": for n in (0, 1, 8, 15, 21, 25, 31, 48, 70, 75, 80, 81, 90, 98, 100, 101, 250, 1000, 1804, 2026, 15000): print(f"{n:>6} {spell_int(n)}") for s in ["Jenèz chapit pemye. Istwa kreyasyon an.", "Nan lane 1804, 25 moun te peye 50% nan 2,500 goud.", "pa t' gen anyen, epi t'ap vini", "Qu'est-ce que le vaudou, d'où vient-il?"]: print(f"\n{s}\n-> {normalize(s)}\n french={french_score(s):.2f} suspect={suspect(s)}")