Files
AskaEth 5de708a560
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs:
  把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>,
  与正确形态 <p><action>…</action>…</p> 一致)
- **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工)
- 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、
  proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
2026-10-05 10:56:39 +08:00

358 lines
14 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 gateway
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"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/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// 本文件是协同项 C1(语音回合接进编排器与持久化)与 C3(voice_output 带
// sessionId/deviceId)的回归。
//
// 修前:runVoiceTurn 直调 runner.Run ⇒ 语音问过的事不进历史、不跨端同步、不参与
// busy/pending,音频却只能同步等在那个回合上。
//
// 修后:语音回合与文本回合走同一条路(进编排器 → 落库 → 流式 → 跨端同步),音频
// 由回合**结算**时的收尾回调合成并定向回原连接;TTS 失败/降级照旧回帧并带既有
// provider 降级信号,空正文不打无谓的 TTS。客户端既有绕行(transcribe → WS chat
// → speech)不受影响,因为它们走的是另一条入口。
//
// 断言一律按帧字段与已落库记录判定;日志/错误不按文本子串断言。
// waitFrame 排空连接上的帧直到出现给定类型(语音回合的收尾帧会排在若干状态
// 信号广播之后),超时即失败。
//
// 兜底期限取 15s:**断言的是「帧确实到达」,不是「3 秒内到达」** —— 语音回合
// 要经编排器 → 假 runner(带门控)→ TTS 假实现 → 定向回连接,在整树并行
// (多包同时跑)或宿主负载高时 3s 会被误判成超时(实测:单跑 -count=20 全绿、
// 只在整树并行下偶红)。放宽兜底不放宽断言:帧没到照样 Fatal。
func waitFrame(t *testing.T, c *wsConn, typ string) wsEvent {
t.Helper()
deadline := time.After(15 * time.Second)
for {
select {
case b := <-c.send:
var ev wsEvent
if err := json.Unmarshal(b, &ev); err != nil {
t.Fatalf("解码帧 %s: %v", b, err)
}
if ev.Type == typ {
return ev
}
case <-deadline:
t.Fatalf("等待 %q 帧超时", typ)
return wsEvent{}
}
}
}
// frameData 返回一帧的载荷 map(形状不对直接判失败)。
func frameData(t *testing.T, ev wsEvent) map[string]any {
t.Helper()
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("帧载荷 = %T, want map[string]any(frame=%+v)", ev.Data, ev)
}
return data
}
// voiceInputFrame 构造一条 voice_input 上行帧。text 非空时它覆盖 STT(测试因此
// 不依赖假 STT 的内容);为空时帧里带一段假音频,走真实的解码 + STT 分支。
func voiceInputFrame(text, sessionID, deviceID string) wsInbound {
data := map[string]any{
"text": text,
"format": "wav",
"sessionId": sessionID,
"deviceId": deviceID,
}
if text == "" {
data["audio"] = base64.StdEncoding.EncodeToString([]byte("fake-audio"))
}
raw, err := json.Marshal(data)
if err != nil {
panic(err)
}
return wsInbound{Type: "voice_input", Data: raw}
}
// TestVoiceTurnRepliesWithAudioAndIdentity 钉住 C1 的音频收尾与 C3 的归属字段:
// 一条语音回合(走编排器)结算后,定向回一条 voice_output,带助手正文合成的
// 音频、识别出的转写,以及 sessionId/deviceId;有真实音频时**不带** provider
// 降级信号。
func TestVoiceTurnRepliesWithAudioAndIdentity(t *testing.T) {
t.Parallel()
hb := newOrchestratedTestHub(t, fixedResult("我在听"), nil)
hb.voice = &signalVoice{text: "你好昔涟", audio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}, rung: "cloud"}
c := newOwnerConn(hb, 64)
hb.register(c)
c.route(voiceInputFrame("", "web", "android:pixel:7a"))
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
if data["text"] != "你好昔涟" {
t.Fatalf("voice_output.text = %v, want 识别出的转写", data["text"])
}
if data["reply"] != "我在听" {
t.Fatalf("voice_output.reply = %v, want 助手正文", data["reply"])
}
if want := base64.StdEncoding.EncodeToString([]byte("audio-bytes")); data["audio"] != want {
t.Fatalf("voice_output.audio = %v, want 合成音频的 base64", data["audio"])
}
if data["sessionId"] != "web" || data["deviceId"] != "android:pixel:7a" {
t.Fatalf("voice_output 缺归属字段:%+v(协同 C3)", data)
}
if _, present := data["provider"]; present {
t.Fatalf("有真实音频时不许带降级信号:%+v", data)
}
}
// TestVoiceTurnRepliesFrameWhenTTSError 钉住「降级不静默」:TTS 不可用时**照旧
// 回帧**(而不是只回一条错误帧让客户端等不到终点),并带既有 provider 降级信号,
// 客户端据此提示「没生成声音」。
func TestVoiceTurnRepliesFrameWhenTTSError(t *testing.T) {
t.Parallel()
hb := newOrchestratedTestHub(t, fixedResult("我在听"), nil)
hb.voice = &signalVoice{text: "你好", ttsErr: errors.New("tts down"), rung: "cloud"}
c := newOwnerConn(hb, 64)
hb.register(c)
c.route(voiceInputFrame("", "web", "android:pixel:7a"))
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
if data["provider"] != "silent" {
t.Fatalf("TTS 失败时必须带降级信号:%+v", data)
}
if data["reply"] != "我在听" || data["text"] != "你好" {
t.Fatalf("TTS 失败也要把转写与正文带回:%+v", data)
}
if data["audio"] != "" {
t.Fatalf("TTS 失败不该给出音频:%v", data["audio"])
}
if data["sessionId"] != "web" || data["deviceId"] != "android:pixel:7a" {
t.Fatalf("voice_output 缺归属字段:%+v(协同 C3)", data)
}
}
// TestVoiceOutputCarriesIdentityWithoutOrchestrator 钉住未接编排器时的旧直跑
// 路径(未接线的构建 / 裸 hub)同样带 C3 的两个归属字段:两条路径的
// voice_output 契约必须一致。
func TestVoiceOutputCarriesIdentityWithoutOrchestrator(t *testing.T) {
t.Parallel()
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", StopReason: "stop"}}, nil)
hb.voice = &signalVoice{text: "喂", audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
c := newOwnerConn(hb, 16)
hb.register(c)
c.route(voiceInputFrame("喂", "s9", "android:x"))
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
if data["sessionId"] != "s9" || data["deviceId"] != "android:x" {
t.Fatalf("直跑路径的 voice_output 缺归属字段:%+v(协同 C3)", data)
}
}
// TestVoiceTurnQueuesWhenSessionBusy 钉住 C1 的忙时语义:语音回合与文本回合共用
// 编排器,因此会话忙时它照常入有界待处理队列(不插队、不丢),前一条结算后按
// **严格 FIFO** 续跑;两条都落库。
func TestVoiceTurnQueuesWhenSessionBusy(t *testing.T) {
t.Parallel()
run := newGatedRunner(4)
hist := &fakeHistory{}
hb := newOrchestratedTestHub(t, run.Run, hist)
hb.voice = &signalVoice{audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
c := newOwnerConn(hb, 64)
hb.register(c)
c.route(voiceInputFrame("第一次", "web", "dev"))
if !waitBusy(hb.orchestrator, "web") {
t.Fatal("会话一直没有忙碌起来")
}
// 第二条在会话忙时到达:按编排器规则排队,而不是新起一个回合或丢掉。
c.route(voiceInputFrame("第二次", "web", "dev"))
if !waitQueueDepth(hb.orchestrator, "web", 1) {
t.Fatalf("队列深度 = %d, want 1", hb.orchestrator.QueueDepth("web"))
}
// 放行第一条:只有它能结算(第二条还堵在围栏之后),因此这一帧确定属于
// 先到的那条 —— FIFO 不是靠时序运气,而是靠门控。
run.tickets <- struct{}{}
first := frameData(t, waitFrame(t, c, wsVoiceOutput))
if first["text"] != "第一次" {
t.Fatalf("先到的语音回合 = %v, want 第一次(不得插队)", first["text"])
}
run.tickets <- struct{}{}
second := frameData(t, waitFrame(t, c, wsVoiceOutput))
if second["text"] != "第二次" {
t.Fatalf("续跑的语音回合 = %v, want 第二次", second["text"])
}
if !waitIdle(hb.orchestrator, "web") {
t.Fatalf("会话一直没有回到空闲:busy=%v depth=%d", hb.orchestrator.Busy("web"), hb.orchestrator.QueueDepth("web"))
}
users := userContents(hist.records())
if len(users) != 2 || users[0] != "第一次" || users[1] != "第二次" {
t.Fatalf("落库的用户消息 = %v, want [第一次 第二次](语音问过的事必须进历史、且一条不丢)", users)
}
}
// TestVoiceSinkFollowsSteeredVoiceMessage 钉住转向注入这条路径(design/15 §2.4):
// 忙时入 pending 的语音消息若在回合内边界被注入**同一个回合**(注入=已消费),
// 它的音频必须由那个回合结算时一并送回 —— 那条请求的回合已被接手,若不跟着走,
// 它会永远等不到 voice_output(音频回复不能丢)。
//
// 本测试直接驱动认领那一步(consumePending),因为真正的触发点在 loop 的边界回调上,
// 而这里要钉的是网关侧的接线:注入时挂上收尾回调、结算时把它派发出去。
func TestVoiceSinkFollowsSteeredVoiceMessage(t *testing.T) {
t.Parallel()
run := newGatedRunner(4)
hb := newOrchestratedTestHub(t, run.Run, nil)
hb.voice = &signalVoice{audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
c := newOwnerConn(hb, 64)
hb.register(c)
// 一个普通(非语音)回合在飞,语音请求在它忙时排队。
if sr := hb.orchestrator.Submit(context.Background(), "web", []model.Message{userMsg("文本问题")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("文本回合 = %+v, want Immediate", sr)
}
if !waitBusy(hb.orchestrator, "web") {
t.Fatal("会话一直没有忙碌起来")
}
c.route(voiceInputFrame("语音问题", "web", "dev"))
if !waitQueueDepth(hb.orchestrator, "web", 1) {
t.Fatalf("队列深度 = %d, want 1", hb.orchestrator.QueueDepth("web"))
}
// 模拟 loop 在回合内边界认领那条 pending 的语音消息(注入=已消费)。
o := hb.orchestrator
o.mu.Lock()
st := o.states["web"]
gen, seq := st.gen, st.pending[0].seq
o.mu.Unlock()
if !o.consumePending("web", gen, map[uint64]bool{seq: true}) {
t.Fatal("认领失败:pending 里没有那条语音消息")
}
// 放行那个回合:答案就在它的回复里,因此音频必须回给语音连接。
run.tickets <- struct{}{}
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
if data["text"] != "语音问题" {
t.Fatalf("注入后回的音频属于 %v, want 语音问题", data["text"])
}
}
// userContents 返回已落库记录里的用户消息正文(按顺序)。
func userContents(msgs []history.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
if m.Role == history.RoleUser {
out = append(out, m.Content)
}
}
return out
}
// fixedResult 返回一个总是产出同一助手正文的回合驱动器。
func fixedResult(text string) TurnRunner {
return func(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
return &loop.Result{Text: text, Steps: 1, StopReason: "stop"}, nil
}
}
// TestVoiceTurnPersistsIntoHistoryEndToEnd 是 C1 的端到端回归(HTTP/WS 层 +
// 真实 history 存储):一条 voice_input 之后,用户消息(转写)与助手回复都真的
// 落库,用户消息带发送端的 deviceId 与平台标签,且历史接口查得到 —— 这正是
// 「语音问过的事在 Web 上必须看得到」。
func TestVoiceTurnPersistsIntoHistoryEndToEnd(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
st := newGatewayHistoryStore(t)
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: &stubRunner{res: &loop.Result{Text: "语音答复", StopReason: "stop"}}, History: st})
if err != nil {
t.Fatalf("构造网关失败: %v", err)
}
srv.SetVoice(&signalVoice{text: "语音提问", audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"})
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
t.Cleanup(srv.Close)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token 失败: %v", err)
}
conn := dialWS(t, ts.URL, tok)
t.Cleanup(func() { _ = conn.Close() })
if err := conn.WriteJSON(map[string]any{"type": "voice_input", "data": map[string]any{
"audio": base64.StdEncoding.EncodeToString([]byte("raw-audio")),
"format": "wav",
"sessionId": history.MainSessionID,
"deviceId": "android:pixel:7a",
}}); err != nil {
t.Fatalf("发送 voice_input 失败: %v", err)
}
if ev := readWSType(t, conn, wsVoiceOutput); ev.Type != wsVoiceOutput {
t.Fatalf("帧类型 = %q", ev.Type)
}
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("历史接口 status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var envelope struct {
Data []struct {
Role string `json:"role"`
Content string `json:"content"`
Platform string `json:"platform"`
DeviceID string `json:"deviceId"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("解析历史响应失败: %v (body=%s)", err, body)
}
var userOK, assistantOK bool
for _, m := range envelope.Data {
switch {
case m.Role == history.RoleUser && m.Content == "语音提问":
userOK = true
if m.DeviceID != "android:pixel:7a" {
t.Fatalf("用户消息 deviceId = %q, want android:pixel:7a(语音特有身份必须落库)", m.DeviceID)
}
if m.Platform != history.PlatformWeb {
t.Fatalf("用户消息 platform = %q, want %q", m.Platform, history.PlatformWeb)
}
case m.Role == history.RoleAssistant && m.Content == storedParagraph("语音答复"):
assistantOK = true
}
}
if !userOK || !assistantOK {
t.Fatalf("历史里缺语音回合:user=%v assistant=%v (msgs=%+v)", userOK, assistantOK, envelope.Data)
}
}
// readWSType 从真连接上读帧直到出现给定类型。
func readWSType(t *testing.T, conn *websocket.Conn, typ string) wsEvent {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var ev wsEvent
if err := conn.ReadJSON(&ev); err != nil {
t.Fatalf("读 WS 帧失败(等 %q): %v", typ, err)
}
if ev.Type == typ {
return ev
}
}
}