diff --git a/README.md b/README.md index 6342bc1..ff4bca3 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/cmd/talk/control.go b/cmd/talk/control.go index 8bf7e94..ed1db0a 100644 --- a/cmd/talk/control.go +++ b/cmd/talk/control.go @@ -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 diff --git a/cmd/talk/daemon.go b/cmd/talk/daemon.go index 7ae5ebb..5a0d483 100644 --- a/cmd/talk/daemon.go +++ b/cmd/talk/daemon.go @@ -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 + ")" } diff --git a/internal/api/mcp.go b/internal/api/mcp.go index 6afbd1a..e3ccda3 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -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}}} } } diff --git a/internal/api/server.go b/internal/api/server.go index 75880c4..2ac128b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) } diff --git a/internal/api/types.go b/internal/api/types.go index 9505c21..84bfe34 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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). diff --git a/internal/config/config.go b/internal/config/config.go index 9646857..4598356 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 462d8c6..114e367 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index ac971ec..a2bdd25 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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: diff --git a/internal/queue/laws_test.go b/internal/queue/laws_test.go new file mode 100644 index 0000000..703f116 --- /dev/null +++ b/internal/queue/laws_test.go @@ -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()) + } +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 6baaac1..75728f6 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -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) } } } diff --git a/internal/queue/rate.go b/internal/queue/rate.go index d7a638c..8d40901 100644 --- a/internal/queue/rate.go +++ b/internal/queue/rate.go @@ -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 } diff --git a/test/limits_test.go b/test/limits_test.go new file mode 100644 index 0000000..14188a6 --- /dev/null +++ b/test/limits_test.go @@ -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()) + } +}