llmrouter: Sprechtext-Normalisierung vor TTS — Postleitzahl als Ziffernfolge mit Pause

"Bergstraße 7, 83646 Bad Tölz" -> "Bergstraße 7; Postleitzahl 8 3 6 4 6, Bad Tölz"
(Andreas 2026-09-06, Variante 4 nach Hörtest mit Fish S2 Pro). Betraege, Daten,
Einheiten und Bindestrich-Nummern bleiben unberuehrt; 10 Tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 08:28:19 +02:00
committed by andreas
co-authored by Claude Fable 5.1
parent d07bef5e10
commit dfc85a4829
5 changed files with 109 additions and 2 deletions
+4
View File
@@ -17,6 +17,7 @@
import { configLaden, aclLaden, tokenLaden, upstreamKey, erlaubt, type Config, type Upstream } from "./config.ts";
import { aufloesen, alternativeAufloesen, datenHolen, stateLaden, stateVeraltet, type State } from "./resolver.ts";
import { Log, usageLesen, type Eintrag } from "./log.ts";
import { sprechtextNormalisieren } from "./sprechtext.ts";
import { referenzFormat, referenzParsen, EFFORTS, type Effort } from "../../llmlite/openrouter-auswahl.ts";
const config: Config = configLaden();
@@ -141,6 +142,9 @@ const FORM_PFADE = new Set(["/audio/transcriptions", "/audio/translations"]);
function jsonBody(orig: Record<string, unknown>, ziel: Ziel, pfad: string): Record<string, unknown> {
const b: Record<string, unknown> = { ...orig, model: ziel.model, ...(ziel.body ?? {}) };
// Sprechtext vor der Synthese normalisieren (Postleitzahlen als Ziffernfolge mit Pause),
// fuer jedes TTS-Ziel gleich -- die Modelle lesen Betraege und Daten, PLZ nicht.
if (pfad === "/audio/speech" && typeof b.input === "string") b.input = sprechtextNormalisieren(b.input);
if (pfad.startsWith("/messages")) {
// Anthropic-Format: kein usage.include, Effort als reasoning.effort (OpenRouter nimmt es dort an)
if (ziel.effortSetzen) { delete b.thinking; delete b.output_config; if (ziel.effort !== null) b.reasoning = { effort: ziel.effort }; }
+41
View File
@@ -0,0 +1,41 @@
/**
* sprechtext.ts — Normalisierung vor der Sprachsynthese.
*
* Befund 2026-09-06 mit Fish S2 Pro: Betraege ("1.248,50 Euro") und Daten
* ("14. August 2026") liest das Modell richtig, eine Postleitzahl dagegen
* nicht -- "83646" wird als Zahl gelesen und die Hausnummer davor verschmilzt
* damit. Andreas' Entscheidung: Ziffern bleiben Ziffern (uebersetzbar), aber
* mit Leerzeichen getrennt, und davor eine Ansage mit Pause:
* "Bergstraße 7, 83646 Bad Tölz" -> "Bergstraße 7; Postleitzahl 8 3 6 4 6, Bad Tölz"
* Das war Variante 4 von vier gehoerten; die stummen Trenner (Punkt,
* Gedankenstrich, Auslassung) trennten schlechter.
*
* Bewusst eng: nur fuenf Ziffern, denen ein grossgeschriebener Ortsname folgt.
* Betraege (Komma/Punkt im Inneren, "Euro" dahinter), Daten und Jahreszahlen
* bleiben unberuehrt -- die kann das Modell.
*/
// Ortsname: grossgeschriebenes Wort, das keine Einheit ist ("83646 Euro" ist ein Betrag)
const KEIN_ORT = "Euro|EUR|Uhr|Prozent|Stück|Stueck|Meter|Kilometer|Gramm|Kilo|Liter|Personen|Tage|Jahre";
const ORT = `(?!(?:${KEIN_ORT})\\b)[A-ZÄÖÜ][\\wäöüß.-]+`;
/** Postleitzahl vor Ortsname: Ziffern einzeln, mit Ansage und Pause davor. */
export function postleitzahlenAussprechen(text: string): string {
// vor der PLZ: Textanfang, oder ein Zeichen (kein Punkt/Bindestrich: "2026-83646" ist eine Nummer)
// plus Komma/Leerzeichen als Trenner -- so bleibt die Hausnummer "7" ein eigenes Wort.
return text.replace(
new RegExp(`(^|[^\\s.,-](?:,\\s*|\\s+))(\\d{5})(?=\\s+${ORT})`, "gu"),
(_, vor: string, plz: string) => {
const ziffern = plz.split("").join(" ");
const kern = vor.replace(/[,\s]+$/u, "");
if (kern === "") return `Postleitzahl ${ziffern},`;
// nach Doppelpunkt reicht die Ansage, sonst Semikolon als Pause zur Hausnummer
return kern.endsWith(":") ? `${kern} Postleitzahl ${ziffern},` : `${kern}; Postleitzahl ${ziffern},`;
},
);
}
/** Alle Regeln fuer Sprechtext; heute nur die Postleitzahl. */
export function sprechtextNormalisieren(text: string): string {
return postleitzahlenAussprechen(text);
}