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

178 lines
10 KiB
Diff
Raw Normal View History

# 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 }