feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
采集面: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),对齐留待下次改动。
This commit is contained in:
1 parent
679cee04f7
commit
a918819878
20 files changed
+1685
-61
No files matched your search
@@ -395,11 +395,47 @@ func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string,
|
||||
return nil, fmt.Errorf("voice: %w: read: %v", ErrProviderFailed, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return nil, classifyDashscopeStatus(resp.StatusCode, body)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// classifyDashscopeStatus 把一个非 200 的 DashScope 响应翻成**可分类**的失败:鉴权失败 /
|
||||
// 欠费额度 / 限流各自成一条哨兵链(都以 ErrProviderFailed 为底),其余原样归入
|
||||
// ErrProviderFailed(切片 IM-ASR 的熔断据此判定「再试是不是白试」)。
|
||||
//
|
||||
// 判据只用**状态码 + 错误码/消息里的两类关键字**(阿里云官方口径:`Arrearage`、
|
||||
// `Throttling.*`、额度上限),绝不匹配自由文本描述——把「格式不支持」误判成「欠费」
|
||||
// 会让一条被拒的语音关掉整个转写面。
|
||||
func classifyDashscopeStatus(status int, body []byte) error {
|
||||
detail := strings.TrimSpace(string(body))
|
||||
if err := dashscopeClassifiedError(status, detail); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, status, detail)
|
||||
}
|
||||
|
||||
// dashscopeClassifiedError 给出状态码对应的**分类**失败,无法分类时返回 nil。
|
||||
func dashscopeClassifiedError(status int, detail string) error {
|
||||
switch {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrAuthFailed, status, detail)
|
||||
case status == http.StatusTooManyRequests:
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrRateLimited, status, detail)
|
||||
}
|
||||
// 欠费/额度耗尽:阿里云用 HTTP 400(少数情况 402)配上 Arrearage /
|
||||
// AllocatedQuotaExceeded 这类错误码,因此按错误码关键字归类(不按状态码)。
|
||||
lowered := strings.ToLower(detail)
|
||||
if strings.Contains(lowered, "arrearage") || strings.Contains(lowered, "quota") ||
|
||||
strings.Contains(lowered, "throttling") {
|
||||
if strings.Contains(lowered, "throttling") {
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrRateLimited, status, detail)
|
||||
}
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrQuotaExceeded, status, detail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractSTTText 从 DashScope ASR 响应中取出转写。ASR
|
||||
// 的输出形状随模型而异,因此按多个候选路径依次尝试。
|
||||
func extractSTTText(body []byte) (string, error) {
|
||||
@@ -427,14 +463,27 @@ func extractSTTText(body []byte) (string, error) {
|
||||
}
|
||||
|
||||
// mimeOf 把音频格式映射为其 MIME 类型,供 data: URI / Content-Type 使用。
|
||||
//
|
||||
// 覆盖 IM 入站语音放行的全部格式(`mediafetch` 的音频白名单):data: URI 的 MIME 是
|
||||
// 上游唯一能看到的类型线索,把它写错(例如把 AMR 写成 audio/wav)本身就是一种假话。
|
||||
// ⚠️ 未确证:DashScope 对 AMR/SILK 等格式的接受度、以及 `parameters.sample_rate` 对
|
||||
// 压缩容器是否有影响,官方「音频规格」页尚未抓到(design/06 §1.2 已标注)。
|
||||
func mimeOf(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "mp3", "mpeg":
|
||||
return "audio/mpeg"
|
||||
case "ogg", "oga":
|
||||
case "ogg", "oga", "opus":
|
||||
return "audio/ogg"
|
||||
case "webm":
|
||||
return "audio/webm"
|
||||
case "m4a", "mp4":
|
||||
return "audio/mp4"
|
||||
case "aac":
|
||||
return "audio/aac"
|
||||
case "amr":
|
||||
return "audio/amr"
|
||||
case "flac":
|
||||
return "audio/flac"
|
||||
default:
|
||||
return "audio/wav"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package voice
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// voice 包返回的哨兵错误。每个错误都以 %w 包装,
|
||||
// 使调用方能按类别判断,同时保留原始原因。
|
||||
@@ -15,4 +18,41 @@ var (
|
||||
ErrEmptyText = errors.New("voice: empty text")
|
||||
// ErrClosed 在 Close 之后仍有服务调用到达时返回。
|
||||
ErrClosed = errors.New("voice: service closed")
|
||||
|
||||
// 下面三条是**可分类的上游失败**(切片 IM-ASR 的熔断需要)。它们各自以
|
||||
// ErrProviderFailed 为底(`%w`),因此既有的 `errors.Is(err, ErrProviderFailed)`
|
||||
// 判定逐字不变,而需要「这条失败再试也是白试吗」的调用方可以再细分一层。
|
||||
//
|
||||
// 分类依据是 DashScope 的 HTTP 状态码与错误码(官方口径),**绝不**匹配自由文本
|
||||
// 描述:把「格式不支持」误判成「欠费」会让一条被拒的语音关掉整个转写面。
|
||||
//
|
||||
// 归入这三类的失败打开**临时熔断**(未配置同理,见 CircuitWorthy):它们是
|
||||
// 「账户/配额/权限」层面的问题,逐条重试只会白等超时并反复撞付费墙。
|
||||
//
|
||||
// ErrAuthFailed 报告云端鉴权失败(HTTP 401/403,例如 API key 无效或被吊销)。
|
||||
ErrAuthFailed = fmt.Errorf("%w: 云端鉴权失败(API key 被拒)", ErrProviderFailed)
|
||||
// ErrQuotaExceeded 报告云端欠费或额度耗尽(例如阿里云的 Arrearage /
|
||||
// AllocatedQuotaExceeded)。
|
||||
ErrQuotaExceeded = fmt.Errorf("%w: 云端账户欠费或额度耗尽", ErrProviderFailed)
|
||||
// ErrRateLimited 报告云端限流(HTTP 429 / Throttling.*)。
|
||||
ErrRateLimited = fmt.Errorf("%w: 云端限流", ErrProviderFailed)
|
||||
)
|
||||
|
||||
// CircuitWorthy 报告一次失败是否属于「**再试也是白试**」的类别:未配置 / 鉴权失败 /
|
||||
// 欠费 / 限流。只有这些才值得熔断退避。
|
||||
//
|
||||
// 「这份音频格式不支持」「解码失败」「音频为空」这类**按内容发生**的失败返回 false:
|
||||
// 它们不该关掉整个转写面(否则一条被拒的语音会让后续语音全部降级)。判据只认哨兵链。
|
||||
func CircuitWorthy(err error) bool {
|
||||
switch {
|
||||
case err == nil:
|
||||
return false
|
||||
case errors.Is(err, ErrNotConfigured),
|
||||
errors.Is(err, ErrAuthFailed),
|
||||
errors.Is(err, ErrQuotaExceeded),
|
||||
errors.Is(err, ErrRateLimited):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package voice
|
||||
|
||||
import "context"
|
||||
|
||||
// 本文件承载一次链上 STT 的**完整诚实结果**(切片 IM-ASR)。既有的
|
||||
// `STTWithProvider` 只报告「谁最后成功了」,而链路末端是**总是成功**的静默档位
|
||||
// (空文本 + `err=nil`)——真实失败原因因此被吞掉。IM 入站语音要拿它做两件事:
|
||||
//
|
||||
// 1. **识破静默降级**:静默档位返回的「成功」不是转写成功,绝不能当成功;
|
||||
// 2. **按原因分类**:未配置 / 鉴权失败 / 欠费 / 限流才值得**临时熔断**——否则每条
|
||||
// 语音都会去撞一次付费墙、白等一次超时。
|
||||
//
|
||||
// 这里只**新增**报告面:STT/TTS 的既有语义与 `STTWithProvider` 的返回逐字不变,
|
||||
// 既有调用方(WS 的 voice_input、`/audio/transcribe`)无需改动。
|
||||
|
||||
// STTOutcome 是一次链上 STT 的结果。
|
||||
type STTOutcome struct {
|
||||
// Text 是转写文本;空串表示这一趟没有拿到文字。
|
||||
Text string
|
||||
// Provider 是产出该结果的档位名(cloud / local / edge-tts / espeak /
|
||||
// SilentProviderName)。空串表示链上没有任何 STT 方向可用。
|
||||
Provider string
|
||||
// Cause 是**静默档位掩盖的真实原因**:非 nil 表示这一趟**没有真的转写成功**
|
||||
// (例如云端 401 / 欠费 / 限流,或整个链路未配置)。nil 且 Provider 不是静默档位
|
||||
// 才是真转写——此时 Text 仍可能为空(音频里没人说话),那是**服务正常**的空结果。
|
||||
Cause error
|
||||
}
|
||||
|
||||
// Transcribed 报告这一趟是不是**真转写**(不是静默降级掩盖的成功)。文本本身可能为空
|
||||
// (静音/噪声),那属于「服务正常但没识别出文字」,与「上游不可用」必须分开。
|
||||
func (o STTOutcome) Transcribed() bool {
|
||||
return o.Cause == nil && o.Provider != "" && o.Provider != SilentProviderName
|
||||
}
|
||||
|
||||
// STTOutcome 在链上做一次 STT,并如实报告结果来源与(静默档位掩盖的)真实原因。
|
||||
// 它与 STTWithProvider 走同一条降级顺序,只是**不把静默档位当成功**。
|
||||
func (c *Chain) STTOutcome(ctx context.Context, audio Audio) STTOutcome {
|
||||
if len(c.steps) == 0 {
|
||||
return STTOutcome{Cause: ErrProviderFailed}
|
||||
}
|
||||
var lastErr error
|
||||
for _, p := range c.steps {
|
||||
stt := p.STT()
|
||||
if stt == nil {
|
||||
continue
|
||||
}
|
||||
text, err := stt.STT(ctx, audio)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
out := STTOutcome{Text: text, Provider: p.Name()}
|
||||
if p.Name() != SilentProviderName {
|
||||
return out
|
||||
}
|
||||
// 静默档位:它「成功」只说明链路没崩,真实原因看它前面那一级。
|
||||
out.Cause = silenceCause(lastErr)
|
||||
return out
|
||||
}
|
||||
return STTOutcome{Cause: silenceCause(lastErr)}
|
||||
}
|
||||
|
||||
// silenceCause 把「静默档位为什么成功」翻译成一个可分类的原因:链上确实有过失败时
|
||||
// 用那一级的原因;一次失败都没有(链上压根没有云端档位,或它被跳过)就是未配置。
|
||||
func silenceCause(lastErr error) error {
|
||||
if lastErr == nil {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -60,10 +60,15 @@ func EspeakProvider(tts TTS) *StaticProvider {
|
||||
return NewProvider("espeak", nil, tts)
|
||||
}
|
||||
|
||||
// SilentProviderName 是末端静默档位(SilentProvider)的稳定标识。调用方靠它**识破
|
||||
// 静默降级**:该档位总是「成功」(空文本 + 无错误),因此只看转写文本会把「没有可用
|
||||
// 的识别后端」当成一次成功的空转写(切片 IM-ASR)。
|
||||
const SilentProviderName = "silent"
|
||||
|
||||
// SilentProvider 是末端一级。它总是成功,返回空
|
||||
// 转写与空音频,因此包含它的链永不报错。
|
||||
func SilentProvider() *StaticProvider {
|
||||
return NewProvider("silent", silenceSTT{}, silenceTTS{})
|
||||
return NewProvider(SilentProviderName, silenceSTT{}, silenceTTS{})
|
||||
}
|
||||
|
||||
// silenceSTT 是静默 STT 一级;它返回空转写。
|
||||
|
||||
Reference in new issue
Block a user