646 lines
28 KiB
Python
646 lines
28 KiB
Python
#!/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 (?<![\d.,:/\-]) and (?![\d.,:/\-]), which also
|
|||
|
|
# rejects every number followed by a comma or a full stop — on real subtitles
|
|||
|
|
# that silently skipped most of them.
|
|||
|
|
_NUM_START = r'(?<!\d)(?<!\d[.,:/\-])'
|
|||
|
|
_NUM_END = r'(?!\d)(?![.,:/\-]\d)'
|
|||
|
|
|
|||
|
|
# A standalone one-digit integer is pronounced correctly by the TTS, so it stays
|
|||
|
|
# a digit; anything longer or more complex goes to the model.
|
|||
|
|
#
|
|||
|
|
# The two space guards are not in the brief either, and are there because
|
|||
|
|
# without them "1 350 000" has its leading 1 protected as if it were standalone:
|
|||
|
|
# the model then sees "KEEPNUMA 350 000" and returns "1 trzysta pięćdziesiąt
|
|||
|
|
# tysięcy" — the million silently lost. A digit that sits next to a group of
|
|||
|
|
# exactly three is part of a grouped number, not a number of its own.
|
|||
|
|
KEEP_RE = re.compile(_NUM_START + r'(?<!\d[\u00a0\u202f ])'
|
|||
|
|
r'\d'
|
|||
|
|
+ _NUM_END + r'(?![\u00a0\u202f ]\d{3}(?!\d))')
|
|||
|
|
DIGIT_RE = re.compile(r'\d')
|
|||
|
|
|
|||
|
|
# Fallback path only: integers of 2+ digits, or any number written in groups of
|
|||
|
|
# three. Decimals, times and dates are deliberately left alone rather than
|
|||
|
|
# mangled without the model's grammar.
|
|||
|
|
_INT_RE = re.compile(_NUM_START +
|
|||
|
|
r'(?:\d{1,3}(?:[\u00a0\u202f .]\d{3})+|\d{2,})'
|
|||
|
|
+ _NUM_END)
|
|||
|
|
|
|||
|
|
# Banned alongside digits so the model cannot emit a symbol the TTS would read
|
|||
|
|
# as a word of the wrong language, or not read at all.
|
|||
|
|
_BANNED_SYMBOLS = ["$", "%", "€", "£", "¥", "°", "§", "@", "#", "&", "×", "÷", "±",
|
|||
|
|
"©", "®", "™", "→", "←", "↑", "↓", "√", "∞", "≈", "≠", "≤", "≥"]
|
|||
|
|
|
|||
|
|
# The instruction the general model gets. It never sees a digit — num2words has
|
|||
|
|
# already produced correct nominative words — so its only job is to inflect
|
|||
|
|
# them. That is why it cannot get a value wrong: it is not reading numbers.
|
|||
|
|
SYSTEM_INFLECT = (
|
|||
|
|
"Otrzymujesz polskie zdanie, w którym liczebniki są już zapisane słowami, "
|
|||
|
|
"ale w mianowniku. Popraw wyłącznie formę gramatyczną tych liczebników tak, "
|
|||
|
|
"aby pasowały do zdania (przypadek, rodzaj, liczba). Nie zmieniaj, nie "
|
|||
|
|
"dodawaj ani nie usuwaj żadnych innych słów. Zwróć samo zdanie."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Pins down the three things it gets wrong unprompted: the case must follow the
|
|||
|
|
# governing word, everything else must survive verbatim, and the answer is the
|
|||
|
|
# sentence alone. The last example is a no-op on purpose — without it the model
|
|||
|
|
# invents changes when none are needed.
|
|||
|
|
FEWSHOT_INFLECT = [
|
|||
|
|
("Mówili o dwieście pięćdziesiąt tysięcy uchodźcach.",
|
|||
|
|
"Mówili o dwustu pięćdziesięciu tysiącach uchodźców."),
|
|||
|
|
("Koncert dla osiem tysięcy dwieście pięćdziesiąt osób.",
|
|||
|
|
"Koncert dla ośmiu tysięcy dwustu pięćdziesięciu osób."),
|
|||
|
|
("Przyjechał z dwadzieścia pięć psami.",
|
|||
|
|
"Przyjechał z dwudziestoma pięcioma psami."),
|
|||
|
|
("Kosztuje tysiąc dwieście czterdzieści siedem złotych.",
|
|||
|
|
"Kosztuje tysiąc dwieście czterdzieści siedem złotych."),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
_state = {"loaded": False, "tok": None, "model": None, "banned": None, "error": None}
|
|||
|
|
_llm = {"loaded": False, "tok": None, "model": None, "banned": None, "error": None}
|
|||
|
|
|
|||
|
|
# Counted per process and reported by the worker, so a run can say which leg did
|
|||
|
|
# the work and how often a guard had to step in.
|
|||
|
|
STATS = {"plain": 0, "complex": 0, "guarded": 0, "reasons": {}}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Model
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _banned_ids(tok, cache_dir: Path = None) -> 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'(?<![\w])-(?=\d)', 'minus ', text)
|
|||
|
|
|
|||
|
|
def repl(m):
|
|||
|
|
digits = re.sub(r'[\u00a0\u202f .]', '', m.group(0))
|
|||
|
|
try:
|
|||
|
|
return num2words(int(digits), lang="pl")
|
|||
|
|
except Exception:
|
|||
|
|
# num2words raises KeyError, not ValueError, past its largest scale
|
|||
|
|
# word (10^66). Leaving the digits is bad; crashing a run is worse.
|
|||
|
|
LOG.warning("num2words could not spell out %r — leaving it", m.group(0))
|
|||
|
|
return m.group(0)
|
|||
|
|
return _INT_RE.sub(repl, text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Routing
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
#
|
|||
|
|
# The two models fail in opposite directions, measured on 40 real subtitle lines
|
|||
|
|
# and a 32-case battery:
|
|||
|
|
#
|
|||
|
|
# fine-tune good: 6:05, P10, LO 3842, 95,7, 21.00, dates
|
|||
|
|
# bad: loses the value of a large number while declining it
|
|||
|
|
# ("z 8250 osobami" -> 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'(?<![\w])-\s?\d', original) and "minus" not in candidate.lower():
|
|||
|
|
return "minus sign lost"
|
|||
|
|
|
|||
|
|
if check_value:
|
|||
|
|
# The plain leg's model is given text with no digits in it and asked
|
|||
|
|
# only to inflect, so the numerals it returns must be exactly the ones
|
|||
|
|
# num2words put there — same values, same count. A one-directional
|
|||
|
|
# check is not enough: "dwadzieścia" -> "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())
|