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
+430 -26

No files matched your search

+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())
}
}
+23 -3
View File
@@ -65,7 +65,8 @@ type Item struct {
// high) — sie unterbricht aber nie die laufende Ansage.
Now bool
seq uint64 // Enqueue-Reihenfolge: FIFO-Tiebrecher innerhalb eines Levels
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)
}
}
}
+57 -8
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,17 +93,31 @@ 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.
type verdict int
const (
verdictPlay verdict = iota // abspielen
verdictWait // parken bis zum freien Slot
verdictDiscardRate // verwerfen: low über dem Budget
verdictDiscardQuiet // verwerfen: Ruhezeit (discard)
verdictToast // Toast statt Audio (Ruhezeit)
verdictPlay verdict = iota // abspielen
verdictWait // parken bis zum freien Slot
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
@@ -159,5 +185,28 @@ type LawState struct {
Quiet QuietHours
QuietActive bool
QuietMode string
QuietUntil time.Time // Nullzeit = Ruhezeit inaktiv
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
}