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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user