refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d30bd95ad
commit
85fa64cfe6
95 files changed
+4707
-4854
No files matched your search
+79
-82
@@ -7,32 +7,32 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Verwurfs-Gründe im HTTP-Vertrag von POST /speak
|
||||
// Discard reasons in the HTTP contract of POST /speak
|
||||
// (202 {"id":"","discarded":"…"}).
|
||||
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)
|
||||
DiscardRateLimit = "rate_limit" // priority=low over the window budget
|
||||
DiscardQuietHours = "quiet_hours" // quiet hours active (discard or toast)
|
||||
DiscardExpired = "expired" // waited longer than queue.max_age (stale)
|
||||
)
|
||||
|
||||
// RateLimiter ist das Zeitfenster des Daemon-Gesetzes gegen Ansage-Spam:
|
||||
// Spec-Default ca. 1 Ansage / 30 s (queue.rate_limit), 0 = unbegrenzt.
|
||||
// Jede gestartete Ansage bucht das Fenster neu (Reserve) — auch eine per
|
||||
// now oder high-burst ausgebrochene: Das Fenster zählt Abspiel-Starts,
|
||||
// nicht deren Legitimität.
|
||||
// RateLimiter is the time window of the daemon's anti-spam law: default
|
||||
// about one announcement per 30 s (queue.rate_limit), 0 = unlimited. Every
|
||||
// started announcement books the window anew (Reserve), including ones that
|
||||
// broke out via now or high burst: the window counts playback starts, not
|
||||
// their legitimacy.
|
||||
//
|
||||
// Lock-Ordnung: RateLimiter.mu ist immer die innere Sperre — Aufrufer
|
||||
// halten beim Allow/Reserve/Next die Queue-sperre (niemals umgekehrt).
|
||||
// Lock order: RateLimiter.mu is always the inner lock; callers hold the
|
||||
// queue lock during Allow/Reserve/Next, never the other way round.
|
||||
type RateLimiter struct {
|
||||
// Interval ist der Mindestabstand zwischen zwei Ansage-Starten.
|
||||
// 0 oder negativ = unbegrenzt.
|
||||
// Interval is the minimum gap between two announcement starts. 0 or
|
||||
// negative = unlimited.
|
||||
Interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
last time.Time // Start der zuletzt per Reserve gebuchten Ansage
|
||||
last time.Time // start of the announcement last booked via Reserve
|
||||
}
|
||||
|
||||
// Allow meldet, ob sofort eine Ansage starten dürfte.
|
||||
// Allow reports whether an announcement could start right now.
|
||||
func (l *RateLimiter) Allow() bool {
|
||||
if l == nil || l.Interval <= 0 {
|
||||
return true
|
||||
@@ -42,10 +42,9 @@ func (l *RateLimiter) Allow() bool {
|
||||
return time.Since(l.last) >= l.Interval
|
||||
}
|
||||
|
||||
// Reserve bucht einen Ansage-Slot (direkt nach der Play-Entscheidung
|
||||
// aufrufen, vor dem eigentlichen Abspielen — auch fehlgeschlagene Ansagen
|
||||
// verbrauchen das Fenster: Ein kaputter Provider darf nicht zum Maschinengewehr
|
||||
// werden).
|
||||
// Reserve books a slot. Call it right after the play decision, before actual
|
||||
// playback: failed announcements consume the window too, so a broken
|
||||
// provider cannot turn into a machine gun.
|
||||
func (l *RateLimiter) Reserve() {
|
||||
if l == nil || l.Interval <= 0 {
|
||||
return
|
||||
@@ -55,9 +54,9 @@ func (l *RateLimiter) Reserve() {
|
||||
l.last = time.Now()
|
||||
}
|
||||
|
||||
// Next liefert, wann der nächste Ansage-Slot frei wird — Nullzeit, wenn
|
||||
// Allow sofort true liefern würde (unbegrenzt oder Fenster abgelaufen).
|
||||
// Basis für das Parken wartender Items und den Status-Endpoint.
|
||||
// Next returns when the next slot opens; the zero time when Allow would
|
||||
// return true right away (unlimited or window elapsed). Basis for parking
|
||||
// waiting items and for the status endpoint.
|
||||
func (l *RateLimiter) Next() time.Time {
|
||||
if l == nil || l.Interval <= 0 {
|
||||
return time.Time{}
|
||||
@@ -70,68 +69,65 @@ func (l *RateLimiter) Next() time.Time {
|
||||
return l.last.Add(l.Interval)
|
||||
}
|
||||
|
||||
// Laws sind die Anti-Spam-Gesetze des Daemons (Task 05): Rate-Limit mit
|
||||
// verkonfigurierbarem Verstoß-Recht für high, plus Ruhezeiten. Der Nullwert
|
||||
// erlaubt alles (unbegrenzt, kein Ruhefenster) — der Daemon liefert die
|
||||
// Gesetze aus der Config; Default siehe internal/config.Defaults.
|
||||
// Laws are the daemon's anti-spam laws: rate limit with configurable burst
|
||||
// rights for high, quiet hours, text length cap and maximum queue age. The
|
||||
// zero value allows everything; the daemon supplies the laws from the
|
||||
// config (defaults in internal/config.Defaults).
|
||||
type Laws struct {
|
||||
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten
|
||||
// (0 = unbegrenzt).
|
||||
// RateLimit is the minimum gap between two announcement starts
|
||||
// (0 = unlimited).
|
||||
RateLimit time.Duration
|
||||
|
||||
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
|
||||
// verstoßen (Default true). Ohne Burst-Recht wartet high wie normal.
|
||||
// Die Ruhezeit gilt davon unberührt auch für high.
|
||||
// AllowHighBurst lets priority=high deliberately break the rate limit
|
||||
// (default true). Without burst rights, high waits like normal. Quiet
|
||||
// hours apply to high regardless.
|
||||
AllowHighBurst bool
|
||||
|
||||
// Quiet ist das Ruhezeit-Fenster (lokale Zeit; Nullwert = deaktiviert).
|
||||
// Quiet is the quiet window (local time; zero value = disabled).
|
||||
Quiet QuietHours
|
||||
|
||||
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" verwirft
|
||||
// still, "toast" ersetzt Audio durch eine Benachrichtigung — aktuell
|
||||
// eine Protokoll-Zeile über den Notification-Pfad des Loggers; der
|
||||
// echte OS-Toast folgt mit der Notification-Anbindung (dokumentierte
|
||||
// Einschränkung, siehe Task-05-Report).
|
||||
// QuietMode is the behaviour during quiet hours: "discard" drops
|
||||
// silently, "toast" replaces audio with a desktop notification (the
|
||||
// queue fires the OnToast hook; the daemon wires the notification).
|
||||
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 caps announcement length in characters (runes). Longer texts
|
||||
// are cut at a word boundary on admission and reported as truncated: an
|
||||
// agent reading out its long answer costs neither minutes nor API
|
||||
// budget, and the user still hears the beginning. 0 = unlimited.
|
||||
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 is the longest an announcement may wait in the queue. Whatever
|
||||
// parks longer (rate limit, backlog) is stale and gets discarded when it
|
||||
// comes due (DiscardExpired) instead of spoken. 0 = unlimited. now:true
|
||||
// never waits long and is exempt.
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
// verdict ist die Gesetzes-Entscheidung über ein fälliges Item.
|
||||
// verdict is the laws' decision about a due 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)
|
||||
verdictDiscardExpired // verwerfen: länger gewartet als MaxAge
|
||||
verdictPlay verdict = iota // play
|
||||
verdictWait // park until the slot opens
|
||||
verdictDiscardRate // discard: low over budget
|
||||
verdictDiscardQuiet // discard: quiet hours (discard)
|
||||
verdictToast // toast instead of audio (quiet hours)
|
||||
verdictDiscardExpired // discard: waited longer than MaxAge
|
||||
)
|
||||
|
||||
// decide wendet die Gesetze auf ein fälliges Item an (rein, ohne Zustand):
|
||||
// slotFree sagt, ob der Rate-Limit-Slot gerade frei ist. Rangordnung:
|
||||
// decide applies the laws to a due item (pure, no state): slotFree says
|
||||
// whether the rate-limit slot is open right now. Precedence:
|
||||
//
|
||||
// 1. now:true bricht bewusst aus — überspringt Rate-Limit UND Ruhezeit
|
||||
// (dokumentierter Ausbruch für Dringendes; startet trotzdem ein neues
|
||||
// Rate-Fenster).
|
||||
// 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.
|
||||
// 4. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low
|
||||
// wird verworfen; normal (und high ohne Recht) parken bis zum Slot.
|
||||
// 1. now:true deliberately breaks out: it skips rate limit AND quiet hours
|
||||
// (the documented escape for urgent matters; it still books a new rate
|
||||
// window).
|
||||
// 2. Stale: an item that waited longer than MaxAge is discarded; an
|
||||
// announcement from five minutes ago is no longer news.
|
||||
// 3. Quiet hours: discard drops, toast replaces; this applies to high too,
|
||||
// because burst is a rate right, not a quiet right.
|
||||
// 4. Rate limit: an open slot plays; high plays with burst rights; low is
|
||||
// discarded; normal (and high without rights) park until the slot.
|
||||
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
|
||||
if item.Now {
|
||||
return verdictPlay
|
||||
@@ -157,42 +153,43 @@ func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
|
||||
return verdictWait
|
||||
}
|
||||
|
||||
// Admission ist das Ergebnis von Enqueue: Aufgenommene tragen ihre ID,
|
||||
// verworfene nennen den Grund (die 202-Antwort von POST /speak führt ihn).
|
||||
// Admission is the result of Enqueue: admitted items carry their ID,
|
||||
// discarded ones name the reason (the 202 response of POST /speak carries
|
||||
// it).
|
||||
type Admission struct {
|
||||
// ID der aufgenommenen Ansage (leer, wenn verworfen).
|
||||
// ID of the admitted announcement (empty when discarded).
|
||||
ID string
|
||||
|
||||
// Discarded ist der Verwurfs-Grund (DiscardRateLimit bzw.
|
||||
// DiscardQuietHours); leer, wenn die Ansage aufgenommen wurde.
|
||||
// Discarded is the discard reason (DiscardRateLimit or
|
||||
// DiscardQuietHours); empty when the announcement was admitted.
|
||||
Discarded string
|
||||
|
||||
// Toast meldet, dass die Ansage in der Ruhezeit durch eine
|
||||
// Benachrichtigung ersetzt wurde (quiet_mode = "toast").
|
||||
// Toast reports that the announcement was replaced by a notification
|
||||
// during quiet hours (quiet_mode = "toast").
|
||||
Toast bool
|
||||
|
||||
// Truncated meldet, dass der Text auf MaxText gekürzt wurde (die
|
||||
// gekürzte Fassung ist aufgenommen).
|
||||
// Truncated reports that the text was cut to MaxText (the shortened
|
||||
// version was admitted).
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// LawState ist die /status-Sicht auf die Anti-Spam-Gesetze: konfiguriertes
|
||||
// Rate-Limit mit dem nächsten freien Slot sowie das Ruhezeit-Fenster.
|
||||
// LawState is the /status view of the laws: the configured rate limit with
|
||||
// the next open slot, the quiet window, and the length and age limits.
|
||||
type LawState struct {
|
||||
RateLimit time.Duration
|
||||
AllowHighBurst bool
|
||||
NextSlot time.Time // Nullzeit = Slot frei (oder unbegrenzt)
|
||||
NextSlot time.Time // zero = slot open (or unlimited)
|
||||
Quiet QuietHours
|
||||
QuietActive bool
|
||||
QuietMode string
|
||||
QuietUntil time.Time // Nullzeit = Ruhezeit inaktiv
|
||||
MaxText int // 0 = unbegrenzt
|
||||
MaxAge time.Duration // 0 = unbegrenzt
|
||||
QuietUntil time.Time // zero = quiet hours inactive
|
||||
MaxText int // 0 = unlimited
|
||||
MaxAge time.Duration // 0 = unlimited
|
||||
}
|
||||
|
||||
// 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.
|
||||
// truncateText cuts s to at most max runes, preferably at a word boundary
|
||||
// (unless that lies in the first half), and reports whether it cut.
|
||||
// max <= 0 = unlimited.
|
||||
func truncateText(s string, max int) (string, bool) {
|
||||
if max <= 0 {
|
||||
return s, false
|
||||
|
||||
Reference in new issue
Block a user