feat(queue): Gesetze max_text und max_age — gekürzte Langtexte, verfallene Stau-Ansagen
Zwei Löcher im Anti-Spam-Gesetz, die ein Prompt trivial nutzt: Ein Agent
kann 64 KB Text einreichen (ElevenLabs stellt die Rechnung, die Ansage
dauert Minuten), und geparkte normal-Ansagen spielen unbegrenzt spät —
„Build fertig" zweieinhalb Minuten nach dem Build ist keine Nachricht.
- queue.max_text (Default 400 Zeichen, Env TALK_MAX_TEXT): längere Texte
werden beim Eintritt an einer Wortgrenze gekürzt und so gesprochen; die
/speak-Antwort meldet truncated, die CLI warnt auf stderr, das MCP-
Ergebnis sagt es im Text. Runen, nicht Bytes.
- queue.max_age (Default 2m, Env TALK_MAX_AGE): Items, die beim
Fälligwerden länger gewartet haben, werden verworfen (discarded
"expired", Protokoll nennt Wartezeit und Grenze). now:true ist
ausgenommen. Rang: now > veraltet > Ruhezeit > Rate-Limit.
- /status trägt "limits": {max_text, max_age}; `talk status` zeigt sie.
- Negative Werte sind Config-Fehler; 0 = unbegrenzt.
Tests: truncateText-Tabelle (Wortgrenze, Satzzeichen, Runen), Enqueue-
Kürzung, decide-Vorrang inkl. now-Ausnahme, geparktes Item verfällt;
Config-Schichten; Prozessgrenze: Provider bekommt die gekürzte Fassung,
dritte von drei Stau-Ansagen wird nie gesprochen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
79314dfb0f
commit
97650ad0ec
13 files changed
+430
-26
No files matched your search
@@ -29,6 +29,14 @@ const DefaultServerPort = 7800
|
||||
// Default 1 Ansage / 30 s. „0s" schaltet das Limit ab.
|
||||
const DefaultRateLimit = 30 * time.Second
|
||||
|
||||
// DefaultMaxText ist die Default-Obergrenze der Ansage-Länge in Zeichen —
|
||||
// etwa zwei bis drei Sätze, rund 25 Sekunden Sprache. 0 = unbegrenzt.
|
||||
const DefaultMaxText = 400
|
||||
|
||||
// DefaultMaxAge ist die Default-Höchstwartezeit einer Ansage in der Queue;
|
||||
// was länger parkt, ist veraltet und wird verworfen. „0s" = unbegrenzt.
|
||||
const DefaultMaxAge = 2 * time.Minute
|
||||
|
||||
// Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02
|
||||
// führt audio.sink; Task 03 ergänzt den Provider-Pin, Provider-Endpunkte
|
||||
// und das Voice-Mapping; Task 04 den Daemon-Port (server.port, TALK_PORT);
|
||||
@@ -129,6 +137,16 @@ type Queue struct {
|
||||
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
|
||||
// verstoßen (Default true). Die Ruhezeit gilt davon unberührt.
|
||||
AllowHighBurst bool `toml:"allow_high_burst"`
|
||||
|
||||
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen; längere
|
||||
// Texte werden an einer Wortgrenze gekürzt (Default 400; 0 =
|
||||
// unbegrenzt; Env TALK_MAX_TEXT).
|
||||
MaxText int `toml:"max_text"`
|
||||
|
||||
// MaxAge ist die längste Wartezeit einer Ansage in der Queue; ältere
|
||||
// werden beim Fälligwerden verworfen (Default "2m"; "0s" = unbegrenzt;
|
||||
// Env TALK_MAX_AGE).
|
||||
MaxAge time.Duration `toml:"max_age"`
|
||||
}
|
||||
|
||||
// ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
|
||||
@@ -148,7 +166,7 @@ func Defaults() Config {
|
||||
return Config{
|
||||
Audio: Audio{Sink: SinkOto},
|
||||
Server: Server{Port: DefaultServerPort},
|
||||
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
|
||||
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge},
|
||||
Daemon: Daemon{Autostart: true},
|
||||
QuietMode: queue.QuietModeDiscard,
|
||||
}
|
||||
@@ -223,6 +241,20 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
||||
cfg.Server.AllowedOrigins = origins
|
||||
}
|
||||
}
|
||||
if v := getenv("TALK_MAX_TEXT"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return cfg, fmt.Errorf("TALK_MAX_TEXT: ungültiger Wert %q (Zeichen; 0 = unbegrenzt)", v)
|
||||
}
|
||||
cfg.Queue.MaxText = n
|
||||
}
|
||||
if v := getenv("TALK_MAX_AGE"); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil || d < 0 {
|
||||
return cfg, fmt.Errorf("TALK_MAX_AGE: ungültiger Wert %q (Dauer, z. B. \"2m\"; \"0s\" = unbegrenzt)", v)
|
||||
}
|
||||
cfg.Queue.MaxAge = d
|
||||
}
|
||||
if v := getenv("TALK_AUTOSTART"); v != "" {
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
@@ -264,6 +296,9 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
||||
}
|
||||
cfg.QuietMode = v
|
||||
}
|
||||
if cfg.Queue.MaxText < 0 || cfg.Queue.MaxAge < 0 || cfg.Queue.RateLimit < 0 {
|
||||
return cfg, fmt.Errorf("queue: max_text, max_age und rate_limit dürfen nicht negativ sein (0 = unbegrenzt)")
|
||||
}
|
||||
switch cfg.Audio.Sink {
|
||||
case SinkOto, SinkNull:
|
||||
default:
|
||||
|
||||
@@ -731,3 +731,58 @@ func TestLoadDaemonAutostart(t *testing.T) {
|
||||
t.Error("TALK_AUTOSTART=vielleicht: kein Fehler, want klarer Fehler")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadLimits deckt queue.max_text / queue.max_age ab: Defaults 400 / 2m,
|
||||
// TOML und Env (Env schlägt Datei), 0 = unbegrenzt, Unsinn und negative
|
||||
// Werte sind klare Fehler.
|
||||
func TestLoadLimits(t *testing.T) {
|
||||
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
|
||||
writeTOML := func(t *testing.T, content string) string {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Queue.MaxText != DefaultMaxText || cfg.Queue.MaxAge != DefaultMaxAge {
|
||||
t.Errorf("Defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
|
||||
}
|
||||
|
||||
cfg, err = Load(Options{File: writeTOML(t, "[queue]\nmax_text = 0\nmax_age = \"45s\"\n"), Getenv: env(nil)})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Queue.MaxText != 0 || cfg.Queue.MaxAge != 45*time.Second {
|
||||
t.Errorf("TOML = %d/%v, want 0/45s", cfg.Queue.MaxText, cfg.Queue.MaxAge)
|
||||
}
|
||||
|
||||
cfg, err = Load(Options{
|
||||
File: writeTOML(t, "[queue]\nmax_text = 100\n"),
|
||||
Getenv: env(map[string]string{"TALK_MAX_TEXT": "250", "TALK_MAX_AGE": "0s"}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Queue.MaxText != 250 || cfg.Queue.MaxAge != 0 {
|
||||
t.Errorf("Env = %d/%v, want 250/0 (Env schlägt Datei)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
|
||||
}
|
||||
|
||||
for name, kv := range map[string]map[string]string{
|
||||
"TALK_MAX_TEXT=abc": {"TALK_MAX_TEXT": "abc"},
|
||||
"TALK_MAX_TEXT=-1": {"TALK_MAX_TEXT": "-1"},
|
||||
"TALK_MAX_AGE=bald": {"TALK_MAX_AGE": "bald"},
|
||||
"TALK_MAX_AGE=-1s": {"TALK_MAX_AGE": "-1s"},
|
||||
} {
|
||||
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(kv)}); err == nil {
|
||||
t.Errorf("%s: kein Fehler, want klarer Fehler", name)
|
||||
}
|
||||
}
|
||||
if _, err := Load(Options{File: writeTOML(t, "[queue]\nmax_text = -5\n"), Getenv: env(nil)}); err == nil {
|
||||
t.Error("max_text = -5 in TOML: kein Fehler, want klarer Fehler")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user