fix(tts): freistehende Ganzzahlen tag-unabhaengig ausschreiben
Regression seit das lokale LLM leichte Turns (Wetter, Spotify) beantwortet:
es nutzt bewusst KEINE <voice>-Tags, an denen frueher die Zahl-Aussprache hing.
clean_text_for_tts schrieb nur Uhrzeiten/Dezimalzahlen/Zahl+Einheit aus, nie
freistehende Ganzzahlen ('23°C', '100%', '7 Nachrichten').
Neuer vollstaendiger Konverter _int_to_words_de (0..999999) + generischer
Durchlauf am Ende von clean_text_for_tts, der alle uebrigen Ganzzahlen zu
Woertern macht. Tag-unabhaengig -> gilt fuer local UND claude. Lange
Ziffernfolgen (IDs/Codes, >6 Stellen) bleiben Ziffern; an .,:/ klebende
Zahlen (IPs, Versionen) werden uebersprungen. Nebenbei: fehlendes Leerzeichen
bei '23°C' -> 'dreiundzwanzig Grad Celsius' gefixt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+59
-2
@@ -149,6 +149,54 @@ def _num_to_words_de(n: int) -> str:
|
|||||||
return str(n)
|
return str(n)
|
||||||
|
|
||||||
|
|
||||||
|
# Vollstaendige Zehner fuer den generischen Konverter (inkl. 60-90).
|
||||||
|
_TENS_FULL_DE = {20: "zwanzig", 30: "dreissig", 40: "vierzig", 50: "fuenfzig",
|
||||||
|
60: "sechzig", 70: "siebzig", 80: "achtzig", 90: "neunzig"}
|
||||||
|
|
||||||
|
|
||||||
|
def _below_100_de(n: int) -> str:
|
||||||
|
if n in _NUM_WORDS_DE: # 0..20
|
||||||
|
return _NUM_WORDS_DE[n]
|
||||||
|
tens = (n // 10) * 10
|
||||||
|
ones = n % 10
|
||||||
|
if ones == 0:
|
||||||
|
return _TENS_FULL_DE[tens]
|
||||||
|
ones_word = "ein" if ones == 1 else _NUM_WORDS_DE[ones]
|
||||||
|
return f"{ones_word}und{_TENS_FULL_DE[tens]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _below_1000_de(n: int) -> str:
|
||||||
|
if n < 100:
|
||||||
|
return _below_100_de(n)
|
||||||
|
h, rest = divmod(n, 100)
|
||||||
|
h_word = ("ein" if h == 1 else _NUM_WORDS_DE[h]) + "hundert"
|
||||||
|
return h_word if rest == 0 else h_word + _below_100_de(rest)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_to_words_de(n: int) -> str:
|
||||||
|
"""Ganzzahl 0..999999 als zusammenhaengendes deutsches Wort
|
||||||
|
('dreiundzwanzig', 'einhundert', 'zweitausendsechsundzwanzig').
|
||||||
|
Groesser (IDs/Codes) → als Ziffern lassen (der Aufrufer gated zusaetzlich)."""
|
||||||
|
if n < 0:
|
||||||
|
return "minus " + _int_to_words_de(-n)
|
||||||
|
if n < 1000:
|
||||||
|
return _below_1000_de(n)
|
||||||
|
if n < 1_000_000:
|
||||||
|
th, rest = divmod(n, 1000)
|
||||||
|
th_word = ("ein" if th == 1 else _below_1000_de(th)) + "tausend"
|
||||||
|
return th_word if rest == 0 else th_word + _below_1000_de(rest)
|
||||||
|
return str(n)
|
||||||
|
|
||||||
|
|
||||||
|
def _spell_standalone_int(m) -> str:
|
||||||
|
"""Regex-Callback: freistehende Ganzzahl ausschreiben. Sehr lange
|
||||||
|
Ziffernfolgen (IDs, Codes, Telefonnummern) bleiben Ziffern."""
|
||||||
|
s = m.group(0)
|
||||||
|
if len(s) > 6:
|
||||||
|
return s
|
||||||
|
return _int_to_words_de(int(s))
|
||||||
|
|
||||||
|
|
||||||
def _time_range_to_words(m):
|
def _time_range_to_words(m):
|
||||||
"""'8:00-9:00 Uhr' → 'acht bis neun Uhr', '8-9 Uhr' → 'acht bis neun Uhr'."""
|
"""'8:00-9:00 Uhr' → 'acht bis neun Uhr', '8-9 Uhr' → 'acht bis neun Uhr'."""
|
||||||
h1 = int(m.group(1))
|
h1 = int(m.group(1))
|
||||||
@@ -168,7 +216,7 @@ def _decimal_to_words(m):
|
|||||||
"""'0.1' / '0,1' → 'null komma eins', '1,25' → 'eins komma zwei fuenf'."""
|
"""'0.1' / '0,1' → 'null komma eins', '1,25' → 'eins komma zwei fuenf'."""
|
||||||
int_part = int(m.group(1))
|
int_part = int(m.group(1))
|
||||||
dec_part = m.group(2)
|
dec_part = m.group(2)
|
||||||
int_word = _num_to_words_de(int_part) if 0 <= int_part <= 59 else str(int_part)
|
int_word = _int_to_words_de(int_part) if int_part <= 999999 else str(int_part)
|
||||||
dec_words = " ".join(_num_to_words_de(int(d)) for d in dec_part)
|
dec_words = " ".join(_num_to_words_de(int(d)) for d in dec_part)
|
||||||
return f"{int_word} komma {dec_words}"
|
return f"{int_word} komma {dec_words}"
|
||||||
|
|
||||||
@@ -184,7 +232,7 @@ _UNIT_WORDS = [
|
|||||||
(r'\bkm\b', 'Kilometer'),
|
(r'\bkm\b', 'Kilometer'),
|
||||||
(r'\bm/s\b', 'Meter pro Sekunde'),
|
(r'\bm/s\b', 'Meter pro Sekunde'),
|
||||||
(r'\bkg\b', 'Kilogramm'),
|
(r'\bkg\b', 'Kilogramm'),
|
||||||
(r'\b°C\b', 'Grad Celsius'),
|
(r'\b°C\b', ' Grad Celsius'),
|
||||||
(r'°C', ' Grad Celsius'),
|
(r'°C', ' Grad Celsius'),
|
||||||
(r'\bMbps\b', 'Megabit pro Sekunde'),
|
(r'\bMbps\b', 'Megabit pro Sekunde'),
|
||||||
(r'\bGbps\b', 'Gigabit pro Sekunde'),
|
(r'\bGbps\b', 'Gigabit pro Sekunde'),
|
||||||
@@ -301,6 +349,15 @@ def clean_text_for_tts(text: str) -> str:
|
|||||||
# werden, koennen bei Bedarf explizit in _UNIT_WORDS uebersteuert werden.
|
# werden, koennen bei Bedarf explizit in _UNIT_WORDS uebersteuert werden.
|
||||||
t = _re_tts.sub(r'\b([A-Z]{2,5})\b', lambda m: " ".join(m.group(1)), t)
|
t = _re_tts.sub(r'\b([A-Z]{2,5})\b', lambda m: " ".join(m.group(1)), t)
|
||||||
|
|
||||||
|
# Generisches Ausschreiben ALLER verbleibenden freistehenden Ganzzahlen
|
||||||
|
# ('23' → 'dreiundzwanzig', '100' → 'einhundert', '2026' → 'zwei-
|
||||||
|
# tausendsechsundzwanzig'). Laeuft NACH Uhrzeiten/Dezimalzahlen/Einheiten,
|
||||||
|
# die ihre Ziffern schon zu Woertern gemacht haben. Tag-unabhaengig — so
|
||||||
|
# klingen auch Antworten des lokalen LLM (das keine <voice>-Tags nutzt)
|
||||||
|
# sauber. Ziffernfolgen die an ., :, / kleben (IPs, Versionen, Uhrzeit-
|
||||||
|
# Reste) ueberspringen wir, um sie nicht zu zerreissen.
|
||||||
|
t = _re_tts.sub(r'(?<![\d.,:/])\d{1,6}(?![\d.,:/])', _spell_standalone_int, t)
|
||||||
|
|
||||||
# Anfuehrungszeichen
|
# Anfuehrungszeichen
|
||||||
t = _re_tts.sub(r'["""„`]', '', t)
|
t = _re_tts.sub(r'["""„`]', '', t)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user