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:
@@ -130,6 +130,23 @@ Der erste Lauf mit leerem State prüft jeden Alias unter Last, das ist gewollt.
|
||||
- Upstream-Schlüssel aus der Umgebung: `OPENROUTER_API_KEY`, `MISTRAL_API_KEY`.
|
||||
- `ADMIN_KEY` schützt `GET /stats?days=N`, `GET /zuordnung`, `POST /auffrischen`.
|
||||
|
||||
## Sprechtext-Normalisierung (TTS)
|
||||
|
||||
Vor jeder Synthese über `/v1/audio/speech` läuft `input` durch
|
||||
`src/sprechtext.ts`, für alle TTS-Ziele gleich (lokales Qwen-TTS, Mistral
|
||||
Voxtral, künftig Fish). Befund 2026-09-06 mit Fish S2 Pro: Beträge
|
||||
(„1.248,50 Euro") und Daten („14. August 2026") liest das Modell richtig, eine
|
||||
Postleitzahl nicht, und die Hausnummer davor verschmilzt damit. Regel (Andreas,
|
||||
Variante 4 von vier gehörten):
|
||||
|
||||
Bergstraße 7, 83646 Bad Tölz → Bergstraße 7; Postleitzahl 8 3 6 4 6, Bad Tölz
|
||||
|
||||
Ziffern bleiben Ziffern (übersetzbar), mit Leerzeichen getrennt; das Semikolon
|
||||
erzwingt die Pause, die Ansage macht die Folge eindeutig. Greift nur bei fünf
|
||||
Ziffern vor einem großgeschriebenen Ortsnamen; Einheiten („83646 Euro"),
|
||||
Bindestrich-Nummern („2026-83646") und Zahlen ohne Ortsnamen bleiben
|
||||
unberührt. Tests: `bun test` im Router-Verzeichnis.
|
||||
|
||||
## Log und Statistik
|
||||
|
||||
Je Anfrage eine Zeile in `log/requests.jsonl` und in `log/requests.sqlite`:
|
||||
|
||||
+4
-2
@@ -87,7 +87,8 @@
|
||||
"42i/marvin-stt",
|
||||
"42i/marvin-stt-turbo",
|
||||
"42i/marvin-tts",
|
||||
"42i/marvin-tts-turbo"
|
||||
"42i/marvin-tts-turbo",
|
||||
"openrouter/openai/gpt-5.6-luna"
|
||||
],
|
||||
"persona-inka-mx": [
|
||||
"42i/marvin-2609-core:none",
|
||||
@@ -100,7 +101,8 @@
|
||||
"42i/marvin-stt",
|
||||
"42i/marvin-stt-turbo",
|
||||
"42i/marvin-tts",
|
||||
"42i/marvin-tts-turbo"
|
||||
"42i/marvin-tts-turbo",
|
||||
"openrouter/openai/gpt-5.6-luna"
|
||||
],
|
||||
"persona-andreas": [],
|
||||
"nightjob": [
|
||||
|
||||
@@ -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 }; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { sprechtextNormalisieren as n } from "../src/sprechtext.ts";
|
||||
|
||||
describe("Postleitzahl", () => {
|
||||
test("Hausnummer, Komma, PLZ, Ort", () => {
|
||||
expect(n("an die 42i GmbH, Bergstraße 7, 83646 Bad Tölz.")).toBe("an die 42i GmbH, Bergstraße 7; Postleitzahl 8 3 6 4 6, Bad Tölz.");
|
||||
});
|
||||
test("ohne Komma", () => {
|
||||
expect(n("Bergstraße 7 83646 Bad Tölz")).toBe("Bergstraße 7; Postleitzahl 8 3 6 4 6, Bad Tölz");
|
||||
});
|
||||
test("mehrteiliger Ortsname", () => {
|
||||
expect(n("Adresse: 60311 Frankfurt am Main, Zeil 1")).toBe("Adresse: Postleitzahl 6 0 3 1 1, Frankfurt am Main, Zeil 1");
|
||||
});
|
||||
test("Ort mit Bindestrich", () => {
|
||||
expect(n("in 40210 Düsseldorf-Stadtmitte")).toBe("in; Postleitzahl 4 0 2 1 0, Düsseldorf-Stadtmitte");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bleibt unberuehrt", () => {
|
||||
test("Betrag mit Tausenderpunkt", () => {
|
||||
const t = "Ihre Rechnung über 1.248,50 Euro ist offen.";
|
||||
expect(n(t)).toBe(t);
|
||||
});
|
||||
test("fuenfstelliger Betrag mit Euro", () => {
|
||||
const t = "Der Kaufpreis beträgt 83646 Euro im Jahr 2026.";
|
||||
expect(n(t)).toBe(t);
|
||||
});
|
||||
test("Datum und Jahreszahl", () => {
|
||||
const t = "vom 14. August 2026 bis zum 30. September 2026";
|
||||
expect(n(t)).toBe(t);
|
||||
});
|
||||
test("PLZ am Satzanfang", () => {
|
||||
expect(n("83646 Bad Tölz ist der Sitz.")).toBe("Postleitzahl 8 3 6 4 6, Bad Tölz ist der Sitz.");
|
||||
});
|
||||
test("Kundennummer ohne Ortsnamen", () => {
|
||||
const t = "Kundennummer 48213 bitte bereithalten.";
|
||||
expect(n(t)).toBe(t);
|
||||
});
|
||||
test("Teil einer laengeren Zahl", () => {
|
||||
const t = "Rechnungsnummer 2026-83646 Berlin";
|
||||
expect(n(t)).toBe(t);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user