Files
AskaEth ede00d9928 feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00

465 lines
18 KiB
Go
Raw Permalink 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.
package channelbridge
// 本文件锁定**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
//
// - 转写结果写回 `Content` ⇒ 落库与模型可见消息看到的都是**同一份文本**;
// - **平台自带转写优先**:Content 已有真实文本(微信 voice_item.text)⇒ 一次 ASR 都
// 不调;QQ 的 `[语音]` 占位标记不算文字 ⇒ 照样转写;
// - **静默降级被识破**:`silent` 档的「成功」被当失败处理;
// - **临时熔断**:可分类失败(鉴权/欠费/限流/未配置)之后不再调上游,但消息面仍有
// 一行诚实提示;TTL 到期或一次成功即恢复;
// - 拿不到字节 / 格式不支持 / 超时 / 空识别结果都**诚实提示**,绝不静默、绝不留空;
// - 纯语音消息不再被丢空判据丢掉。
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// providerFailureForTest 构造一个「按内容发生」的上游失败(格式不支持一类):它只带
// `ErrProviderFailed`、不带任何可熔断的分类哨兵 —— 用它断言熔断的**分类**是准的。
func providerFailureForTest() error {
return fmt.Errorf("voice: %w: unsupported format", voice.ErrProviderFailed)
}
// fakeVoiceSTT 是可编程的假 STT:记录调用次数与每次的音频(格式+字节),可返回任意
// STTOutcome,也可阻塞到 ctx 结束(超时用例)。
type fakeVoiceSTT struct {
mu sync.Mutex
calls int
seen []voice.Audio
reply voice.STTOutcome
block bool
}
func (f *fakeVoiceSTT) STTOutcome(ctx context.Context, audio voice.Audio) voice.STTOutcome {
f.mu.Lock()
f.calls++
f.seen = append(f.seen, audio)
reply, block := f.reply, f.block
f.mu.Unlock()
if block {
<-ctx.Done()
return voice.STTOutcome{Provider: voice.SilentProviderName, Cause: ctx.Err()}
}
return reply
}
func (f *fakeVoiceSTT) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}
func (f *fakeVoiceSTT) lastAudio(t *testing.T) voice.Audio {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if len(f.seen) == 0 {
t.Fatal("STT 从未被调用")
}
return f.seen[len(f.seen)-1]
}
func (f *fakeVoiceSTT) set(out voice.STTOutcome) {
f.mu.Lock()
defer f.mu.Unlock()
f.reply = out
}
// fakeClock 是可推进的墙上时钟(熔断的 TTL 断言必须确定性)。
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// voiceEnv 是入站语音用例的装配:真循环(装配期)+ 假模型 + 记录落库的历史 + 假 STT +
// 可推进时钟。
type voiceEnv struct {
bridge *Bridge
model *stubModelAdapter
history *captureHistory
stt *fakeVoiceSTT
clock *fakeClock
logs *safeBuffer
}
func newVoiceEnv(t *testing.T, stt VoiceSTT, timeout time.Duration) *voiceEnv {
t.Helper()
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
modelAdapter := &stubModelAdapter{}
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
runner := loop.New(loop.Options{Agent: ag, MaxRounds: 4, Log: log})
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
hist := &captureHistory{}
clock := &fakeClock{now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)}
b, err := New(Options{
Log: log, Loop: runner, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
History: hist, VoiceSTT: stt, VoiceSTTTimeout: timeout, Now: clock.Now,
})
if err != nil {
t.Fatalf("New: %v", err)
}
platform := &fakeAdapter{name: "telegram"}
b.Add(platform)
return &voiceEnv{bridge: b, model: modelAdapter, history: hist, clock: clock, logs: logs}
}
// voiceMessage 构造一条主人私聊的**纯语音**消息(无正文 + 已内联字节)。
func voiceMessage(name, mime string, data []byte) adapter.UnifiedMessage {
msg := directMessage()
msg.Content = ""
msg.ContentType = adapter.ContentVoice
msg.Media = []adapter.Media{{
Kind: adapter.MediaVoice, Name: name, MIMEType: mime, Size: int64(len(data)), Data: data,
}}
return msg
}
// lastUserContent 返回最近一次落库的用户消息正文(没有则空串)。
func lastUserContent(env *voiceEnv) string {
content := ""
for _, m := range env.history.records() {
if m.Role == history.RoleUser {
content = m.Content
}
}
return content
}
// TestInboundVoiceNormalizedIntoSingleVisibleText 覆盖验收:转写结果写回 Content ⇒
// 模型可见视图与落库的用户消息都是**同一份文本**(不是空行、也不是占位标记)。
func TestInboundVoiceNormalizedIntoSingleVisibleText(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: " 你好呀,昔涟 ", Provider: "cloud"}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 1 {
t.Fatalf("STT 调用次数 = %d, want 1", stt.callCount())
}
audio := stt.lastAudio(t)
if audio.Format != "ogg" || string(audio.Data) != "bytes" {
t.Fatalf("STT 收到的音频 = %+v, want ogg/bytes", audio)
}
if got := lastUserContent(env); got != "你好呀,昔涟" {
t.Fatalf("落库的用户正文 = %q, want 转写文本(去首尾空白)", got)
}
if visible := env.model.visibleText(); !strings.Contains(visible, "你好呀,昔涟") {
t.Fatalf("模型可见视图里没有转写文本:%q", visible)
}
if log := env.logs.String(); !strings.Contains(log, "inbound voice transcribed") ||
!strings.Contains(log, "source=services/channelbridge/voice") {
t.Fatalf("转写日志缺少子功能来源:%q", log)
}
}
// TestInboundVoicePlatformTextWinsOverASR 覆盖验收「平台文本优先(不调 ASR)」:微信
// 自带转写已经进了 Content ⇒ 一次上游调用都不能发生(不重复付费)。
func TestInboundVoicePlatformTextWinsOverASR(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "不该被用到"}}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("v.silk", "audio/silk", []byte("bytes"))
msg.Content = "你好" // 平台自带转写(weixin_ilink 的 voice_item.text)
env.bridge.process(context.Background(), msg)
if stt.callCount() != 0 {
t.Fatalf("STT 调用次数 = %d, want 0(平台已给文字,绝不重复付费)", stt.callCount())
}
if got := lastUserContent(env); got != "你好" {
t.Fatalf("落库的用户正文 = %q, want 平台自带转写", got)
}
}
// TestInboundVoicePlaceholderTextStillTranscribes 钉住反面:QQ 的 `[语音]` 是**占位
// 标记**、不是人说的话 ⇒ 照样转写,并把占位标记换成真实文本(否则纯语音整条丢掉)。
func TestInboundVoicePlaceholderTextStillTranscribes(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "语音里的内容", Provider: "cloud"}}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("v.amr", "audio/amr", []byte("bytes"))
msg.Content = "[语音]"
env.bridge.process(context.Background(), msg)
if stt.callCount() != 1 {
t.Fatalf("STT 调用次数 = %d, want 1(占位标记不算文字)", stt.callCount())
}
if got := lastUserContent(env); got != "语音里的内容" {
t.Fatalf("落库的用户正文 = %q, want 转写文本", got)
}
}
// TestInboundVoiceSilentDegradationIsFailure 覆盖验收「静默降级被识破」:链路末端
// `silent` 档返回空文本 + 无错误 —— 它必须被当**失败**处理(落一行诚实提示),
// 绝不能当成一次成功的空转写(那样消息就静默了)。
func TestInboundVoiceSilentDegradationIsFailure(t *testing.T) {
t.Parallel()
t.Run("静默档位带原因", func(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrNotConfigured,
}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
got := lastUserContent(env)
if !strings.Contains(got, "没有转写") || !strings.Contains(got, "未配置") {
t.Fatalf("落库正文 = %q, want 一行讲清原因的诚实提示", got)
}
if log := env.logs.String(); !strings.Contains(log, "category=not_configured") {
t.Fatalf("日志缺少可分类的原因:%q", log)
}
})
// 「只信文本 / 只信 err == nil」的实现会在这里静默:空文本 + 无错误看着像一次
// 成功的空转写,但档位是静默档 ⇒ 必须仍按失败处理(这就是本切片的硬要求)。
t.Run("静默档位无原因", func(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "", Provider: voice.SilentProviderName}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
got := lastUserContent(env)
if !strings.Contains(got, "没有转写") {
t.Fatalf("落库正文 = %q, want 静默降级的诚实提示(绝不留空消息)", got)
}
if log := env.logs.String(); !strings.Contains(log, "category=silent_degraded") {
t.Fatalf("日志缺少静默降级类别:%q", log)
}
})
}
// TestInboundVoiceEmptyResultIsHonestButNotFailure 区分「服务正常但没识别出文字」与
// 「上游不可用」:前者不熔断(provider 真转写,文本为空)。
func TestInboundVoiceEmptyResultIsHonestButNotFailure(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: " ", Provider: "cloud"}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if got := lastUserContent(env); !strings.Contains(got, "没有识别出文字") {
t.Fatalf("落库正文 = %q, want 空识别结果的诚实提示", got)
}
// 第二次仍要正常调上游(没有被误开的熔断挡住)。
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 2 {
t.Fatalf("STT 调用次数 = %d, want 2(空结果不是故障,不得熔断)", stt.callCount())
}
}
// TestInboundVoiceBreakerStopsUpstreamAfterClassifiedFailure 覆盖验收「熔断(连续失败后
// 不再调用上游 + 消息面仍有诚实提示)」:第一次鉴权失败 ⇒ 熔断;第二条语音**一次上游
// 调用都不发生**,但消息面照样落一行提示(绝不静默)。
func TestInboundVoiceBreakerStopsUpstreamAfterClassifiedFailure(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrAuthFailed,
}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
first := lastUserContent(env)
if !strings.Contains(first, "鉴权失败") {
t.Fatalf("第一条的落库正文 = %q, want 鉴权失败的原因方向", first)
}
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
second := lastUserContent(env)
if stt.callCount() != 1 {
t.Fatalf("STT 调用次数 = %d, want 1(熔断期间绝不调上游、不白等超时)", stt.callCount())
}
if !strings.Contains(second, "熔断") {
t.Fatalf("第二条的落库正文 = %q, want 熔断退避的诚实提示", second)
}
if strings.TrimSpace(second) == "" || strings.TrimSpace(first) == "" {
t.Fatal("熔断路径留下了空消息 —— 违反「绝不静默」")
}
}
// TestInboundVoiceBreakerRecoversOnTTLAndSuccess 钉住熔断是**临时**的:TTL 到期恢复调用,
// 一次成功把退避与计数清零(下一次立刻能再调)。
func TestInboundVoiceBreakerRecoversOnTTLAndSuccess(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrRateLimited,
}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 1 {
t.Fatalf("首次调用次数 = %d, want 1", stt.callCount())
}
// TTL 未到:短路。
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 1 {
t.Fatalf("熔断期间调用次数 = %d, want 1", stt.callCount())
}
// TTL 到期:恢复调用,且这次成功。
env.clock.advance(6 * time.Minute)
stt.set(voice.STTOutcome{Text: "恢复了", Provider: "cloud"})
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 2 {
t.Fatalf("TTL 到期后调用次数 = %d, want 2(熔断必须自动恢复)", stt.callCount())
}
if got := lastUserContent(env); got != "恢复了" {
t.Fatalf("落库正文 = %q, want 恢复后的转写文本", got)
}
// 成功即清零:紧接着的第三条必须还能调上游(不带退避)。
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 3 {
t.Fatalf("成功之后调用次数 = %d, want 3(成功必须清零熔断)", stt.callCount())
}
}
// TestInboundVoiceFormatRejectionDoesNotTripBreaker 钉住熔断的**分类**:按内容发生的
// 失败(上游说这份音频不行)不该关掉整个转写面 —— 后续语音必须还能被尝试。
func TestInboundVoiceFormatRejectionDoesNotTripBreaker(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
Text: "", Provider: voice.SilentProviderName,
Cause: providerFailureForTest(),
}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("bytes")))
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 2 {
t.Fatalf("STT 调用次数 = %d, want 2(按内容的失败不得熔断整个转写面)", stt.callCount())
}
if got := lastUserContent(env); strings.TrimSpace(got) == "" {
t.Fatal("失败路径留下了空消息")
}
}
// TestInboundVoiceTimeoutIsHonest 覆盖验收「超时诚实」:上游卡住 ⇒ 独立超时把它掐掉,
// 消息面落一行「超时」,绝不留空。
func TestInboundVoiceTimeoutIsHonest(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{block: true}
env := newVoiceEnv(t, stt, 30*time.Millisecond)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
got := lastUserContent(env)
if !strings.Contains(got, "超时") {
t.Fatalf("落库正文 = %q, want 超时的诚实提示", got)
}
if log := env.logs.String(); !strings.Contains(log, "category=timeout") {
t.Fatalf("日志缺少超时类别:%q", log)
}
}
// TestInboundVoiceWithoutBytesOrFormatIsHonest 覆盖两条「拿不到音频」的路径:没有字节
// (渠道只给平台内部引用 / 代取失败)与格式不在**可转写白名单**内 —— 都落诚实提示,
// 且**不调上游**。
//
// ⚠️ 文案留痕(2026-10-06,切片 CODE-VOICE):本用例原先钉的是「格式不支持(需要先转码,
// 当前未做)」——那句在转码能力(design/25)落地后**就是一句假话**(design/25 §5.4 明令
// 同步改)。因此这里改钉新口径:格式**不在可转写白名单内**。这不是「改测试迁就代码」,
// 而是被断言保护的行为**已按设计变更**(与 QQ `[语音]` 占位标记那条同一性质)。
func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
t.Parallel()
t.Run("没有字节", func(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("v.ogg", "audio/ogg", nil)
env.bridge.process(context.Background(), msg)
if stt.callCount() != 0 {
t.Fatalf("STT 调用次数 = %d, want 0(没有字节可转写)", stt.callCount())
}
if got := lastUserContent(env); !strings.Contains(got, "没有拿到音频字节") {
t.Fatalf("落库正文 = %q, want 字节缺失的诚实提示", got)
}
})
t.Run("格式不在可转写白名单内", func(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("v.silk", "audio/silk", []byte("bytes"))
env.bridge.process(context.Background(), msg)
if stt.callCount() != 0 {
t.Fatalf("STT 调用次数 = %d, want 0(格式不在白名单,不调上游)", stt.callCount())
}
got := lastUserContent(env)
if !strings.Contains(got, "不在服务端可转写的白名单内") {
t.Fatalf("落库正文 = %q, want 白名单外的诚实提示", got)
}
if strings.Contains(got, "当前未做") {
t.Fatalf("落库正文 = %q:转码能力已落地(design/25),这句「当前未做」是假话", got)
}
})
}
// TestInboundPureVoiceIsNotDropped 覆盖验收「inbound 放宽后不再丢纯语音」:无正文、
// 无字节的纯语音消息(telegram 的 file_id 取不到字节那种)也必须跑完流水线并留痕。
func TestInboundPureVoiceIsNotDropped(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("", "audio/ogg", nil)
env.bridge.process(context.Background(), msg)
if env.model.calls() == 0 {
t.Fatal("纯语音消息没有跑到模型(被丢空判据丢掉了)")
}
if got := lastUserContent(env); strings.TrimSpace(got) == "" {
t.Fatal("纯语音消息落库成空行 —— 违反「绝不静默」")
}
}
// TestInboundVoiceWithoutSTTWiringIsHonest 覆盖「未接线」:组合根没注入 STT 时,带语音的
// 消息仍要落一行「未配置」的诚实提示(绝不静默丢),且不 panic。
func TestInboundVoiceWithoutSTTWiringIsHonest(t *testing.T) {
t.Parallel()
env := newVoiceEnv(t, nil, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
got := lastUserContent(env)
if !strings.Contains(got, "没有转写") || !strings.Contains(got, "未配置") {
t.Fatalf("落库正文 = %q, want 未配置的诚实提示", got)
}
}