178 lines
10 KiB
Diff
178 lines
10 KiB
Diff
# 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 }
|
|||
|
|
|