# Build brief — Polish number normalizer (offline `transformers` component) ## Scope of this document This brief covers **only the normalizer component**: installing it, running it offline with plain `transformers`, and what it does. **Integration is out of scope** — the calling app (`lektor_ai`) handles SRT parsing, decides which subtitle blocks to normalize, and invokes this component. The user will specify that wiring separately. Assume the caller sends **one sentence / subtitle block at a time**, and only sends blocks that already contain at least one digit. This component's job is to return that text with numbers converted to spoken Polish words, per the rules below. ## Purpose The downstream TTS (Higgs Audio v3) mispronounces raw digits — especially long numbers — sometimes emitting garbled non-words. This component removes that failure by converting digits to fully spelled-out Polish words **before** the text reaches the TTS, with correct grammatical case (the reason a neural normalizer is used rather than a lookup table: Polish numerals decline by case, gender, and the noun they govern). Behaviour rules: 1. **Skip single-digit numbers.** A standalone one-digit integer (e.g. `3` in `Mam 3 koty`) is read correctly by the TTS and must be **left as a digit**. 2. **Expand two-or-more-digit numbers** to words (`25`, `2020`, `1 350 000`). 3. Complex numeric expressions — decimals (`3,5`), times (`14:30`), dates (`3 maja`), ratios/addresses (`12/45`), currency, units — are handled by the model. They are not "single-digit numbers" and are not skipped. 4. A block that (after removing single digits) contains no multi-digit number is returned unchanged. ## The model - **Repo:** `Folx/qwen3-0.6b-pl-text-normalization` (Hugging Face). ~1.1 GB, bfloat16, 600M params, `Qwen3ForCausalLM`. Fine-tuned specifically for Polish TTS text normalization (numbers, dates, times, units, abbreviations). - **GATED REPO.** The user must accept the terms on the model page and provide an `HF_TOKEN` for the **one-time** download. The build agent cannot accept the licence. Load the model from a **local path / local cache** at run time — never re-download. - **CRITICAL version requirement:** `transformers >= 5.0.0` and `torch >= 2.1.0`. Older transformers (e.g. 4.57) tokenize the Qwen3 chat template differently and silently produce wrong output even under greedy decode. Pin `transformers>=5.0`. - **System prompt (fixed, required):** `Zamieniasz tekst na formę mówioną.` - **Context:** max 512 tokens; quality degrades past ~300 characters. Input is one short block per call, so this is fine — warn (don't truncate) on longer inputs. - **Decoding:** greedy (`do_sample=False`), with **constrained decoding** that bans digit tokens from the output so no un-normalized number can leak through. ## Environment & installation Install into a **dedicated, isolated venv/conda env**, separate from the ComfyUI / Higgs TTS environment. Rationale: this model hard-requires `transformers>=5.0`, and installing that into the existing working TTS environment risks disturbing its pinned dependencies. Keep them apart; this component is invoked as its own process. Pinned dependencies: - `transformers>=5.0.0` - `torch>=2.1.0` — CUDA build matching the host (target: RTX 3090, 24 GB) - `accelerate` — for `device_map` - `num2words` — used only by the fallback path One-time model fetch (network + token required): ```bash HF_TOKEN=... huggingface-cli download Folx/qwen3-0.6b-pl-text-normalization \ --local-dir ./model ``` Run time is **offline**. Set `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1`, and load the model from `./model` (or the local HF cache). Add a check that a full run succeeds with networking disabled. Load the model, tokenizer, and banned-token list **once** (module level) and reuse across calls — model load dominates cost. ## Normalization logic For a given input string: 1. **Protect single-digit numbers.** Replace each standalone one-digit integer with a **digit-free sentinel**, remembering the mapping. The boundary set excludes adjacent digits and numeric punctuation so decimals/times/dates are not caught: ```python KEEP_RE = re.compile(r'(? str: messages = [{"role": "system", "content": SYS}, {"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=400, do_sample=False, pad_token_id=tok.pad_token_id, suppress_tokens=BANNED) return tok.decode(out[0][n:], skip_special_tokens=True).strip() ``` ### Reference: skip / decide / restore + public function ```python KEEP_RE = re.compile(r'(? str: s = ""; i += 1 while i: i, r = divmod(i - 1, 26); s = _LETTERS[r] + s return f"KEEPNUM{s}" def normalize(text: str) -> str: """Return `text` with 2+ digit numbers spelled out in Polish; single-digit numbers left as digits. Safe to call on any string.""" mapping = {} def protect(m): key = _sentinel(len(mapping)); mapping[key] = m.group(0); return key protected = KEEP_RE.sub(protect, text) if re.search(r'\d', protected): try: result = _model_normalize(protected) except Exception: result = _fallback(protected) else: result = protected for key, digit in mapping.items(): result = result.replace(key, digit) return result ``` ### Reference: fallback (model unavailable / generation error) Guarantees the component never raises. Degraded: nominative case only, bare multi-digit integers only (leaves decimals/times/dates untouched rather than mangle them). Log a warning when used. ```python from num2words import num2words _INT_RE = re.compile(r'(? str: def repl(m): digits = re.sub(r'[\u00a0 .]', '', m.group(0)) return num2words(int(digits), lang="pl") return _INT_RE.sub(repl, text) ``` ## Error handling - Input longer than ~300 chars: log a warning (quality drops), still process, never truncate silently. - Model load failure: fall back to `num2words` and log clearly that the neural normalizer is unavailable — never crash. - Per-call generation error: use the fallback for that call only. ## Testing / acceptance The model produces valid case variants, so assert on **properties**, not exact strings, except where noted. - **Single digit preserved:** `Mam 3 koty.` → still contains `3`. - **Multi-digit expanded:** `Wygrał 1247 złotych.` → no `1247`; contains `tysiąc`. - **Large number:** `populacja 1 350 000 osób` → no digits from that number; contains `milion`. - **Mixed (key sentinel round-trip):** `Mam 3 koty i 25 psów.` → keeps `3`, expands `25` → `dwadzieścia pięć`. - **Only single digits:** model is not called; text returned unchanged (assert via a spy on `_model_normalize`). - **No digits leak:** expanded output contains no digit characters except restored single digits. - **Sentinel round-trip:** `KEEPNUM…` never appears in final output. **If the model is found to alter/inflect the sentinel, flag it** — the fix is a more inert sentinel, or dropping protection and documenting that single digits co-occurring with multi-digit numbers get expanded. - **Offline:** the suite passes with networking disabled, `HF_HUB_OFFLINE=1`, `TRANSFORMERS_OFFLINE=1`. ## Deliverables - `normalizer.py` — model/tokenizer/banned-list loaded once at import; public `normalize(text: str) -> str`; `_fallback`; skip/decide/restore logic. - `requirements.txt` — pinned as above. - `README.md` — env creation, one-time gated download with `HF_TOKEN`, offline run variables. (No caller/integration details — those are provided separately.) - `tests/` — the checks above with sample inputs. ## Design decisions (defaults — confirm if you disagree) 1. **Single digit inside a date/time.** `3 maja` keeps its `3` (space-separated standalone single digit), while `9:30`, `3,5`, `12/45` go to the model and are expanded. If you want `3 maja` → `trzeciego maja`, that's a one-line change to the boundary set. 2. **Sentinel robustness.** Small residual risk the model alters the placeholder instead of passing it through. Encoded as an explicit test with a documented fallback so it's caught, not shipped silently.