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:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 09:54:40 +02:00
1 parent 79314dfb0f
commit 97650ad0ec
13 files changed
+423 -19

No files matched your search

+23 -6
View File
@@ -65,9 +65,9 @@ schreibt die Mute-Datei direkt.
| Endpunkt | Wirkung |
| --- | --- |
| `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}`; bei Mute: `202 {"id":"","muted":true}`; bei Gesetz-Verwurf: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` im Toast-Modus) — verworfen, dokumentiertes Verhalten |
| `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}` (+ `"truncated":true`, wenn auf `max_text` gekürzt); bei Mute: `202 {"id":"","muted":true}`; bei Gesetz-Verwurf: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` im Toast-Modus) — verworfen, dokumentiertes Verhalten |
| `OPTIONS /speak` | CORS-Preflight → `204` mit Freigabe-Headern, wenn die `Origin` in `server.allowed_origins` steht (siehe unten); sonst `204` ohne CORS-Header |
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "mcp": true, "cors": [origins]}` |
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "limits": {"max_text", "max_age"}, "mcp": true, "cors": [origins]}` |
| `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` |
| `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` |
| `POST /shutdown` | fährt den Daemon herunter (`talk daemon stop`) → `200 {"ok":true}` |
@@ -117,21 +117,38 @@ sind Opt-in wie Mute). In der Ruhezeit verfällt Audio, auch für high
Notification-Anbindung).
- `now:true` **bricht auch die Ruhezeit** (bewusster Ausbruch).
**Rangordnung:** manuelles Mute gewinnt immer > `now` > Ruhezeit >
Rate-Limit. Konfiguration (config.toml, Env schlägt Datei):
**Länge und Alter:** Zwei weitere Gesetze gegen das, was Prompts gern tun
— die Langantwort vorlesen und Ansagen stapeln:
- `max_text` (Default **400 Zeichen**, etwa zwei bis drei Sätze): Längere
Texte werden beim Eintritt an einer Wortgrenze **gekürzt** und so
gesprochen; die Antwort meldet `"truncated": true`, die CLI warnt auf
stderr, das MCP-Ergebnis sagt es im Text. Der Nutzer hört den Anfang
statt zehn Minuten Vortrag, das API-Budget bleibt begrenzt.
- `max_age` (Default **2 Minuten**): Was länger in der Queue parkt (Rate-
Limit, Stau), ist keine Nachricht mehr und wird beim Fälligwerden
**verworfen** (`discarded: "expired"` im Protokoll; die 202-Antwort beim
Eintritt bleibt eine Zusage, denn beim Eintritt ist nichts veraltet).
`now:true` wartet nie lange und ist ausgenommen.
**Rangordnung:** manuelles Mute gewinnt immer > `now` > veraltet (`max_age`)
> Ruhezeit > Rate-Limit; `max_text` greift beim Eintritt auf alles.
Konfiguration (config.toml, Env schlägt Datei):
```toml
[queue]
rate_limit = "30s" # Dauer; "0s" = unbegrenzt (TALK_RATE_LIMIT)
allow_high_burst = true # high darf verstoßen (TALK_ALLOW_HIGH_BURST)
max_text = 400 # Zeichen; 0 = unbegrenzt (TALK_MAX_TEXT)
max_age = "2m" # Wartezeit; "0s" = unbegrenzt (TALK_MAX_AGE)
quiet_hours = "18:00-08:00" # leer = aus; Env TALK_QUIET_HOURS ("off" = aus)
quiet_mode = "discard" # discard | toast (TALK_QUIET_MODE)
```
`talk status` zeigt den Gesetz-State: `rate-limit: 30s (high-burst
erlaubt), nächster slot …` und `ruhezeit: 18:00-08:00 (modus discard,
aktiv bis 08:00)`.
erlaubt), nächster slot …`, `ruhezeit: 18:00-08:00 (modus discard,
aktiv bis 08:00)` und `limits: max. 400 Zeichen, max. Wartezeit 2m0s`.
### CORS-Tür + MCP-Endpoint (Task 06)
+11
View File
@@ -146,6 +146,17 @@ func printLawState(stdout io.Writer, st *api.StatusResponse) {
}
fmt.Fprintf(stdout, "ruhezeit: %s (modus %s, %s)\n", window, st.QuietHours.Mode, state)
}
if st.Limits != nil {
text := fmt.Sprintf("max. %d Zeichen", st.Limits.MaxText)
if st.Limits.MaxText == 0 {
text = "Länge unbegrenzt"
}
age := "max. Wartezeit " + st.Limits.MaxAge
if st.Limits.MaxAge == "0s" {
age = "Wartezeit unbegrenzt"
}
fmt.Fprintf(stdout, "limits: %s, %s\n", text, age)
}
}
// runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
+5
View File
@@ -158,6 +158,9 @@ func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stder
return exitOK, true
}
fmt.Fprintln(info, resp.ID)
if resp.Truncated {
fmt.Fprintln(stderr, "talk: Text war zu lang und wurde gekürzt (queue.max_text) — Ansagen sind 1–2 Sätze")
}
if !opts.wait {
return exitOK, true
}
@@ -175,6 +178,8 @@ func discardHint(reason string, toast bool) string {
return "Ruhezeit aktiv — Ansage verworfen (quiet_mode = discard; now bricht bewusst aus)"
case queue.DiscardRateLimit:
return "Rate-Limit — Ansage verworfen (Priorität low über dem Fenster-Budget)"
case queue.DiscardExpired:
return "Ansage verworfen — sie hat länger gewartet als queue.max_age erlaubt (veraltet)"
default:
return "Ansage verworfen (" + reason + ")"
}
+5 -4
View File
@@ -334,10 +334,11 @@ func speakToolResult(resp SpeakResponse) mcpToolResult {
Text: "discarded: " + resp.Discarded,
}}}
default:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "queued: " + resp.ID,
}}}
text := "queued: " + resp.ID
if resp.Truncated {
text += " (Text auf max_text gekürzt — Ansagen sind 1–2 Sätze, keine Langantworten)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
}
}
+4 -2
View File
@@ -164,7 +164,7 @@ func (h *handler) admitSpeak(req SpeakRequest) speakAdmission {
return speakAdmission{status: http.StatusAccepted,
resp: SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}}
}
return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID}}
return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID, Truncated: adm.Truncated}}
}
// status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
@@ -195,7 +195,9 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) {
if h.provider != nil {
st.Provider = h.provider()
}
st.RateLimit, st.QuietHours = lawState(h.queue.LawState())
law := h.queue.LawState()
st.RateLimit, st.QuietHours = lawState(law)
st.Limits = &LimitsState{MaxText: law.MaxText, MaxAge: law.MaxAge.String()}
writeJSON(w, http.StatusOK, st)
}
+15
View File
@@ -41,6 +41,10 @@ type SpeakResponse struct {
// Toast meldet die Ersetzung durch eine Benachrichtigung.
Toast bool `json:"toast,omitempty"`
// Truncated meldet, dass der Text auf queue.max_text gekürzt und so
// aufgenommen wurde (Gesetz gegen vorgelesene Langantworten).
Truncated bool `json:"truncated,omitempty"`
}
// CurrentItem beschreibt das laufende Item in GET /status.
@@ -66,6 +70,7 @@ type StatusResponse struct {
Provider string `json:"provider"`
RateLimit *RateLimitState `json:"rate_limit,omitempty"`
QuietHours *QuietHoursState `json:"quiet_hours,omitempty"`
Limits *LimitsState `json:"limits,omitempty"`
// MCP meldet, dass der Daemon den MCP-Endpoint anbietet (immer true;
// Feld existiert für additive Compat-Sicht von Clients/Browsern).
@@ -90,6 +95,16 @@ type RateLimitState struct {
NextSlot *string `json:"next_slot"`
}
// LimitsState sind die Längen- und Alters-Gesetze in GET /status.
type LimitsState struct {
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (0 = unbegrenzt).
MaxText int `json:"max_text"`
// MaxAge ist die längste Wartezeit in der Queue als Dauer-String
// ("2m0s"; "0s" = unbegrenzt).
MaxAge string `json:"max_age"`
}
// QuietHoursState ist der Ruhezeit-State in GET /status (Task 05).
type QuietHoursState struct {
// Window ist das konfigurierte Fenster ("18:00-08:00"; "" = deaktiviert).
+36 -1
View File
@@ -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:
+55
View File
@@ -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")
}
}
+4 -2
View File
@@ -87,6 +87,8 @@ func Run(ctx context.Context, opts Options) error {
AllowHighBurst: cfg.Queue.AllowHighBurst,
Quiet: quiet,
QuietMode: cfg.QuietMode,
MaxText: cfg.Queue.MaxText,
MaxAge: cfg.Queue.MaxAge,
}, func(ctx context.Context, item queue.Item) error {
return engine.Speak(ctx, speak.Request{
Text: item.Text,
@@ -148,9 +150,9 @@ func Run(ctx context.Context, opts Options) error {
if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
}
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(),
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.QuietHours, cfg.QuietMode, corsLog)
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
select {
case err := <-errCh:
+114
View File
@@ -0,0 +1,114 @@
package queue
import (
"strings"
"testing"
"time"
)
// TestTruncateText: Kürzung auf max Runen an einer Wortgrenze (nicht in der
// ersten Hälfte), ohne hängende Satzzeichen; kurze Texte und max 0 bleiben
// unangetastet; Runen zählen, nicht Bytes.
func TestTruncateText(t *testing.T) {
cases := []struct {
name string
in string
max int
want string
truncated bool
}{
{"kurz bleibt", "Build fertig", 400, "Build fertig", false},
{"genau max bleibt", "abcde", 5, "abcde", false},
{"unbegrenzt", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
{"wortgrenze", "Der Build ist fertig und bereit", 14, "Der Build ist", true},
{"satzzeichen weg", "Fertig, aber dann kam noch mehr", 8, "Fertig", true},
{"keine wortgrenze in zweiter hälfte → hart", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
{"umlaute als eine rune", "Übergröße ärgert", 9, "Übergröße", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, tr := truncateText(c.in, c.max)
if got != c.want || tr != c.truncated {
t.Errorf("truncateText(%q, %d) = %q/%v, want %q/%v", c.in, c.max, got, tr, c.want, c.truncated)
}
if c.max > 0 && len([]rune(got)) > c.max {
t.Errorf("Ergebnis %q länger als %d Runen", got, c.max)
}
})
}
}
// TestEnqueueKuerztText: Das Gesetz MaxText greift beim Eintritt — die
// gekürzte Fassung wird gesprochen, Admission meldet Truncated.
func TestEnqueueKuerztText(t *testing.T) {
g := neueSpielerin()
q := New(Laws{MaxText: 20}, g.speak, nil)
defer q.Close()
adm, err := q.Enqueue(Item{Text: "Dieser Text ist deutlich länger als zwanzig Zeichen"})
if err != nil || adm.ID == "" || !adm.Truncated {
t.Fatalf("Enqueue = %+v, %v; want aufgenommen + Truncated", adm, err)
}
items := g.warteBis(t, 1)
if items[0].Text != "Dieser Text ist" {
t.Errorf("gesprochen = %q, want gekürzte Fassung", items[0].Text)
}
g.step <- struct{}{}
adm, _ = q.Enqueue(Item{Text: "kurz"})
if adm.Truncated {
t.Error("kurzer Text als gekürzt gemeldet")
}
g.warteBis(t, 2)
g.step <- struct{}{}
}
// TestDecideExpired: Ein Item, das länger als MaxAge gewartet hat, wird
// verworfen — vor Ruhezeit und Rate-Limit, aber nach now (now bricht aus).
func TestDecideExpired(t *testing.T) {
laws := Laws{MaxAge: time.Minute, RateLimit: time.Hour}
now := time.Now()
alt := Item{Text: "alt", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
frisch := Item{Text: "frisch", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
if v := laws.decide(alt, now, true); v != verdictDiscardExpired {
t.Errorf("altes Item bei freiem Slot = %v, want verdictDiscardExpired", v)
}
if v := laws.decide(frisch, now, true); v != verdictPlay {
t.Errorf("frisches Item bei freiem Slot = %v, want verdictPlay", v)
}
if v := laws.decide(frisch, now, false); v != verdictWait {
t.Errorf("frisches Item ohne Slot = %v, want verdictWait", v)
}
altNow := alt
altNow.Now = true
if v := laws.decide(altNow, now, false); v != verdictPlay {
t.Errorf("altes now-Item = %v, want verdictPlay (now bricht aus)", v)
}
if v := (Laws{}).decide(alt, now, true); v != verdictPlay {
t.Errorf("MaxAge 0 (unbegrenzt) verwirft altes Item: %v", v)
}
}
// TestGeparktesItemVerfaellt: Ein normal-Item parkt auf den Rate-Limit-Slot;
// ist es beim Fälligwerden älter als MaxAge, wird es verworfen (Protokoll
// nennt max_age) und nicht mehr gesprochen.
func TestGeparktesItemVerfaellt(t *testing.T) {
logb := &syncLog{}
g := neueSpielerin()
q := New(Laws{RateLimit: 300 * time.Millisecond, MaxAge: 150 * time.Millisecond}, g.speak, logb)
defer q.Close()
q.Enqueue(Item{Text: "erste"}) // bucht das Fenster
g.warteBis(t, 1)
g.step <- struct{}{}
adm, _ := q.Enqueue(Item{Text: "parkt zu lange"}) // parkt ~300 ms > MaxAge
if adm.ID == "" || adm.Discarded != "" {
t.Fatalf("beim Eintritt darf nichts verfallen: %+v", adm)
}
waitFor(t, func() bool { return strings.Contains(logb.String(), "max_age") })
g.keineWeitere(t)
if !strings.Contains(logb.String(), adm.ID) {
t.Errorf("Protokoll nennt die verfallene Ansage nicht:\n%s", logb.String())
}
}
+22 -2
View File
@@ -66,6 +66,7 @@ type Item struct {
Now bool
seq uint64 // Enqueue-Reihenfolge: FIFO-Tiebrecher innerhalb eines Levels
enqueued time.Time // Eintrittszeit (Basis für Laws.MaxAge)
}
// rank liefert die Abarbeitungs-Klasse: now über high über normal über low.
@@ -160,7 +161,12 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
if item.Priority == "" {
item.Priority = PriorityNormal
}
switch q.laws.decide(item, time.Now(), q.limit.Allow()) {
var truncated bool
if item.Text, truncated = truncateText(item.Text, q.laws.MaxText); truncated {
defer q.logf("queue: ansage (%q …) auf %d Zeichen gekürzt (max_text)", firstRunes(item.Text, 40), q.laws.MaxText)
}
item.enqueued = time.Now()
switch q.laws.decide(item, item.enqueued, q.limit.Allow()) {
case verdictDiscardQuiet:
q.mu.Unlock()
q.logf("queue: ansage (%q, priorität %s) verworfen — ruhezeit %s aktiv (modus discard)",
@@ -185,7 +191,16 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
q.items = append(q.items, item)
q.mu.Unlock()
q.notEmpty.Signal()
return Admission{ID: item.ID}, nil
return Admission{ID: item.ID, Truncated: truncated}, nil
}
// firstRunes liefert die ersten n Runen von s (Protokoll-Vorschau).
func firstRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// Pending liefert eine Momentaufnahme der wartenden Items in
@@ -272,6 +287,8 @@ func (q *Queue) LawState() LawState {
QuietActive: q.laws.Quiet.Active(now),
QuietMode: q.laws.QuietMode,
QuietUntil: q.laws.Quiet.Until(now),
MaxText: q.laws.MaxText,
MaxAge: q.laws.MaxAge,
}
}
@@ -348,6 +365,9 @@ func (q *Queue) loop() {
case verdictToast:
q.logf("queue: ansage %s (%q) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
item.ID, item.Text, q.laws.Quiet)
case verdictDiscardExpired:
q.logf("queue: ansage %s (%q) verworfen — %s gewartet, älter als max_age %s",
item.ID, item.Text, time.Since(item.enqueued).Round(time.Second), q.laws.MaxAge)
}
}
}
+51 -2
View File
@@ -1,8 +1,10 @@
package queue
import (
"strings"
"sync"
"time"
"unicode"
)
// Verwurfs-Gründe im HTTP-Vertrag von POST /speak
@@ -10,6 +12,7 @@ import (
const (
DiscardRateLimit = "rate_limit" // priority=low über dem Fenster-Budget
DiscardQuietHours = "quiet_hours" // Ruhezeit aktiv (discard oder toast)
DiscardExpired = "expired" // länger gewartet als queue.max_age (Ansage wäre veraltet)
)
// RateLimiter ist das Zeitfenster des Daemon-Gesetzes gegen Ansage-Spam:
@@ -90,6 +93,19 @@ type Laws struct {
// echte OS-Toast folgt mit der Notification-Anbindung (dokumentierte
// Einschränkung, siehe Task-05-Report).
QuietMode string
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (Runen).
// Längere Texte werden beim Eintritt an einer Wortgrenze gekürzt und
// als gekürzt gemeldet — ein Agent, der die Langantwort vorliest,
// kostet so weder Minuten noch API-Budget; der Nutzer hört den Anfang.
// 0 = unbegrenzt.
MaxText int
// MaxAge ist die längste Wartezeit einer Ansage in der Queue. Was
// länger parkt (Rate-Limit, Stau), ist veraltet und wird beim
// Fälligwerden verworfen (DiscardExpired) statt gesprochen. 0 =
// unbegrenzt. now:true wartet nie lange und ist davon ausgenommen.
MaxAge time.Duration
}
// verdict ist die Gesetzes-Entscheidung über ein fälliges Item.
@@ -101,6 +117,7 @@ const (
verdictDiscardRate // verwerfen: low über dem Budget
verdictDiscardQuiet // verwerfen: Ruhezeit (discard)
verdictToast // Toast statt Audio (Ruhezeit)
verdictDiscardExpired // verwerfen: länger gewartet als MaxAge
)
// decide wendet die Gesetze auf ein fälliges Item an (rein, ohne Zustand):
@@ -109,14 +126,19 @@ const (
// 1. now:true bricht bewusst aus — überspringt Rate-Limit UND Ruhezeit
// (dokumentierter Ausbruch für Dringendes; startet trotzdem ein neues
// Rate-Fenster).
// 2. Ruhezeit: discard verwirft, toast ersetzt — gilt auch für high,
// 2. Veraltet: Hat das Item länger als MaxAge gewartet, wird es verworfen
// — eine Ansage von vor fünf Minuten ist keine Nachricht mehr.
// 3. Ruhezeit: discard verwirft, toast ersetzt — gilt auch für high,
// denn Burst ist ein Rate-Recht, kein Ruhe-Recht.
// 3. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low
// 4. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low
// wird verworfen; normal (und high ohne Recht) parken bis zum Slot.
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
if item.Now {
return verdictPlay
}
if l.MaxAge > 0 && !item.enqueued.IsZero() && now.Sub(item.enqueued) > l.MaxAge {
return verdictDiscardExpired
}
if l.Quiet.Active(now) {
if l.QuietMode == QuietModeToast {
return verdictToast
@@ -148,6 +170,10 @@ type Admission struct {
// Toast meldet, dass die Ansage in der Ruhezeit durch eine
// Benachrichtigung ersetzt wurde (quiet_mode = "toast").
Toast bool
// Truncated meldet, dass der Text auf MaxText gekürzt wurde (die
// gekürzte Fassung ist aufgenommen).
Truncated bool
}
// LawState ist die /status-Sicht auf die Anti-Spam-Gesetze: konfiguriertes
@@ -160,4 +186,27 @@ type LawState struct {
QuietActive bool
QuietMode string
QuietUntil time.Time // Nullzeit = Ruhezeit inaktiv
MaxText int // 0 = unbegrenzt
MaxAge time.Duration // 0 = unbegrenzt
}
// truncateText kürzt s auf höchstens max Runen, bevorzugt an einer
// Wortgrenze (sofern die nicht in der ersten Hälfte liegt), und meldet, ob
// gekürzt wurde. max <= 0 = unbegrenzt.
func truncateText(s string, max int) (string, bool) {
if max <= 0 {
return s, false
}
runes := []rune(s)
if len(runes) <= max {
return s, false
}
cut := max
for i := max; i > max/2; i-- {
if unicode.IsSpace(runes[i]) {
cut = i
break
}
}
return strings.TrimRight(string(runes[:cut]), " \t\r\n,;:-–—"), true
}
+78
View File
@@ -0,0 +1,78 @@
package test
// Prozessgrenzen-Tests für die Längen- und Alters-Gesetze: queue.max_text
// kürzt überlange Texte beim Eintritt (Antwort truncated, CLI-Hinweis, der
// Provider sieht die gekürzte Fassung); queue.max_age verwirft Ansagen, die
// zu lange geparkt haben (expired, nie gesprochen).
import (
"strings"
"testing"
"time"
)
// TestMaxTextKuerzt: Ein 60-Wörter-Text gegen max_text=40 — /speak meldet
// truncated, die CLI warnt auf stderr, der Fake-Provider bekommt höchstens
// 40 Zeichen, und /status zeigt das Limit.
func TestMaxTextKuerzt(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_MAX_TEXT": "40"})
lang := strings.Repeat("viel zu lange Antwort ", 12)
code, m := speak(t, d, `{"text":"`+lang+`"}`)
if code != 202 || m["id"] == "" || m["truncated"] != true {
t.Fatalf("speak: %d %v, want 202 mit id und truncated=true", code, m)
}
hits := fake.waitCompleted(t, 1)
if n := len([]rune(hits[0].Text)); n > 40 || !strings.HasPrefix(hits[0].Text, "viel zu lange Antwort") {
t.Errorf("Provider bekam %q (%d Zeichen), want ≤ 40 Zeichen vom Anfang", hits[0].Text, n)
}
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "say", lang)
if res.code != 0 || !strings.Contains(res.stderr, "gekürzt") {
t.Errorf("talk say (lang): Exit %d, stderr %q — will Kürzungs-Hinweis", res.code, res.stderr)
}
fake.waitCompleted(t, 2)
res = runTalk(t, env, "say", "kurz genug")
if strings.Contains(res.stderr, "gekürzt") {
t.Errorf("kurzer Text mit Kürzungs-Hinweis: %q", res.stderr)
}
fake.waitCompleted(t, 3)
res = runTalk(t, env, "status")
if !strings.Contains(res.stdout, "limits: max. 40 Zeichen") {
t.Errorf("talk status ohne Limits-Zeile: %q", res.stdout)
}
}
// TestMaxAgeVerwirftGeparkte: Rate-Limit 1 s, max_age 1,5 s, drei normal-
// Ansagen direkt hintereinander: die erste spielt sofort, die zweite parkt
// 1 s (frisch genug), die dritte wäre erst nach 2 s fällig — veraltet,
// verworfen, nie beim Provider.
func TestMaxAgeVerwirftGeparkte(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_RATE_LIMIT": "1s",
"TALK_MAX_AGE": "1500ms",
})
for _, text := range []string{"eins", "zwei", "drei"} {
code, m := speak(t, d, `{"text":"`+text+`"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("speak %s: %d %v — beim Eintritt darf nichts verfallen", text, code, m)
}
}
hits := fake.waitCompleted(t, 2)
if hits[0].Text != "eins" || hits[1].Text != "zwei" {
t.Errorf("gesprochen = %q, %q; want eins, zwei", hits[0].Text, hits[1].Text)
}
fake.assertNoFurtherArrivals(t, 1500*time.Millisecond)
waitStatusIdle(t, d, 3*time.Second)
if !strings.Contains(d.stderr.String(), "max_age") {
t.Errorf("Daemon-Protokoll nennt den Verfall nicht:\n%s", d.stderr.String())
}
}