Files
talk/.superpowers/sdd/plan/review-8351d40..3b44605.diff
T

179 lines
10 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Review package: 8351d40..HEAD
## Commits
3b44605 fix(provider): Chain.Speak läuft im Direkt-Endgame nur einmal durch die Kette
## Files changed
internal/provider/chain.go | 39 +++++++++++++++++++-----------
internal/provider/chain_test.go | 53 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 78 insertions(+), 14 deletions(-)
## Diff
internal/provider/chain.go --- 1/2 --- Go
205 } 205 }
206 return nil, fmt.Errorf("%w 206 return nil, fmt.Errorf("%w
... (Stufe %s spricht nur direkt)", Err ... (Stufe %s spricht nur direkt)", Err
... NoByteOutput, r.directName) ... NoByteOutput, r.directName)
207 } 207 }
208 return nil, fmt.Errorf("kette: 208 return nil, r.noTierErr()
... keine Stufe konnte sprechen (nicht ...
... verfügbar: %s; fehlgeschlagen: %s)" ...
... , ...
209 joinOr(r.skipped, "keine"), ...
... joinOr(r.failed, "keine")) ...
210 } 209 }
211 210
212 // SpeakDirect implementiert Direct 211 // SpeakDirect implementiert Direct
... Speaker: Direktansage über die Stuf ... Speaker: Direktansage über die Stuf
... e, ... e,
internal/provider/chain.go --- 2/2 --- Go
225 joinOr(r.skipped, "keine"), 224 joinOr(r.skipped, "keine"),
... joinOr(r.failed, "keine")) ... joinOr(r.failed, "keine"))
226 } 225 }
227 226
228 // Speak ist die komplette Ansage: 227 // Speak ist die komplette Ansage i
... Synthese mit Fallback → Wiedergabe ... n EINEM Ketten-Durchlauf: Synthese
... über ... mit
229 // den Player; fällt die Kette auf 228 // Fallback → Wiedergabe über den P
... die Direkt-Stufe zurück, entscheide ... layer; fällt die Kette auf die Dire
... t der ... kt-
230 // Player-Name über Ton (alles auße 229 // Stufe zurück, entscheidet der Pl
... r „null") oder tonloses Verwerfen ( ... ayer-Name über Ton (alles außer „nu
... null: ... ll")
231 // CI/Server bleiben fehler- und to 230 // oder tonloses Verwerfen (null: C
... nfrei, der Verwurf wird protokollie ... I/Server bleiben fehler- und tonfre
... rt). ... i,
... 231 // der Verwurf wird protokolliert).
... ... Der Durchlauf findet bewusst genau
... 232 // einmal statt: fehlgeschlagene Cl
... ... oud-Stufen werden nicht ein zweites
... ... Mal
... 233 // angefragt (keine doppelten POSTs
... ... auf bezahlten APIs, keine doppelte
... ... n
... 234 // Protokoll-Zeilen), und Bytes aus
... ... diesem Durchlauf werden abgespielt
... ... ,
... 235 // statt sie wegzuwerfen und neu zu
... ... laufen. Nur ein Fehler aller Stufe
... ... n
232 // Nur ein Fehler aller Stufen oder 236 // oder eine fehlgeschlagene Wieder
... eine fehlgeschlagene Wiedergabe si ... gabe sind Fehler.
... nd ...
233 // Fehler. ...
234 func (c *Chain) Speak(ctx context.C 237 func (c *Chain) Speak(ctx context.C
... ontext, text, lang string) error { ... ontext, text, lang string) error {
235 data, err := c.Synthesize(ctx, 238 r := c.walk(ctx, text, lang)
... text, lang) ...
236 switch { 239 switch {
237 case err == nil: 240 case r.data != nil:
238 if c.cfg.Player == nil { 241 if c.cfg.Player == nil {
239 return fmt.Errorf("kett 242 return fmt.Errorf("kett
... e: kein Player konfiguriert — Bytes ... e: kein Player konfiguriert — Bytes
... können nicht abgespielt werden") ... können nicht abgespielt werden")
240 } 243 }
241 if err := c.cfg.Player.Play 244 if err := c.cfg.Player.Play
... (ctx, data); err != nil { ... (ctx, r.data); err != nil {
242 return fmt.Errorf("kett 245 return fmt.Errorf("kett
... e: Wiedergabe über %s fehlgeschlage ... e: Wiedergabe über %s fehlgeschlage
... n: %w", c.cfg.Player.Name(), err) ... n: %w", c.cfg.Player.Name(), err)
243 } 246 }
244 return nil 247 return nil
245 case errors.Is(err, ErrNoByteOu 248 case r.direct != nil:
... tput): ...
246 if c.cfg.Player != nil && c 249 if c.cfg.Player != nil && c
... .cfg.Player.Name() == NullPlayerNam ... .cfg.Player.Name() == NullPlayerNam
... e { ... e {
247 chainLogf(c.cfg, "kette 250 chainLogf(c.cfg, "kette
... : Direktansage verworfen (audio.sin ... : Direktansage verworfen (audio.sin
... k=null)\n") ... k=null)\n")
248 return nil 251 return nil
249 } 252 }
250 return c.SpeakDirect(ctx, t 253 return r.direct.SpeakDirect
... ext, lang) ... (ctx, text, lang)
251 default: 254 default:
252 return err 255 return r.noTierErr()
253 } 256 }
254 } 257 }
... 258
... 259 // noTierErr bündelt den Ende-ohne-
... ... Ergebnis-Fehler eines Durchlaufs: k
... ... eine
... 260 // Stufe lieferte Bytes oder Direkt
... ... ansage — übersprungene (nicht verfü
... ... gbar)
... 261 // und fehlgeschlagene Stufen werde
... ... n genannt, nie still.
... 262 func (r walkResult) noTierErr() err
... ... or {
... 263 return fmt.Errorf("kette: keine
... ... Stufe konnte sprechen (nicht verfü
... ... gbar: %s; fehlgeschlagen: %s)",
... 264 joinOr(r.skipped, "keine"),
... ... joinOr(r.failed, "keine"))
... 265 }
255 266
256 // chainLogf schreibt eine Protokol 267 // chainLogf schreibt eine Protokol
... l-Zeile, wenn ein Log gesetzt ist. ... l-Zeile, wenn ein Log gesetzt ist.
257 func chainLogf(cfg ChainConfig, for 268 func chainLogf(cfg ChainConfig, for
... mat string, args ...any) { ... mat string, args ...any) {
internal/provider/chain_test.go --- Go
341 341 }
342 342 }
343
344 // TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker-
345 // Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe
346 // wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs,
347 // keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag
348 // genau einmal, und die Direktansage kommt zustande.
349 func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
350 eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
351 // elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key →
352 // übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit
353 // fake spd-say (kein espeak) → keine Bytes, Direktansage.
354 dir := t.TempDir()
355 seen := filepath.Join(dir, "seen.txt")
356 fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
357 t.Setenv("PATH", dir)
358
359 var log bytes.Buffer
360 ch, err := NewChain(ChainConfig{
361 Getenv: chainEnv(map[string]string{
362 EnvElevenLabsKey: "k",
363 EnvElevenLabsEndpoint: eleven,
364 EnvOpenAIEndpoint: eleven, // ohne Key ohnehin übersprungen
365 EnvEdgeEndpoint: "ws://127.0.0.1:1/",
366 }),
367 Player: &fakePlayer{name: "oto"},
368 Log: &log,
369 })
370 if err != nil {
371 t.Fatalf("NewChain: %v", err)
372 }
373
374 if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
375 t.Fatalf("Speak (Direkt-Endgame): %v", err)
376 }
377 if got := elHits.Load(); got != 1 {
378 t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got)
379 }
380 for _, tier := range []string{"elevenlabs", "edge"} {
381 if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 {
382 t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String())
383 }
384 }
385 if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 {
386 t.Errorf("openai-Skip steht %d× im Log, want genau 1", got)
387 }
388 args, err := os.ReadFile(seen)
389 if err != nil {
390 t.Fatalf("fake spd-say nicht gerufen: %v", err)
391 }
392 if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
393 t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
394 }
395 }