#!/usr/bin/env python3 """Polish number normalizer — digits to spoken Polish words, for TTS. Higgs Audio v3 mispronounces raw digits, especially long ones, and sometimes emits garbled non-words. This component rewrites them as fully spelled-out Polish, in the right grammatical case — which is why it is a neural model and not a lookup table: Polish numerals decline by case, gender and the noun they govern. >>> normalize("Wygrał 1247 złotych.") 'Wygrał tysiąc dwieście czterdzieści siedem złotych.' >>> normalize("Mam 3 koty.") # nothing worth sending: left alone 'Mam 3 koty.' Model: Folx/qwen3-0.6b-pl-text-normalization, loaded from a local directory and run offline. If it is unavailable the component degrades to a num2words fallback and says so in the log — it never raises and never crashes the caller. Run `python normalizer.py "some text"` for a quick check, or `python worker.py` for the JSON-lines service that lektor_ai_comfy.py talks to. """ import json import logging import os import re import sys from pathlib import Path LOG = logging.getLogger("pl_normalizer") HERE = Path(__file__).resolve().parent MODEL_DIR = Path(os.environ.get("PL_NORMALIZER_MODEL", HERE / "model")) # "auto" uses the GPU when one is free, "cpu" forces RAM. Forcing CPU is a real # use case, not a fallback: ComfyUI keeps ~8.4 GB of VRAM resident between runs, # so a normalizer that insists on the GPU either fights it or has to wait. DEVICE = os.environ.get("PL_NORMALIZER_DEVICE", "auto").strip().lower() # The second model. The 0.6B fine-tune reads odd formats well — clock times, # race positions, call signs — but loses the value of a large number while # declining it ("z 8250 osobami" came back as 8205). A general Polish LLM has # the opposite profile. Neither is asked to do the other's job; see route(). LLM_DIR = Path(os.environ.get("PL_NORMALIZER_LLM", HERE / "models" / "PLLuM-4B-instruct-2512")) # Fixed by the fine-tune — the model was trained with this exact system prompt. SYSTEM_PROMPT = "Zamieniasz tekst na formę mówioną." MAX_NEW_TOKENS = 400 LONG_INPUT_CHARS = 300 # quality degrades past this; warn, never truncate # Where a number begins and ends. Numeric punctuation only continues a number # when a digit follows it, which is what separates "3,5" and "14:30" (one number # each, for the model to handle) from "Przód 85, tył 75." (two, each ending at # ordinary sentence punctuation). # # The build brief writes these as (? list: """Token ids that may never appear in the output. Scanning the whole vocabulary means one decode call per token — a few seconds for Qwen3's 150k entries — so the result is cached beside the model. """ cache = (cache_dir or MODEL_DIR) / "banned_tokens.json" size = len(tok) if cache.is_file(): try: data = json.loads(cache.read_text(encoding="utf-8")) if data.get("vocab_size") == size: return data["ids"] except (OSError, ValueError, KeyError): pass # stale or corrupt — just recompute banned = set() for tid in range(size): piece = tok.decode([tid]).strip() if piece and piece.isdigit(): banned.add(tid) for sym in _BANNED_SYMBOLS: ids = tok.encode(sym, add_special_tokens=False) if len(ids) == 1: banned.add(ids[0]) ids = sorted(banned) try: cache.write_text(json.dumps({"vocab_size": size, "ids": ids}), encoding="utf-8") except OSError: pass # cache is an optimisation, not a requirement return ids def load_model() -> bool: """Load model, tokenizer and banned-token list once, and keep them. Returns True when the neural path is available. A failure here is not an error: it is logged and the component runs on the fallback for the rest of the process. Loading is lazy rather than at import so that tests, --help and fallback-only runs do not pay for a 1.1 GB model they never use. """ if _state["loaded"]: return _state["model"] is not None _state["loaded"] = True if not (MODEL_DIR / "config.json").is_file(): _state["error"] = (f"no model in {MODEL_DIR} — see README.md for the " f"one-time download") LOG.warning("Neural normalizer unavailable (%s). Falling back to num2words: " "numbers will be spelled out in the nominative case only.", _state["error"]) return False try: import torch from transformers import AutoModelForCausalLM, AutoTokenizer tok = AutoTokenizer.from_pretrained(str(MODEL_DIR)) placement = {"": "cpu"} if DEVICE == "cpu" else "auto" try: model = AutoModelForCausalLM.from_pretrained( str(MODEL_DIR), dtype=torch.bfloat16, device_map=placement) except TypeError: # transformers < 5 spells the same argument torch_dtype. model = AutoModelForCausalLM.from_pretrained( str(MODEL_DIR), torch_dtype=torch.bfloat16, device_map=placement) model.eval() _state["tok"], _state["model"] = tok, model _state["banned"] = _banned_ids(tok) LOG.info("Model loaded from %s on %s (%d banned tokens)", MODEL_DIR, next(model.parameters()).device, len(_state["banned"])) return True except Exception as exc: # missing torch, no VRAM, corrupt download… _state["tok"] = _state["model"] = None _state["error"] = f"{type(exc).__name__}: {exc}" LOG.warning("Could not load the neural normalizer (%s). Falling back to " "num2words: numbers will be spelled out in the nominative " "case only.", _state["error"]) return False def model_available() -> bool: return load_model() def status() -> dict: """What the caller needs to report: which path is live, and why.""" ok = load_model() device = "" if ok: try: device = str(next(_state["model"].parameters()).device) except Exception: device = "unknown" return {"mode": "model" if ok else "fallback", "model_dir": str(MODEL_DIR), "device": device, "error": _state["error"]} def _model_normalize(text: str) -> str: import torch tok, model = _state["tok"], _state["model"] messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": text}] prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = tok(prompt, return_tensors="pt").to(model.device) n = inputs["input_ids"].shape[1] with torch.no_grad(): out = model.generate(**inputs, max_new_tokens=MAX_NEW_TOKENS, do_sample=False, pad_token_id=tok.pad_token_id or tok.eos_token_id, suppress_tokens=_state["banned"]) return tok.decode(out[0][n:], skip_special_tokens=True).strip() def load_llm() -> bool: """Load the general Polish LLM used for the inflection leg. Optional: without it every block goes to the fine-tune, which is what the pipeline did before. Reported, never fatal. """ if _llm["loaded"]: return _llm["model"] is not None _llm["loaded"] = True if not (LLM_DIR / "config.json").is_file(): _llm["error"] = f"no model in {LLM_DIR}" LOG.warning("Inflection model unavailable (%s) — large numbers will be " "normalized by the fine-tune alone.", _llm["error"]) return False try: import torch from transformers import AutoModelForCausalLM, AutoTokenizer tok = AutoTokenizer.from_pretrained(str(LLM_DIR)) placement = {"": "cpu"} if DEVICE == "cpu" else "auto" model = AutoModelForCausalLM.from_pretrained( str(LLM_DIR), dtype=torch.bfloat16, device_map=placement).eval() _llm["tok"], _llm["model"] = tok, model _llm["banned"] = _banned_ids(tok, cache_dir=LLM_DIR) LOG.info("Inflection model loaded from %s on %s", LLM_DIR, next(model.parameters()).device) return True except Exception as exc: _llm["tok"] = _llm["model"] = None _llm["error"] = f"{type(exc).__name__}: {exc}" LOG.warning("Could not load the inflection model (%s) — large numbers " "will be normalized by the fine-tune alone.", _llm["error"]) return False def _llm_inflect(text: str) -> str: import torch tok, model = _llm["tok"], _llm["model"] messages = [{"role": "system", "content": SYSTEM_INFLECT}] for user, assistant in FEWSHOT_INFLECT: messages.append({"role": "user", "content": user}) messages.append({"role": "assistant", "content": assistant}) messages.append({"role": "user", "content": text}) prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = tok(prompt, return_tensors="pt").to(model.device) n = inputs["input_ids"].shape[1] with torch.no_grad(): out = model.generate(**inputs, max_new_tokens=MAX_NEW_TOKENS, do_sample=False, pad_token_id=tok.pad_token_id or tok.eos_token_id, suppress_tokens=_llm["banned"]) answer = tok.decode(out[0][n:], skip_special_tokens=True).strip() # Instruct models like to introduce their answer; the sentence is the last # non-empty line in every case observed. lines = [l.strip() for l in answer.splitlines() if l.strip()] return lines[-1] if lines else answer # --------------------------------------------------------------------------- # Fallback # --------------------------------------------------------------------------- def _fallback(text: str) -> str: """Model unavailable. Nominative case only, bare integers only — wrong case is still far better than the TTS garbling a long digit string.""" try: from num2words import num2words except ImportError: LOG.warning("num2words is not installed — leaving %r unchanged", text) return text # A minus that stays a dash is a minus the TTS will not read, and -12 # spoken as 12 is the wrong temperature. text = re.sub(r'(? 8205), drops words # num2words good: the value, always, whatever the digit grouping # + LLM bad: destroys formats — a flight number becomes a quantity, # "P10" becomes "Pdziesięć", times fall apart # # So a block goes to the second leg only when every number in it is a plain # integer. On the sample, that is 77% of numbered blocks. _MONTHS = ("stycznia|lutego|marca|kwietnia|maja|czerwca|lipca|sierpnia|" "września|października|listopada|grudnia") _COMPLEX_RE = re.compile( r'\d[.,:/]\d' # 3,5 14:30 12/45 1.350 r'|\d\s*[-–—]\s*\d' # 10-15 r'|[^\W\d_]\d|\d[^\W\d_]' # P10, 1998cm3 — an identifier, not a count r'|\b[A-ZĄĆĘŁŃÓŚŹŻ]{2,3}\s\d{2,}\b' # LO 3842 — call sign, read digit by digit r'|\d+\s+(?i:' + _MONTHS + r')\b' # 13 maja — ordinal r'|\d{4}\s+(?i:r(?:oku|\.))', # 2026 roku — ordinal re.UNICODE) # Case matters here, which is why IGNORECASE is scoped to the two alternatives # that need it. Applied globally it turned the call-sign rule into "any short # word before a number", routing "za 150 milionów" and "Przód 85" to the wrong # leg — a third of the sample. def route(text: str) -> str: """"plain" when every number is a plain integer, else "complex".""" return "complex" if _COMPLEX_RE.search(text) else "plain" # --------------------------------------------------------------------------- # Guards # --------------------------------------------------------------------------- # # Both models can produce output that is worse than doing nothing: a repetition # loop that runs to the token ceiling, a dropped minus sign that inverts a # temperature, a vanished noun. These are cheap structural checks against the # input — no second model pass — and a block that fails one falls back to # num2words, which is dull but never wrong about the value. _WORD_RE = re.compile(r'[^\W\d_]+', re.UNICODE) _VOWELS = str.maketrans("", "", "aeiouyąęó") _FOLD = str.maketrans("ąćęłńóśźż", "acelnoszz") def _skeleton(word: str) -> str: """Consonants of a word, diacritics folded — a poor man's lemma, used to ask whether a word survived generation at all. Three consonants is deliberate: ludziach/ludzi and uchodźcach/uchodźców are the same word inflected, and a longer key would report them as lost.""" return word.lower().translate(_FOLD).translate(_VOWELS)[:3] # Numerals need identity, not similarity: "dwieście" and "dwustu" are the same # number and must match, while "trzy" and "trzysta" differ by a factor of a # hundred and must not. No consonant rule does both, so the closed class is # written out. Patterns are tried in order, most specific first, against the # lowercased and diacritic-folded word. _NUMERAL_CANON = [ (r'zero', "0"), (r'dwadziesc|dwudziest', "20"), (r'dwanasc|dwunast', "12"), (r'dwiesc|dwust', "200"), (r'dw', "2"), (r'trzynasc|trzynast', "13"), (r'trzydzies', "30"), (r'trzyst', "300"), (r'trz', "3"), (r'czternasc|czternast', "14"), (r'czterdzies', "40"), (r'czteryst|czterest', "400"), (r'czter', "4"), (r'pietnasc|pietnast', "15"), (r'piecdziesi', "50"), (r'piecset', "500"), (r'pie[cs]', "5"), (r'szesnasc|szesnast', "16"), (r'szescdziesi', "60"), (r'szescset', "600"), (r'szes', "6"), (r'siedemnasc|siedemnast', "17"), (r'siedemdziesi', "70"), (r'siedemset', "700"), (r'siedem|siedmi', "7"), (r'osiemnasc|osiemnast', "18"), (r'osiemdziesi', "80"), (r'osiemset', "800"), (r'osiem|osm', "8"), (r'dziewietnasc|dziewietnast', "19"), (r'dziewiecdziesi', "90"), (r'dziewiecset', "900"), (r'dziewi', "9"), (r'dziesi', "10"), (r'jedenasc|jedenast', "11"), (r'jed', "1"), # Ordinals denote the same number and are often the better reading — # "na pięćdziesiątym piątym okrążeniu". Without them the guard rejects # correct output. (r'pierwsz', "1"), (r'drug', "2"), (r'czwart', "4"), (r'piat', "5"), (r'szost', "6"), (r'siodm', "7"), (r'setn', "100"), (r'st[ou]$', "100"), (r'tysi', "1000"), (r'miliard', "1e9"), (r'milion', "1e6"), ] _NUMERAL_CANON = [(re.compile("^(?:" + pat + ")"), value) for pat, value in _NUMERAL_CANON] def _numeral(word: str) -> str: """The number a word denotes, whatever case it is in, or "" if it is not a numeral.""" folded = word.lower().translate(_FOLD) for pattern, value in _NUMERAL_CANON: if pattern.match(folded): return value return "" # Same as _INT_RE but from one digit up. Only ever applied to blocks the router # called "plain", where every number is a bare integer — and there the model # expands single digits too, so the expectation has to include them. _ANY_INT_RE = re.compile(_NUM_START + r'(?:\d{1,3}(?:[\u00a0\u202f .]\d{3})+|\d+)' + _NUM_END) def _expected_numerals(text: str) -> list: """The numerals num2words would produce for `text`, canonicalised.""" try: from num2words import num2words except ImportError: return [] def repl(m): digits = re.sub(r'[\u00a0\u202f .]', '', m.group(0)) try: return num2words(int(digits), lang="pl") except Exception: return m.group(0) spelled = _ANY_INT_RE.sub(repl, text) return sorted(n for n in map(_numeral, _WORD_RE.findall(spelled)) if n) def _repeats(text: str) -> bool: """A word five times over, or a phrase three times — a generation loop. PLLuM produced 'łącznie' eighty times on one input; the fine-tune produced a wall of replacement characters on another.""" words = text.lower().split() run = 1 for a, b in zip(words, words[1:]): run = run + 1 if a == b else 1 if run >= 4: return True for size in (2, 3): seen = {} for i in range(len(words) - size + 1): gram = tuple(words[i:i + size]) seen[gram] = seen.get(gram, 0) + 1 if seen[gram] >= 3: return True return False def _guard(original: str, candidate: str, check_value: bool) -> str: """Return the reason `candidate` is unusable, or "" if it passes.""" if not candidate.strip(): return "empty" if len(candidate) > 3 * len(original) + 60: return "runaway length" if _repeats(candidate): return "repetition loop" if "�" in candidate: return "invalid characters" if DIGIT_RE.search(candidate): return "digits left in output" if re.search(r'(? "dwadzieścia pięć" keeps every # expected word and still turns 20 into 25. # # The expectation comes from the full num2words rendering rather than # from the digits alone, so numerals already written as words in the # subtitle ("350 tysięcy") are counted too. want = _expected_numerals(original) got = sorted(n for n in map(_numeral, _WORD_RE.findall(candidate)) if n) if want != got: missing = [n for n in want if n not in got] extra = [n for n in got if n not in want] detail = (f"missing {','.join(missing)}" if missing else "") + \ (" " if missing and extra else "") + \ (f"added {','.join(extra)}" if extra else "reordered") return f"numbers changed ({detail})" # A word that vanished entirely. Short words are skipped because the # dictionary legitimately expands them (ul. -> ulicy, zł -> złote). have = [_skeleton(w) for w in _WORD_RE.findall(candidate)] for word in _WORD_RE.findall(original): if len(word) >= 5 and _skeleton(word) not in have: return f"word lost ({word})" return "" # --------------------------------------------------------------------------- # Decide / normalize # --------------------------------------------------------------------------- def needs_normalization(text: str) -> bool: """True when `text` holds a number the TTS would get wrong — anything with a digit, once standalone single digits are discounted. A block whose only numbers are standalone single digits is not sent at all: the TTS says "pięć minut" for "5 minut" by itself, so there is nothing to gain and a whole sentence of rewriting to risk. Inside a block that *is* sent, single digits are normalized along with everything else. lektor_ai_comfy.py applies the same rule before sending a block, so blocks that need nothing never cross the process boundary. Keep the two in step. """ return bool(DIGIT_RE.search(KEEP_RE.sub("", text))) def normalize(text: str) -> str: """Return `text` with its numbers spelled out in Polish. Never raises.""" return normalize_detail(text)[0] def normalize_detail(text: str) -> tuple: """(result, leg, guard_reason) — the caller reports which leg did the work. Plain integers go to num2words for the value and the LLM for the grammar, which is the only arrangement measured to get both right. Everything else goes to the fine-tune, which understands the formats. Either way the result is checked before it is returned. """ if not text or not DIGIT_RE.search(text): return text, "skipped", "" if not needs_normalization(text): return text, "skipped", "" # only standalone single digits if len(text) > LONG_INPUT_CHARS: LOG.warning("Input is %d characters; quality degrades past %d. " "Processing it anyway.", len(text), LONG_INPUT_CHARS) leg = route(text) if leg == "plain" and load_llm(): STATS["plain"] += 1 nominative = _fallback(text) # deterministic: the value cannot be wrong try: candidate = _llm_inflect(nominative) except Exception as exc: LOG.warning("Inflection failed (%s: %s)", type(exc).__name__, exc) candidate = nominative safe, check_value = nominative, True else: leg = "complex" # no inflection model: the fine-tune takes it STATS["complex"] += 1 if load_model(): try: candidate = _model_normalize(text) except Exception as exc: LOG.warning("Generation failed (%s: %s)", type(exc).__name__, exc) candidate = _fallback(text) else: candidate = _fallback(text) # The value check needs a num2words expectation, which only exists for # plain integers — "LO 3842" is correctly read digit by digit, and # demanding "trzy tysiące osiemset" of it would reject a good answer. safe, check_value = _fallback(text), False reason = _guard(text, candidate, check_value) if not reason: return candidate, leg, "" STATS["guarded"] += 1 STATS["reasons"][reason.split(" (")[0]] = \ STATS["reasons"].get(reason.split(" (")[0], 0) + 1 LOG.warning("Rejected %r -> %r (%s); using %r", text, candidate, reason, safe) return (safe if safe.strip() else text), leg, reason # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(argv=None) -> int: import argparse parser = argparse.ArgumentParser( description="Spell out Polish numbers for TTS.", epilog="With no TEXT, reads lines from stdin and writes them normalized.") parser.add_argument("text", nargs="*", metavar="TEXT") parser.add_argument("--status", action="store_true", help="Report which path is live and exit") parser.add_argument("--fallback-only", action="store_true", help="Skip the model, use num2words (for testing)") parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args(argv) logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING, format="%(levelname)s: %(message)s", stream=sys.stderr) if args.fallback_only: _state.update(loaded=True, model=None, tok=None, error="--fallback-only requested") if args.status: print(json.dumps(status(), ensure_ascii=False, indent=2)) return 0 if args.text: print(normalize(" ".join(args.text))) return 0 for line in sys.stdin: print(normalize(line.rstrip("\n")), flush=True) return 0 if __name__ == "__main__": sys.exit(main())