diff --git a/README.md b/README.md index cd08d3a..9727949 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/key-acls.json b/key-acls.json index c478284..00b4d43 100644 --- a/key-acls.json +++ b/key-acls.json @@ -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": [ diff --git a/src/server.ts b/src/server.ts index 65705a6..2122d21 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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, ziel: Ziel, pfad: string): Record { const b: Record = { ...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 }; } diff --git a/src/sprechtext.ts b/src/sprechtext.ts new file mode 100644 index 0000000..fae7940 --- /dev/null +++ b/src/sprechtext.ts @@ -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); +} diff --git a/test/sprechtext.test.ts b/test/sprechtext.test.ts new file mode 100644 index 0000000..b9f136f --- /dev/null +++ b/test/sprechtext.test.ts @@ -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); + }); +});