采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染; 复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。 消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content; inbound.go 的丢空判据放宽为「无文本且无可用媒体」。 微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。 降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位; errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复); 无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。 独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。 kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。 设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。 变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。 诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz; 官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
161 lines
5.9 KiB
Go
161 lines
5.9 KiB
Go
package voice
|
||
|
||
// 本文件锁定**一次链上 STT 的诚实报告**(切片 IM-ASR):`STTOutcome` 必须把静默档位
|
||
// 掩盖的真实原因带出来,`Transcribed()` 必须把「真转写(文本可能为空)」与「静默降级/
|
||
// 未配置」分开,`CircuitWorthy` 只对「再试也是白试」的类别为真。
|
||
//
|
||
// 这些断言直接对应桥接器的熔断与诚实提示:判错了就会出现「每条语音都撞一次付费墙」
|
||
// 或「一条被拒的语音关掉整个转写面」。
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"testing"
|
||
)
|
||
|
||
func TestSTTOutcomeReportsRealTranscription(t *testing.T) {
|
||
t.Parallel()
|
||
chain := NewChain(&stubProvider{name: "cloud", stt: &stubSTT{text: "你好"}})
|
||
|
||
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
|
||
|
||
if !out.Transcribed() {
|
||
t.Fatalf("Transcribed() = false, want true(provider=%q cause=%v)", out.Provider, out.Cause)
|
||
}
|
||
if out.Text != "你好" || out.Provider != "cloud" || out.Cause != nil {
|
||
t.Fatalf("outcome = %+v, want 你好/cloud/nil", out)
|
||
}
|
||
}
|
||
|
||
// TestSTTOutcomeEmptyTextFromRealProviderIsNotFailure 钉住一个容易搞反的区分:云端
|
||
// 转写成功但**没识别出文字**(静音/噪声)不是故障 —— 它绝不熔断、也不该说「服务挂了」。
|
||
func TestSTTOutcomeEmptyTextFromRealProviderIsNotFailure(t *testing.T) {
|
||
t.Parallel()
|
||
chain := NewChain(&stubProvider{name: "cloud", stt: &stubSTT{text: ""}})
|
||
|
||
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
|
||
|
||
if !out.Transcribed() {
|
||
t.Fatalf("Transcribed() = false, want true(空文本但服务正常): %+v", out)
|
||
}
|
||
if out.Provider != "cloud" || out.Cause != nil {
|
||
t.Fatalf("outcome = %+v, want cloud/nil", out)
|
||
}
|
||
if CircuitWorthy(out.Cause) {
|
||
t.Fatal("空文本被判成「再试也是白试」,这会误开熔断")
|
||
}
|
||
}
|
||
|
||
// TestSTTOutcomeRevealsSilentDegradation 是本切片的核心断言:静默档位返回「空文本 +
|
||
// nil 错」,因此只看文本会把「没有可用的识别后端」当成功 —— `Cause` 必须把它揭穿。
|
||
func TestSTTOutcomeRevealsSilentDegradation(t *testing.T) {
|
||
t.Parallel()
|
||
chain := NewChain(
|
||
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrAuthFailed}},
|
||
SilentProvider(),
|
||
)
|
||
|
||
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
|
||
|
||
if out.Transcribed() {
|
||
t.Fatalf("静默降级被判成真转写:%+v", out)
|
||
}
|
||
if out.Provider != SilentProviderName {
|
||
t.Fatalf("provider = %q, want %q", out.Provider, SilentProviderName)
|
||
}
|
||
if !errors.Is(out.Cause, ErrAuthFailed) {
|
||
t.Fatalf("cause = %v, want ErrAuthFailed(被静默档位掩盖的真实原因必须带出来)", out.Cause)
|
||
}
|
||
if !CircuitWorthy(out.Cause) {
|
||
t.Fatal("鉴权失败必须可熔断(否则每条语音都撞一次付费墙)")
|
||
}
|
||
}
|
||
|
||
// TestSTTOutcomeWithoutAnyStepReportsNotConfigured 覆盖「链上压根没有云端档位」:
|
||
// 静默档位成功但没有前车之鉴 ⇒ 归为「未配置」,同样可熔断。
|
||
func TestSTTOutcomeWithoutAnyStepReportsNotConfigured(t *testing.T) {
|
||
t.Parallel()
|
||
out := NewChain(SilentProvider()).STTOutcome(t.Context(), Audio{Format: "ogg"})
|
||
|
||
if out.Transcribed() || !errors.Is(out.Cause, ErrNotConfigured) {
|
||
t.Fatalf("outcome = %+v, want 未配置(不可当真转写)", out)
|
||
}
|
||
if !CircuitWorthy(out.Cause) {
|
||
t.Fatal("未配置必须可熔断(否则每条语音都白跑一趟)")
|
||
}
|
||
}
|
||
|
||
func TestSTTOutcomeEmptyChain(t *testing.T) {
|
||
t.Parallel()
|
||
out := NewChain().STTOutcome(t.Context(), Audio{Format: "ogg"})
|
||
|
||
if out.Transcribed() || !errors.Is(out.Cause, ErrProviderFailed) {
|
||
t.Fatalf("outcome = %+v, want ErrProviderFailed", out)
|
||
}
|
||
}
|
||
|
||
// TestSTTOutcomeKeepsProviderOrder 钉住它仍走既有的降级顺序(不因为新增报告面而改变
|
||
// 「先云端、后本地」的语义)。
|
||
func TestSTTOutcomeKeepsProviderOrder(t *testing.T) {
|
||
t.Parallel()
|
||
chain := NewChain(
|
||
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrRateLimited}},
|
||
&stubProvider{name: "local", stt: &stubSTT{text: "本地转写"}},
|
||
SilentProvider(),
|
||
)
|
||
|
||
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg"})
|
||
|
||
if !out.Transcribed() || out.Text != "本地转写" || out.Provider != "local" {
|
||
t.Fatalf("outcome = %+v, want 落到 local", out)
|
||
}
|
||
}
|
||
|
||
// TestCircuitWorthyClassification 钉住分类口径:只有「账户/配额/权限层面」的失败为真;
|
||
// 按内容发生的失败(格式不支持、解码失败)为假 —— 后者若熔断,一条被拒的语音会让
|
||
// 后续语音全部降级。
|
||
func TestCircuitWorthyClassification(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
want bool
|
||
}{
|
||
{"nil", nil, false},
|
||
{"未配置", ErrNotConfigured, true},
|
||
{"鉴权失败", ErrAuthFailed, true},
|
||
{"欠费/额度", ErrQuotaExceeded, true},
|
||
{"限流", ErrRateLimited, true},
|
||
{"包装后的鉴权失败", fmt.Errorf("voice: 上游: %w", ErrAuthFailed), true},
|
||
{"普通上游失败", ErrProviderFailed, false},
|
||
{"格式不支持", fmt.Errorf("voice: %w: unsupported format", ErrProviderFailed), false},
|
||
{"未知错误", errors.New("boom"), false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if got := CircuitWorthy(tc.err); got != tc.want {
|
||
t.Fatalf("CircuitWorthy(%v) = %v, want %v", tc.err, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSTTOutcomeErrorStringsAreClassified 钉住鉴权/限流/欠费的错误串里同时带着
|
||
// 分类哨兵与上游细节(既能让调用方分类,也让日志能说清是哪一次失败)。
|
||
func TestSTTOutcomeErrorStringsAreClassified(t *testing.T) {
|
||
t.Parallel()
|
||
for name, err := range map[string]error{
|
||
"auth": ErrAuthFailed,
|
||
"quota": ErrQuotaExceeded,
|
||
"rate": ErrRateLimited,
|
||
} {
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if !errors.Is(err, ErrProviderFailed) {
|
||
t.Fatalf("分类哨兵 %v 必须以 ErrProviderFailed 为底(既有 errors.Is 判定不得变化)", err)
|
||
}
|
||
})
|
||
}
|
||
}
|