Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba4cde61d | ||
|
|
28afa11aeb | ||
|
|
9f6243a729 | ||
|
|
a0b4c941e4 | ||
|
|
c9da488749 | ||
|
|
39ae883a4e | ||
|
|
1347a77d33 | ||
|
|
f84a358e30 |
No files matched your search
@@ -85,6 +85,11 @@ type Result struct {
|
||||
// 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 /
|
||||
// 读不到」—— 没有它,抽取失败对客户端就是不可见的。
|
||||
Documents []history.DocumentRef
|
||||
// Voice 是本回合的**出站语音结论**:nil 表示本回合没有请求语音(纯文本);
|
||||
// Name 非空表示合成成功且音频副本已落盘;Requested 为真但 Name 为空、
|
||||
// FallbackReason 非空表示她请求了语音但降级为文本(如 tts_timeout)。
|
||||
// 字段语义详见 OutboundVoice。
|
||||
Voice *OutboundVoice
|
||||
// docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次
|
||||
// Result,绝不跨 Run 共享。
|
||||
docSeen map[string]int
|
||||
@@ -138,6 +143,12 @@ type Loop struct {
|
||||
// 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部
|
||||
// 按会话分桶并自带锁,因此循环的并发 Run 契约不变。
|
||||
skillFace *SkillFace
|
||||
// voiceSynth / voiceStore 是 speak 的注入依赖(M57)。两者都非 nil 时
|
||||
// 循环向模型广告 speak 并自行承载它的执行(见 voice_deps.go);
|
||||
// speakBudget 是超时预算的替换公式,nil 用内置公式。
|
||||
voiceSynth VoiceSynth
|
||||
voiceStore VoiceStore
|
||||
speakBudget func(int) int
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
@@ -211,6 +222,9 @@ func New(opts Options) *Loop {
|
||||
imageResolver: opts.Vision,
|
||||
documentResolver: opts.DocumentResolver,
|
||||
documentInjectChars: docInjectChars,
|
||||
voiceSynth: opts.VoiceSynth,
|
||||
voiceStore: opts.VoiceStore,
|
||||
speakBudget: opts.SpeakBudget,
|
||||
log: sourcedLog(opts.Log),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,15 @@ type Options struct {
|
||||
// 或重排模型所见的消息。它是循环中与
|
||||
// OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。
|
||||
TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message
|
||||
// VoiceSynth 与 VoiceStore 一起接线时(**两个都非 nil**),循环向模型
|
||||
// 广告 speak 工具(M57):她可以自己决定这一条回复用语音还是文本。
|
||||
// 任一为 nil = 不广告、speak 不可调用,模型面与接线前逐字节一致。
|
||||
VoiceSynth VoiceSynth
|
||||
VoiceStore VoiceStore
|
||||
// SpeakBudget 可替换 speak 的超时预算公式(文本 rune 数 → 毫秒)。
|
||||
// 为 nil 或返回值非正时回落内置公式 speakBudgetMs。它是给测试与
|
||||
// 部署调优用的注入点,不是能力开关(开关是上面两个依赖)。
|
||||
SpeakBudget func(runes int) int
|
||||
// Log 是基础 logger。为 nil 时替换为标记来源
|
||||
// "runtime/loop" 的丢弃 logger。
|
||||
Log *slog.Logger
|
||||
|
||||
@@ -87,7 +87,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
}
|
||||
schemas := d.ToolSchemas()
|
||||
if l.capacity == nil || !l.capacity.Enabled() {
|
||||
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas))
|
||||
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)))
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(schemas))
|
||||
@@ -124,7 +124,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out))
|
||||
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)))
|
||||
}
|
||||
|
||||
// capacityTexts 为 capacity 判定器提取近期对话文本:
|
||||
|
||||
@@ -187,6 +187,7 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
|
||||
}
|
||||
msgs = batch.messages
|
||||
res.ToolCallsExecuted += batch.executed
|
||||
applyBatchVoice(res, &batch)
|
||||
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
|
||||
|
||||
// 关键工具循环安全网(或终止钩子)不等真正的最终答案
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package loop
|
||||
|
||||
// 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己
|
||||
// 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里
|
||||
// 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。
|
||||
// 调了 speak = 这条想用语音;不调 = 发文本。
|
||||
//
|
||||
// 三条执行口径:
|
||||
// - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿
|
||||
// 上限,中间线性 —— 越长合成越慢,但绝不无限等;
|
||||
// - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio
|
||||
// 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs);
|
||||
// - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**,
|
||||
// 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
const (
|
||||
// speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。
|
||||
speakToolName = "speak"
|
||||
|
||||
// speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给
|
||||
// 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。
|
||||
speakBudgetBaseMs = 3000
|
||||
// speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于
|
||||
// 每秒 40 个字符的合成速度,比实测慢一截,留出余量。
|
||||
speakBudgetPerRuneMs = 25
|
||||
// speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本,
|
||||
// 绝不让她的一条消息把整个回合挂住。
|
||||
speakBudgetMaxMs = 20000
|
||||
// speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算
|
||||
// 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。
|
||||
speakMaxRunes = 1200
|
||||
|
||||
// speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名
|
||||
// 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。
|
||||
speakAudioOrigName = "speak."
|
||||
|
||||
// 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值,
|
||||
// 客户端据此决定怎么显示「这条本来想用语音」)。
|
||||
speakReasonTimeout = "tts_timeout"
|
||||
speakReasonFailed = "tts_failed"
|
||||
speakReasonNoAudio = "tts_no_audio"
|
||||
speakReasonStoreFailed = "tts_store_failed"
|
||||
speakReasonTooLong = "text_too_long"
|
||||
speakReasonInvalidArgs = "invalid_args"
|
||||
)
|
||||
|
||||
// speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试
|
||||
// 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。
|
||||
type speakTool struct {
|
||||
synth VoiceSynth
|
||||
store VoiceStore
|
||||
// budget 把 rune 数映射为预算(毫秒);nil 用内置公式。
|
||||
budget func(runes int) int
|
||||
// persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用
|
||||
// (发音人绑人格,链本身不动)。
|
||||
persona func(context.Context) string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。
|
||||
var _ sdk.Tool = speakTool{}
|
||||
|
||||
// Name 返回工具标识。
|
||||
func (speakTool) Name() string { return speakToolName }
|
||||
|
||||
// Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 ——
|
||||
// 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。
|
||||
func (speakTool) Description() string {
|
||||
return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" +
|
||||
"适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" +
|
||||
"适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" +
|
||||
"语音只改变这一条回复的呈现方式,不改变你的能力或身份。" +
|
||||
"合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。"
|
||||
}
|
||||
|
||||
// Parameters 返回工具输入的 JSON Schema。
|
||||
func (speakTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。",
|
||||
},
|
||||
"emotion": map[string]any{
|
||||
"type": "string",
|
||||
"description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," +
|
||||
"这个字段只做额外微调,不会换掉发音人。",
|
||||
},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。
|
||||
type speakArgs struct {
|
||||
text string
|
||||
emotion string
|
||||
runes int
|
||||
}
|
||||
|
||||
// Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError,
|
||||
// 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。
|
||||
func (t speakTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
in, err := parseSpeakArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.synth == nil || t.store == nil {
|
||||
return nil, &speakError{Code: speakReasonFailed,
|
||||
Err: errors.New("speak: voice synthesis is not configured")}
|
||||
}
|
||||
budget := t.budgetMs(in.runes)
|
||||
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond)
|
||||
defer cancel()
|
||||
synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion)
|
||||
|
||||
started := time.Now()
|
||||
audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text)
|
||||
cost := int(time.Since(started) / time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, t.synthesisFailed(synthCtx, err, budget, cost)
|
||||
}
|
||||
ext, extOK := speakExt(audio.Format)
|
||||
if len(audio.Data) == 0 || !extOK {
|
||||
return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost,
|
||||
Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))}
|
||||
}
|
||||
// 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算
|
||||
// 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。
|
||||
ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data)
|
||||
if err != nil {
|
||||
return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err}
|
||||
}
|
||||
t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data))
|
||||
// durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一
|
||||
// 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。
|
||||
return map[string]any{
|
||||
"name": ref.Name,
|
||||
"mime": ref.MIME,
|
||||
"size": ref.Size,
|
||||
"durationMs": int64(0),
|
||||
"budgetMs": budget,
|
||||
"costMs": cost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格
|
||||
// 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。
|
||||
func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context {
|
||||
if t.persona != nil {
|
||||
if pid := t.persona(turnCtx); pid != "" {
|
||||
synthCtx = voicetts.WithPersonaID(synthCtx, pid)
|
||||
}
|
||||
}
|
||||
if emotion != "" {
|
||||
synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion)
|
||||
}
|
||||
return synthCtx
|
||||
}
|
||||
|
||||
// budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。
|
||||
func (t speakTool) budgetMs(runes int) int {
|
||||
if t.budget != nil {
|
||||
if ms := t.budget(runes); ms > 0 {
|
||||
return ms
|
||||
}
|
||||
}
|
||||
return speakBudgetMs(runes)
|
||||
}
|
||||
|
||||
// synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处:
|
||||
// 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算
|
||||
// (上游把超时包成自己的错误类型时只有最后一处能认出来)。
|
||||
func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error {
|
||||
code := speakReasonFailed
|
||||
if errors.Is(err, context.DeadlineExceeded) ||
|
||||
errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget {
|
||||
code = speakReasonTimeout
|
||||
}
|
||||
if t.log != nil {
|
||||
// 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。
|
||||
t.log.Warn("speak degraded to text",
|
||||
"reason", code, "budget_ms", budget, "cost_ms", cost, "err", err)
|
||||
}
|
||||
return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err}
|
||||
}
|
||||
|
||||
// logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。
|
||||
func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) {
|
||||
if t.log == nil {
|
||||
return
|
||||
}
|
||||
t.log.Info("speak synthesized",
|
||||
"provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size)
|
||||
}
|
||||
|
||||
// speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让
|
||||
// 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际
|
||||
// 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。
|
||||
type speakError struct {
|
||||
Code string
|
||||
BudgetMs int
|
||||
CostMs int
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。
|
||||
func (e *speakError) Error() string {
|
||||
if e.Err == nil {
|
||||
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs)
|
||||
}
|
||||
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。
|
||||
func (e *speakError) Unwrap() error { return e.Err }
|
||||
|
||||
// parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它
|
||||
// 归到 argument-validation 而不是执行失败。
|
||||
func parseSpeakArgs(args any) (speakArgs, error) {
|
||||
m, err := speakArgMap(args)
|
||||
if err != nil {
|
||||
return speakArgs{}, err
|
||||
}
|
||||
text, _ := m["text"].(string)
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return speakArgs{}, &speakError{Code: speakReasonInvalidArgs,
|
||||
Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)}
|
||||
}
|
||||
runes := utf8.RuneCountInString(text)
|
||||
if runes > speakMaxRunes {
|
||||
return speakArgs{}, &speakError{Code: speakReasonTooLong,
|
||||
Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)}
|
||||
}
|
||||
emotion, _ := m["emotion"].(string)
|
||||
return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil
|
||||
}
|
||||
|
||||
// speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。
|
||||
func speakArgMap(args any) (map[string]any, error) {
|
||||
if args == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
return nil, &speakError{Code: speakReasonInvalidArgs,
|
||||
Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// speakBudgetMs 把文本长度(rune 数)映射为合成超时预算:
|
||||
// clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。
|
||||
func speakBudgetMs(runes int) int {
|
||||
if runes < 0 {
|
||||
runes = 0
|
||||
}
|
||||
ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes
|
||||
if ms > speakBudgetMaxMs {
|
||||
return speakBudgetMaxMs
|
||||
}
|
||||
if ms < speakBudgetBaseMs {
|
||||
return speakBudgetBaseMs
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
// speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游
|
||||
// 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用,
|
||||
// 绝不拿一个读不出 Content-Type 的名字落盘。
|
||||
func speakExt(format string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "":
|
||||
return "wav", true
|
||||
case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr":
|
||||
return strings.ToLower(strings.TrimSpace(format)), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。
|
||||
func (l *Loop) speakSpec() *model.ToolSpec {
|
||||
if !l.voiceEnabled() {
|
||||
return nil
|
||||
}
|
||||
t := l.speakTool()
|
||||
params, err := t.Parameters()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params}
|
||||
}
|
||||
|
||||
// speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。
|
||||
func (l *Loop) speakTool() speakTool {
|
||||
return speakTool{
|
||||
synth: l.voiceSynth,
|
||||
store: l.voiceStore,
|
||||
budget: l.speakBudget,
|
||||
persona: l.personaFor,
|
||||
log: l.log,
|
||||
}
|
||||
}
|
||||
|
||||
// invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与
|
||||
// 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、
|
||||
// panic 兜底与允许集软判对普通工具仍然生效。
|
||||
func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) {
|
||||
if tc.Name == speakToolName && l.voiceEnabled() {
|
||||
return l.speakTool().Execute(ctx, tc.Args)
|
||||
}
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name)
|
||||
}
|
||||
return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件是 speak 工具的叶子测试(M57):预算公式、扩展名白名单、
|
||||
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
|
||||
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
|
||||
|
||||
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
|
||||
// audio 决定成功时的产物,provider 供日志断言。
|
||||
type stubSynth struct {
|
||||
audio mvoice.Audio
|
||||
provider string
|
||||
err error
|
||||
delay time.Duration
|
||||
gotText string
|
||||
}
|
||||
|
||||
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||||
s.gotText = text
|
||||
if s.delay > 0 {
|
||||
select {
|
||||
case <-time.After(s.delay):
|
||||
case <-ctx.Done():
|
||||
return mvoice.Audio{}, "", ctx.Err()
|
||||
}
|
||||
}
|
||||
if s.err != nil {
|
||||
return mvoice.Audio{}, "", s.err
|
||||
}
|
||||
return s.audio, s.provider, nil
|
||||
}
|
||||
|
||||
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
|
||||
type stubStore struct {
|
||||
ref VoiceRef
|
||||
err error
|
||||
gotName string
|
||||
gotData []byte
|
||||
}
|
||||
|
||||
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
|
||||
s.gotName = origName
|
||||
s.gotData = data
|
||||
if s.err != nil {
|
||||
return VoiceRef{}, s.err
|
||||
}
|
||||
return s.ref, nil
|
||||
}
|
||||
|
||||
// newSpeakTool 装配一个注入了桩的 speak 工具。
|
||||
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
|
||||
return speakTool{synth: synth, store: store}
|
||||
}
|
||||
|
||||
// speakCodeOf 取出 speak 失败的结构化原因码。
|
||||
func speakCodeOf(t *testing.T, err error) *speakError {
|
||||
t.Helper()
|
||||
var se *speakError
|
||||
if !errors.As(err, &se) {
|
||||
t.Fatalf("error %v is not *speakError", err)
|
||||
}
|
||||
return se
|
||||
}
|
||||
|
||||
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
|
||||
// 长文封顶,负数按 0 处理(绝不返回负预算)。
|
||||
func TestSpeakBudgetMs(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
runes int
|
||||
want int
|
||||
}{
|
||||
{-5, speakBudgetBaseMs},
|
||||
{0, speakBudgetBaseMs},
|
||||
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
|
||||
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
|
||||
{680, speakBudgetMaxMs},
|
||||
{speakMaxRunes, speakBudgetMaxMs},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := speakBudgetMs(c.runes); got != c.want {
|
||||
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
|
||||
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
|
||||
// 名字落盘)。
|
||||
func TestSpeakExt(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"", "wav", true},
|
||||
{" ", "wav", true},
|
||||
{"WAV", "wav", true},
|
||||
{"mp3", "mp3", true},
|
||||
{" Opus ", "", false},
|
||||
{"exe", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := speakExt(c.format)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
|
||||
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
|
||||
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
|
||||
func TestParseSpeakArgs_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
|
||||
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
|
||||
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
|
||||
}
|
||||
|
||||
// 恰好上限、且是多字节:按 rune 计数,应当放行。
|
||||
in, err := parseSpeakArgs(map[string]any{
|
||||
"text": strings.Repeat("あ", speakMaxRunes),
|
||||
"emotion": " 温柔 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("at-limit runes rejected: %v", err)
|
||||
}
|
||||
if in.runes != speakMaxRunes {
|
||||
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
|
||||
}
|
||||
if in.emotion != "温柔" {
|
||||
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
|
||||
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
|
||||
// 且 durationMs 恒为 0(未解析,不是零长度)。
|
||||
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
synth := &stubSynth{
|
||||
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
|
||||
provider: "emotiontts",
|
||||
}
|
||||
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
|
||||
tk := newSpeakTool(synth, store)
|
||||
tk.budget = func(int) int { return 777 }
|
||||
|
||||
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if synth.gotText != "晚安" {
|
||||
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
|
||||
}
|
||||
if store.gotName != "speak.wav" {
|
||||
t.Fatalf("origName = %q, want speak.wav", store.gotName)
|
||||
}
|
||||
if len(store.gotData) != 3 {
|
||||
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
|
||||
}
|
||||
m, ok := out.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result type = %T, want map[string]any", out)
|
||||
}
|
||||
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
|
||||
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
|
||||
}
|
||||
if m["durationMs"] != int64(0) {
|
||||
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
|
||||
}
|
||||
if m["budgetMs"] != 777 {
|
||||
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
|
||||
}
|
||||
if _, ok := m["costMs"].(int); !ok {
|
||||
t.Fatalf("costMs = %v, want int", m["costMs"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
|
||||
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
|
||||
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
|
||||
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
|
||||
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
tk := newSpeakTool(
|
||||
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
|
||||
&stubStore{},
|
||||
)
|
||||
tk.budget = func(int) int { return 1 }
|
||||
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
se := speakCodeOf(t, err)
|
||||
if se.Code != speakReasonTimeout {
|
||||
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
|
||||
}
|
||||
if se.BudgetMs != 1 {
|
||||
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
|
||||
}
|
||||
if se.CostMs < 0 {
|
||||
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
|
||||
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
|
||||
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
audio mvoice.Audio
|
||||
}{
|
||||
{"empty bytes", mvoice.Audio{Format: "wav"}},
|
||||
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
|
||||
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
|
||||
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
|
||||
}
|
||||
if store.gotName != "" {
|
||||
t.Fatalf("store was called with %q, want no write at all", store.gotName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
|
||||
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
|
||||
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
tk := newSpeakTool(
|
||||
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
|
||||
&stubStore{err: errors.New("disk full")},
|
||||
)
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
se := speakCodeOf(t, err)
|
||||
if se.Code != speakReasonStoreFailed {
|
||||
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
|
||||
}
|
||||
if !errors.Is(err, se.Err) {
|
||||
t.Fatalf("error chain lost the store error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
|
||||
// 以结构化失败返回,使回合仍能继续。
|
||||
func TestSpeakTool_NotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tk := range []speakTool{
|
||||
newSpeakTool(nil, &stubStore{}),
|
||||
newSpeakTool(&stubSynth{}, nil),
|
||||
} {
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
|
||||
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
|
||||
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
|
||||
func TestSpeakSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
if spec := New(Options{}).speakSpec(); spec != nil {
|
||||
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
|
||||
}
|
||||
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
|
||||
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
|
||||
}
|
||||
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
|
||||
if spec == nil {
|
||||
t.Fatal("wired speakSpec = nil, want a spec")
|
||||
}
|
||||
if spec.Name != speakToolName {
|
||||
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
|
||||
}
|
||||
if spec.Parameters == nil {
|
||||
t.Fatal("spec parameters = nil, want the JSON schema")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
|
||||
// (因此不受前面任何收缩影响)、已有同名不重复追加。
|
||||
func TestWithSpeakSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
|
||||
|
||||
unwired := New(Options{})
|
||||
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
|
||||
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
|
||||
}
|
||||
|
||||
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
|
||||
got := wired.withSpeakSpec(base)
|
||||
if len(got) != len(base)+1 {
|
||||
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
|
||||
}
|
||||
if last := got[len(got)-1].Name; last != speakToolName {
|
||||
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
|
||||
}
|
||||
|
||||
again := wired.withSpeakSpec(got)
|
||||
if len(again) != len(got) {
|
||||
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package loop
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
@@ -128,6 +129,37 @@ func notAllowedReason(name string) string {
|
||||
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
|
||||
}
|
||||
|
||||
// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门
|
||||
// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate
|
||||
// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne
|
||||
// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的)
|
||||
// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。
|
||||
//
|
||||
// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。
|
||||
// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed),
|
||||
// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。
|
||||
func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) {
|
||||
if !l.voiceEnabled() {
|
||||
return
|
||||
}
|
||||
env := sessionEnvFrom(ctx)
|
||||
if env.ActorEnv == restrict.ActorOwner {
|
||||
return
|
||||
}
|
||||
for i := range calls {
|
||||
if !actions[i].run || calls[i].Name != speakToolName {
|
||||
continue
|
||||
}
|
||||
reason := notAllowedReason(calls[i].Name)
|
||||
actions[i] = toolCallAction{run: false, reason: reason}
|
||||
l.emitAudit(AuditEvent{
|
||||
Who: string(env.ActorEnv),
|
||||
What: auditToolRejectNotAllowed,
|
||||
Result: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线
|
||||
// sink 时为空操作,因此在未挂审计 sink 的叶子测试中
|
||||
// 安全门仍能工作(软 not_allowed 结果)。
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
@@ -235,6 +237,75 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道
|
||||
// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的
|
||||
// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 ——
|
||||
// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计,
|
||||
// 主人环境照常放行。
|
||||
func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var audited []AuditEvent
|
||||
l := New(Options{
|
||||
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
|
||||
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
|
||||
OnAudit: func(ev AuditEvent) {
|
||||
mu.Lock()
|
||||
audited = append(audited, ev)
|
||||
mu.Unlock()
|
||||
},
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
env restrict.ActorEnv
|
||||
allow bool
|
||||
}{
|
||||
{restrict.ActorGroupMember, false},
|
||||
{restrict.ActorStranger, false},
|
||||
{restrict.ActorOwner, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
mu.Lock()
|
||||
audited = nil
|
||||
mu.Unlock()
|
||||
|
||||
calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}}
|
||||
actions := []toolCallAction{{run: true}}
|
||||
l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions)
|
||||
|
||||
if actions[0].run != tc.allow {
|
||||
t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason)
|
||||
}
|
||||
mu.Lock()
|
||||
got := append([]AuditEvent(nil), audited...)
|
||||
mu.Unlock()
|
||||
if tc.allow {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("env %s audited %+v, want none", tc.env, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(actions[0].reason, "not_allowed") {
|
||||
t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason)
|
||||
}
|
||||
if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) {
|
||||
t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env)
|
||||
}
|
||||
}
|
||||
|
||||
// speak 未接线时没有可执行的旁路,门不该动任何调用。
|
||||
actions := []toolCallAction{{run: true}}
|
||||
New(Options{}).applyToolAdmissions(
|
||||
WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}),
|
||||
nil,
|
||||
[]agent.ToolCall{{Name: speakToolName}},
|
||||
actions,
|
||||
)
|
||||
if !actions[0].run {
|
||||
t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason)
|
||||
}
|
||||
}
|
||||
|
||||
// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。
|
||||
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
|
||||
t.Helper()
|
||||
|
||||
@@ -5,12 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用
|
||||
@@ -77,6 +75,9 @@ type toolRunResult struct {
|
||||
messages []model.Message
|
||||
executed int
|
||||
terminate bool
|
||||
// voice 是本批 speak 调用的结论(M57):nil = 这一批没有要求语音。
|
||||
// 由 runToolCalls 在结局钩子之后提取,供 run.go 回填 Result.Voice。
|
||||
voice *OutboundVoice
|
||||
}
|
||||
|
||||
// toolLoopState 携带关键工具循环安全网的每次 run 状态。
|
||||
@@ -156,6 +157,10 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
|
||||
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
|
||||
l.applyToolOutcomeHooks(ctx, calls, outcomes)
|
||||
|
||||
// 语音结论必须在钩子之后提取:AfterOutcome 可以改写 speak 的结果,
|
||||
// 出站结论要看**模型最终看到的那份**。
|
||||
res.voice = l.captureVoice(calls, outcomes)
|
||||
|
||||
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮
|
||||
// (OpenClaw 的 shouldTerminateToolBatch)。
|
||||
if !res.terminate && shouldTerminateBatch(outcomes) {
|
||||
@@ -231,11 +236,19 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct
|
||||
// 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计;
|
||||
// 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。②
|
||||
// BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit
|
||||
// 逐调用否决。dispatcher 缺席时安全门跳过(runToolCalls 已保证
|
||||
// 此时整批以错误回灌)。
|
||||
// 逐调用否决。
|
||||
//
|
||||
// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎),
|
||||
// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但
|
||||
// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的
|
||||
// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate
|
||||
// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3
|
||||
// 的旁路。
|
||||
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
|
||||
if d != nil {
|
||||
l.applyToolSecurityGate(ctx, d, calls, actions)
|
||||
} else {
|
||||
l.applyDispatcherlessVoiceGate(ctx, calls, actions)
|
||||
}
|
||||
if l.hooks.BatchAdmit != nil {
|
||||
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
|
||||
@@ -287,95 +300,6 @@ func (l *Loop) applyToolOutcomeHooks(ctx context.Context, calls []agent.ToolCall
|
||||
}
|
||||
}
|
||||
|
||||
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
|
||||
// tool span、以 tool_execution.start/end 事件流式输出执行进度
|
||||
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
|
||||
// running,Invoke 返回后按成败取 done/error,同一取值既决定
|
||||
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
|
||||
// running/done/error 状态即由此驱动)。
|
||||
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
|
||||
if d == nil {
|
||||
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
|
||||
}
|
||||
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
|
||||
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
|
||||
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
|
||||
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
|
||||
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
|
||||
// 不做互斥,行为逐字不变(兼容硬要求)。
|
||||
//
|
||||
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
|
||||
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
|
||||
// 「1 普通回合 + 1 并行路」时完全不受保护。
|
||||
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
|
||||
phase := model.ToolPhaseDone
|
||||
if err != nil {
|
||||
phase = model.ToolPhaseError
|
||||
}
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
|
||||
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
|
||||
l.spanProp(toolSpan, "phase", phase)
|
||||
l.recordToolDone(toolSpan, tc, out)
|
||||
if err != nil {
|
||||
l.spanProp(toolSpan, "error", err.Error())
|
||||
l.spanProp(toolSpan, "error_kind", out.ErrKind)
|
||||
} else {
|
||||
recordToolResult(toolSpan, result)
|
||||
}
|
||||
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
|
||||
return out
|
||||
}
|
||||
|
||||
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
|
||||
// tool_execution 三事件(start / update / end)转发给前端;否则为
|
||||
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
|
||||
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
|
||||
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
|
||||
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
|
||||
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
|
||||
if l.onStream == nil || isMutedBroadcast(ctx) {
|
||||
return
|
||||
}
|
||||
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
|
||||
ev.SessionID = loopSession(ctx)
|
||||
ev.TurnID = loopTurn(ctx)
|
||||
l.onStream(ev)
|
||||
}
|
||||
|
||||
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
|
||||
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
|
||||
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
|
||||
func spanStatusForToolPhase(phase string) trace.SpanStatus {
|
||||
if phase == model.ToolPhaseError {
|
||||
return trace.SpanStatusError
|
||||
}
|
||||
return trace.SpanStatusOK
|
||||
}
|
||||
|
||||
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
|
||||
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
|
||||
// 能按原始顺序渲染结果。信号量限制并发,在途
|
||||
// goroutine 完成时释放其槽位,因此被取消的 context
|
||||
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
|
||||
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
|
||||
if len(idxs) == 0 {
|
||||
return
|
||||
}
|
||||
sem := make(chan struct{}, l.toolParallelism)
|
||||
var wg sync.WaitGroup
|
||||
for _, i := range idxs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
out[i] = l.invokeOne(ctx, d, calls[i])
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// 由 classifyToolBatch 为两种跳过原因返回。
|
||||
func duplicateCallReason() string {
|
||||
return "duplicate call with identical arguments already issued in this batch"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package loop
|
||||
|
||||
// 本文件承载**单个/一批工具调用的执行**(span、流式帧、结果归一化、
|
||||
// 有界并行),与 tools.go 的分类、准入与回渲职责分开 —— 从 tools.go
|
||||
// 抽出,使两边都留在 500 行以内(AGENTS.md §10)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
|
||||
// tool span、以 tool_execution.start/end 事件流式输出执行进度
|
||||
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
|
||||
// running,Invoke 返回后按成败取 done/error,同一取值既决定
|
||||
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
|
||||
// running/done/error 状态即由此驱动)。
|
||||
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
|
||||
// speak 由循环自己承载(见 invokeTool),没有 dispatcher 也能执行;其余
|
||||
// 工具缺 dispatcher 时仍按原样短路,不为此多开 span 或流式帧。
|
||||
if d == nil && !(l.voiceEnabled() && tc.Name == speakToolName) {
|
||||
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
|
||||
}
|
||||
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
|
||||
// 分派见 invokeTool:路标识桥(tool.WithLegID)与 speak 的循环内承载都在
|
||||
// 那里。路标识(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把
|
||||
// 互斥身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路
|
||||
// 都挂,取值 = 该路的 turnID),工具层凭 tool.LegIDFrom 读到「我是哪一路」
|
||||
// 做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变(兼容硬要求)。
|
||||
//
|
||||
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
|
||||
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
|
||||
// 「1 普通回合 + 1 并行路」时完全不受保护。
|
||||
result, err := l.invokeTool(ctx, d, tc)
|
||||
phase := model.ToolPhaseDone
|
||||
if err != nil {
|
||||
phase = model.ToolPhaseError
|
||||
}
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
|
||||
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
|
||||
l.spanProp(toolSpan, "phase", phase)
|
||||
l.recordToolDone(toolSpan, tc, out)
|
||||
if err != nil {
|
||||
l.spanProp(toolSpan, "error", err.Error())
|
||||
l.spanProp(toolSpan, "error_kind", out.ErrKind)
|
||||
} else {
|
||||
recordToolResult(toolSpan, result)
|
||||
}
|
||||
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
|
||||
return out
|
||||
}
|
||||
|
||||
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
|
||||
// tool_execution 三事件(start / update / end)转发给前端;否则为
|
||||
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
|
||||
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
|
||||
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
|
||||
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
|
||||
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
|
||||
if l.onStream == nil || isMutedBroadcast(ctx) {
|
||||
return
|
||||
}
|
||||
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
|
||||
ev.SessionID = loopSession(ctx)
|
||||
ev.TurnID = loopTurn(ctx)
|
||||
l.onStream(ev)
|
||||
}
|
||||
|
||||
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
|
||||
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
|
||||
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
|
||||
func spanStatusForToolPhase(phase string) trace.SpanStatus {
|
||||
if phase == model.ToolPhaseError {
|
||||
return trace.SpanStatusError
|
||||
}
|
||||
return trace.SpanStatusOK
|
||||
}
|
||||
|
||||
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
|
||||
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
|
||||
// 能按原始顺序渲染结果。信号量限制并发,在途
|
||||
// goroutine 完成时释放其槽位,因此被取消的 context
|
||||
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
|
||||
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
|
||||
if len(idxs) == 0 {
|
||||
return
|
||||
}
|
||||
sem := make(chan struct{}, l.toolParallelism)
|
||||
var wg sync.WaitGroup
|
||||
for _, i := range idxs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
out[i] = l.invokeOne(ctx, d, calls[i])
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package loop
|
||||
|
||||
// OutboundVoice 是一次 Run 的**出站语音结论**:模型在回合内调用 speak 工具
|
||||
// 请求合成语音时,循环把这次请求的结局归一化到本结构,随 Result 交还调用方
|
||||
// (网关据此决定这条回复是发音频还是发文本,持久化层据此记下「这条是语音」
|
||||
// 与降级原因)。它是进程内类型,不直接序列化给客户端。
|
||||
//
|
||||
// 三种状态互斥且由字段组合判定:
|
||||
//
|
||||
// - Result.Voice == nil —— 本回合**没有**请求语音,回复就是纯文本;
|
||||
// - Name != "" —— 合成成功,且音频副本**已经落盘**(Name 即服务端存储名,
|
||||
// Mime/Size 描述该副本);
|
||||
// - Requested && Name == "" && FallbackReason != "" —— 她**请求了**语音,
|
||||
// 但合成降级为文本(如 tts_timeout);FallbackReason 是机器可读的降级码,
|
||||
// 客户端可以据此特殊显示「这条本来想用语音」。
|
||||
//
|
||||
// 语音是她的表达选择,不是身份或权限判定 —— 谁能用语音仍由渠道层 actor
|
||||
// 元数据 + 工具可达性标记决定,本结构只记录结局。
|
||||
type OutboundVoice struct {
|
||||
// Requested 报告本回合是否发起过语音合成请求(成功与降级都为 true)。
|
||||
Requested bool
|
||||
// Name 是落盘音频副本的服务端存储名;空表示没有可用音频(降级或未请求)。
|
||||
Name string
|
||||
// Mime 是音频副本的媒体类型(如 audio/wav),随 Name 一起有意义。
|
||||
Mime string
|
||||
// Size 是音频副本的字节数。
|
||||
Size int64
|
||||
// DurationMs 是音频时长(毫秒)。**不知道就是 0** —— 本项目绝不用比特率
|
||||
// 或字数估算时长(与 voice.Copy.DurationMs 同一口径);0 表示「未解析」,
|
||||
// 不表示零长度音频。
|
||||
DurationMs int64
|
||||
// CostMs 是这次合成实际耗时(毫秒),用于对照预算看是否接近超时。
|
||||
CostMs int
|
||||
// BudgetMs 是本次合成被允许的超时预算(毫秒),由文本 rune 数推导
|
||||
// (见 speakBudgetMs);未请求时为 0。
|
||||
BudgetMs int
|
||||
// FallbackReason 是降级原因码(如 tts_timeout),仅在「请求了但没有音频」
|
||||
// 时非空;它让调用方与客户端能把「想用语音但没成」与「本来就用文本」区分开。
|
||||
FallbackReason string
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package loop
|
||||
|
||||
// 本文件是 speak 的接线面(M57):两个注入依赖的接口、接线判定、
|
||||
// 以及「本回合的出站语音结论」从工具结局到 Result.Voice 的提取。
|
||||
// 它们刻意留在循环里而不是工具包里 —— speak 需要本回合的人格与
|
||||
// 预算口径,而这两样只有循环持有。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
)
|
||||
|
||||
// VoiceSynth 是 speak 需要的合成能力(*tts.Service 天然满足)。
|
||||
// 接口收窄到**一个方法**:循环只需要「给定文本拿回音频与提供者」,
|
||||
// 发音人解析、降级链与配额全在实现内部,循环不重复实现它们。
|
||||
type VoiceSynth interface {
|
||||
// TTSWithProvider 合成文本并返回音频与**实际**提供者标识
|
||||
// (降级后是备用的那个),供日志与可观测性使用。
|
||||
TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error)
|
||||
}
|
||||
|
||||
// VoiceRef 是音频副本落盘后的交付描述:循环只需要「叫什么、什么类型、
|
||||
// 多大」,与存储层的哈希/去重标记/磁盘路径无关。单独定义而不复用
|
||||
// documents.Ref 是为了让循环不 import documents 包 —— 该包的测试会
|
||||
// import 循环,反向依赖构成测试期的 import cycle。
|
||||
type VoiceRef struct {
|
||||
// Name 是服务端存储名(客户端凭它取回音频)。
|
||||
Name string
|
||||
// MIME 是存储层字节嗅探所得,不是合成侧的声明。
|
||||
MIME string
|
||||
// Size 是该副本的字节数。
|
||||
Size int64
|
||||
}
|
||||
|
||||
// VoiceStore 是 speak 需要的落盘能力。只要一个方法:音频副本必须走文档
|
||||
// 存储的内容寻址 + 权限口径,而不是循环自己写文件。*documents.Store 经
|
||||
// VoiceStoreFunc 适配即可接上(见该类型的用法说明)。
|
||||
type VoiceStore interface {
|
||||
// SaveAudio 落盘一份音频副本并返回其交付描述。
|
||||
SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error)
|
||||
}
|
||||
|
||||
// VoiceStoreFunc 把函数适配为 VoiceStore(与 ImageResolveFunc 同一惯例)。
|
||||
// 接线方用它把存储层接到本包的接缝上,避免循环反向依赖 documents:
|
||||
//
|
||||
// loop.VoiceStoreFunc(func(ctx context.Context, name string, data []byte) (loop.VoiceRef, error) {
|
||||
// ref, err := docs.SaveAudio(ctx, name, data)
|
||||
// return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, err
|
||||
// })
|
||||
type VoiceStoreFunc func(ctx context.Context, origName string, data []byte) (VoiceRef, error)
|
||||
|
||||
// SaveAudio 实现 VoiceStore。
|
||||
func (f VoiceStoreFunc) SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) {
|
||||
return f(ctx, origName, data)
|
||||
}
|
||||
|
||||
// voiceEnabled 报告 speak 是否已接线。两个依赖**都**非 nil 才算 ——
|
||||
// 只接了合成却没有存储时,调用必然在落盘一步失败,不如干脆不广告,
|
||||
// 让模型面与接线前逐字节一致。
|
||||
func (l *Loop) voiceEnabled() bool {
|
||||
return l != nil && l.voiceSynth != nil && l.voiceStore != nil
|
||||
}
|
||||
|
||||
// withSpeakSpec 把 speak 追加到模型面的工具声明末尾。它由 toolSchemas
|
||||
// 在**所有收缩之后**调用,因此 speak 不受能力允许集(quiet/awake)与
|
||||
// 技能轮次的 allowed-tools 裁剪 —— speak 不在任何内置 toolset 里,
|
||||
// 走那些过滤就等于永远不广告。代价(群聊/陌生人也能看到 speak)由
|
||||
// 执行侧的工具安全门兜住:speak 未注册进能力登记表,非主人环境下
|
||||
// Policy.Allow 以「未知工具」拒绝,fail-closed。
|
||||
func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec {
|
||||
spec := l.speakSpec()
|
||||
if spec == nil {
|
||||
return schemas
|
||||
}
|
||||
for i := range schemas {
|
||||
if schemas[i].Name == spec.Name {
|
||||
return schemas
|
||||
}
|
||||
}
|
||||
out := make([]model.ToolSpec, 0, len(schemas)+1)
|
||||
out = append(out, schemas...)
|
||||
return append(out, *spec)
|
||||
}
|
||||
|
||||
// applyBatchVoice 把一批工具调用的语音结论回填进本回合的 Result。
|
||||
// 后面批次里没有 speak 时保留前面那批的结论 —— 「这条回复想用语音」
|
||||
// 发生在哪一批都算数。
|
||||
//
|
||||
// 跨批也守 captureVoice 的同一条优先级:**已落盘的音频绝不被后续批次的
|
||||
// 失败抹掉**。同一批里由 captureVoice 自己保证(成功优先于失败),但批次
|
||||
// 之间是两次独立结论,若这里无条件覆盖,则「先成功合成 abc.wav、下一轮
|
||||
// 换个参数再试却失败」会把交付物退化成 tts_failed —— 音频已经存在,退回
|
||||
// 文本既没有收益也违背她那次表达的意愿。反过来(先降级后成功)仍然覆盖:
|
||||
// 后到的成功就是这条消息最终的语音。
|
||||
func applyBatchVoice(res *Result, batch *toolRunResult) {
|
||||
if batch.voice == nil {
|
||||
return
|
||||
}
|
||||
if res.Voice != nil && res.Voice.Name != "" && batch.voice.Name == "" {
|
||||
return
|
||||
}
|
||||
res.Voice = batch.voice
|
||||
}
|
||||
|
||||
// captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil
|
||||
// 表示「这一批没有要求语音」,Result.Voice 因此保持 nil,客户端按纯
|
||||
// 文本呈现 —— 与没有 speak 之前逐字节一致。
|
||||
//
|
||||
// 多个 speak 调用时取**最后一个成功的**:成功意味着音频真的落了盘,
|
||||
// 后续的重试即使失败也不该让已经交付的音频消失。全都失败时取最后
|
||||
// 一个失败的原因,使客户端仍能区分「想用语音但没成」与「本来就用
|
||||
// 文本」。
|
||||
func (l *Loop) captureVoice(calls []agent.ToolCall, outcomes []ToolOutcome) *OutboundVoice {
|
||||
var lastOK, lastFail *OutboundVoice
|
||||
n := len(calls)
|
||||
if len(outcomes) < n {
|
||||
n = len(outcomes)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if calls[i].Name != speakToolName || outcomes[i].Skipped {
|
||||
continue
|
||||
}
|
||||
if outcomes[i].Err == nil {
|
||||
lastOK = synthesizedVoice(outcomes[i].Result)
|
||||
continue
|
||||
}
|
||||
lastFail = degradedVoice(outcomes[i].Err)
|
||||
}
|
||||
if lastOK != nil {
|
||||
return lastOK
|
||||
}
|
||||
return lastFail
|
||||
}
|
||||
|
||||
// synthesizedVoice 把 speak 成功时的结构化结果翻成 OutboundVoice。
|
||||
// 结果被钩子改写成非对象时返回 nil:拿不到存储名就播放不了,此时
|
||||
// 按文本呈现是唯一诚实的选择(DurationMs 保持 0 = 未解析)。
|
||||
func synthesizedVoice(result any) *OutboundVoice {
|
||||
m, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
v := &OutboundVoice{Requested: true}
|
||||
v.Name, _ = m["name"].(string)
|
||||
v.Mime, _ = m["mime"].(string)
|
||||
v.Size, _ = m["size"].(int64)
|
||||
v.DurationMs, _ = m["durationMs"].(int64)
|
||||
v.CostMs, _ = m["costMs"].(int)
|
||||
v.BudgetMs, _ = m["budgetMs"].(int)
|
||||
return v
|
||||
}
|
||||
|
||||
// degradedVoice 把 speak 的失败翻成「想用语音但退回了文本」。原因码
|
||||
// 优先取结构化错误的 Code(超时/无音频/落盘失败/文本过长),非结构化
|
||||
// 失败一律归 tts_failed,绝不把空原因码交给客户端。
|
||||
func degradedVoice(err error) *OutboundVoice {
|
||||
v := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
|
||||
var se *speakError
|
||||
if !errors.As(err, &se) {
|
||||
return v
|
||||
}
|
||||
if se.Code != "" {
|
||||
v.FallbackReason = se.Code
|
||||
}
|
||||
v.BudgetMs = se.BudgetMs
|
||||
v.CostMs = se.CostMs
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件锁住「speak 调用 → Result.Voice」这条出站链(M57):
|
||||
// 成功、降级、没调三态在叶子层,加上一次端到端回合,证明模型面真的
|
||||
// 被广告了 speak、调用真的被执行、结论真的落在 Result 上。
|
||||
|
||||
// TestCaptureVoice_States 锁住三态:成功带交付物、失败带原因码与
|
||||
// 预算/耗时、没调(或只调了别的工具)返回 nil。
|
||||
func TestCaptureVoice_States(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(Options{})
|
||||
|
||||
if v := l.captureVoice(
|
||||
[]agent.ToolCall{{Name: "terminal"}},
|
||||
[]ToolOutcome{{Result: "ok"}},
|
||||
); v != nil {
|
||||
t.Fatalf("non-speak batch voice = %+v, want nil", v)
|
||||
}
|
||||
|
||||
ok := l.captureVoice(
|
||||
[]agent.ToolCall{{Name: speakToolName}},
|
||||
[]ToolOutcome{{Result: map[string]any{
|
||||
"name": "abc.wav", "mime": "audio/wav", "size": int64(9),
|
||||
"durationMs": int64(0), "budgetMs": 3025, "costMs": 120,
|
||||
}}},
|
||||
)
|
||||
if ok == nil || !ok.Requested {
|
||||
t.Fatalf("success voice = %+v, want Requested", ok)
|
||||
}
|
||||
if ok.Name != "abc.wav" || ok.Mime != "audio/wav" || ok.Size != 9 {
|
||||
t.Fatalf("success voice = %+v, want the stored ref", ok)
|
||||
}
|
||||
if ok.BudgetMs != 3025 || ok.CostMs != 120 || ok.DurationMs != 0 {
|
||||
t.Fatalf("success voice = %+v, want budget/cost and unparsed duration", ok)
|
||||
}
|
||||
if ok.FallbackReason != "" {
|
||||
t.Fatalf("success FallbackReason = %q, want empty", ok.FallbackReason)
|
||||
}
|
||||
|
||||
bad := l.captureVoice(
|
||||
[]agent.ToolCall{{Name: speakToolName}},
|
||||
[]ToolOutcome{{Err: &speakError{Code: speakReasonTimeout, BudgetMs: 3000, CostMs: 3100}}},
|
||||
)
|
||||
if bad == nil || !bad.Requested {
|
||||
t.Fatalf("failure voice = %+v, want Requested", bad)
|
||||
}
|
||||
if bad.FallbackReason != speakReasonTimeout || bad.BudgetMs != 3000 || bad.CostMs != 3100 {
|
||||
t.Fatalf("failure voice = %+v, want the timeout code and budget/cost", bad)
|
||||
}
|
||||
if bad.Name != "" {
|
||||
t.Fatalf("failure name = %q, want empty(没有交付物)", bad.Name)
|
||||
}
|
||||
|
||||
// 非结构化失败也要有原因码,绝不把空串交给客户端。
|
||||
raw := l.captureVoice(
|
||||
[]agent.ToolCall{{Name: speakToolName}},
|
||||
[]ToolOutcome{{Err: errors.New("boom")}},
|
||||
)
|
||||
if raw == nil || raw.FallbackReason != speakReasonFailed {
|
||||
t.Fatalf("raw failure voice = %+v, want %q", raw, speakReasonFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureVoice_LastSuccessWins 锁住多次 speak 的取用规则:成功优先
|
||||
// 于失败(音频已经落盘就交付),同类取最后一个(这条消息最后的意愿)。
|
||||
func TestCaptureVoice_LastSuccessWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(Options{})
|
||||
calls := []agent.ToolCall{{Name: speakToolName}, {Name: speakToolName}, {Name: speakToolName}}
|
||||
|
||||
// 先成功、后失败:交付已落盘的那份。
|
||||
got := l.captureVoice(calls[:2], []ToolOutcome{
|
||||
{Result: map[string]any{"name": "first.wav", "mime": "audio/wav", "size": int64(1)}},
|
||||
{Err: &speakError{Code: speakReasonFailed}},
|
||||
})
|
||||
if got == nil || got.Name != "first.wav" {
|
||||
t.Fatalf("voice = %+v, want first.wav(成功优先于后续失败)", got)
|
||||
}
|
||||
|
||||
// 两次都成功:取后者。
|
||||
got = l.captureVoice(calls[:2], []ToolOutcome{
|
||||
{Result: map[string]any{"name": "first.wav"}},
|
||||
{Result: map[string]any{"name": "second.wav"}},
|
||||
})
|
||||
if got == nil || got.Name != "second.wav" {
|
||||
t.Fatalf("voice = %+v, want second.wav(后者覆盖前者)", got)
|
||||
}
|
||||
|
||||
// 被跳过的调用不算数。
|
||||
if v := l.captureVoice(calls[:1], []ToolOutcome{{Skipped: true, Reason: "duplicate"}}); v != nil {
|
||||
t.Fatalf("skipped voice = %+v, want nil", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_VoiceResult 是一次端到端回合:speak 出现在模型面里、被调用
|
||||
// 后真的走了注入的合成与落盘,结论落在 Result.Voice 上。
|
||||
func TestRun_VoiceResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
|
||||
ID: "call_speak_1",
|
||||
Name: speakToolName,
|
||||
Args: map[string]any{"text": "晚安", "emotion": "温柔"},
|
||||
}}}
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||||
td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})
|
||||
synth := &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}}, provider: "emotiontts"}
|
||||
store := &stubStore{ref: VoiceRef{Name: "abc.wav", MIME: "audio/wav", Size: 3}}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, td),
|
||||
MaxRounds: 8,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
VoiceSynth: synth,
|
||||
VoiceStore: store,
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.ToolCallsExecuted != 1 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
|
||||
}
|
||||
// ① 模型面确实被广告了 speak(否则模型不可能调它)。
|
||||
if len(ad.streamReqs) == 0 {
|
||||
t.Fatal("no streaming request captured")
|
||||
}
|
||||
if set := toolSet(ad.streamReqs[0].Tools); !set[speakToolName] {
|
||||
t.Fatalf("advertised tools = %v, want %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
|
||||
}
|
||||
// ② 调用真的走了注入的合成与落盘。
|
||||
if synth.gotText != "晚安" {
|
||||
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
|
||||
}
|
||||
if store.gotName != "speak.wav" {
|
||||
t.Fatalf("origName = %q, want speak.wav", store.gotName)
|
||||
}
|
||||
// ③ 结论落在 Result.Voice 上。
|
||||
if res.Voice == nil {
|
||||
t.Fatal("Result.Voice = nil, want the synthesized audio")
|
||||
}
|
||||
if !res.Voice.Requested || res.Voice.Name != "abc.wav" || res.Voice.Mime != "audio/wav" || res.Voice.Size != 3 {
|
||||
t.Fatalf("Result.Voice = %+v, want the stored ref", res.Voice)
|
||||
}
|
||||
if res.Voice.FallbackReason != "" {
|
||||
t.Fatalf("FallbackReason = %q, want empty", res.Voice.FallbackReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_VoiceDegraded 锁住端到端的降级:合成失败时回合照常结束
|
||||
// (绝不因语音失败而失败),Result.Voice 带上原因码让客户端特殊显示。
|
||||
func TestRun_VoiceDegraded(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
|
||||
ID: "call_speak_2",
|
||||
Name: speakToolName,
|
||||
Args: map[string]any{"text": "晚安"},
|
||||
}}}
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, dispatcherWith(t)),
|
||||
MaxRounds: 8,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
VoiceSynth: &stubSynth{err: errors.New("upstream down")},
|
||||
VoiceStore: &stubStore{},
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Text != "晚安" {
|
||||
t.Fatalf("Text = %q, want the text answer(语音失败不得拖垮回合)", res.Text)
|
||||
}
|
||||
if res.Voice == nil || res.Voice.FallbackReason != speakReasonFailed {
|
||||
t.Fatalf("Result.Voice = %+v, want %q", res.Voice, speakReasonFailed)
|
||||
}
|
||||
if res.Voice.Requested != true {
|
||||
t.Fatalf("Result.Voice.Requested = %v, want true(确实想用语音)", res.Voice.Requested)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_NoSpeakNoVoice 锁住「不接线就没有语音面」:未注入依赖时
|
||||
// 模型面里没有 speak,Result.Voice 保持 nil —— 与接线前逐字节一致。
|
||||
func TestRun_NoSpeakNoVoice(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})),
|
||||
MaxRounds: 8,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Voice != nil {
|
||||
t.Fatalf("Result.Voice = %+v, want nil", res.Voice)
|
||||
}
|
||||
if len(ad.streamReqs) == 0 {
|
||||
t.Fatal("no streaming request captured")
|
||||
}
|
||||
if set := toolSet(ad.streamReqs[0].Tools); set[speakToolName] {
|
||||
t.Fatalf("advertised tools = %v, must not contain %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeTool_SpeakWithoutDispatcher 锁住 speak 不依赖 dispatcher:
|
||||
// 未接线时普通工具仍按原样报「没有 dispatcher」,接线后 speak 照常执行。
|
||||
func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
wired := New(Options{
|
||||
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
|
||||
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
|
||||
})
|
||||
out := wired.invokeOne(context.Background(), nil, agent.ToolCall{
|
||||
Name: speakToolName, Args: map[string]any{"text": "晚安"},
|
||||
})
|
||||
if out.Err != nil {
|
||||
t.Fatalf("speak without dispatcher err = %v, want nil", out.Err)
|
||||
}
|
||||
|
||||
plain := New(Options{})
|
||||
out = plain.invokeOne(context.Background(), nil, agent.ToolCall{Name: "terminal"})
|
||||
if out.Err == nil || out.ErrKind != tool.KindExecution {
|
||||
t.Fatalf("nil dispatcher outcome = %+v, want an execution error", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchVoice_KeepsCrossBatchSuccess 锁住跨批的语音优先级:批次之间
|
||||
// 是两次独立结论,后一批换个参数失败时**绝不能**把前一批已落盘的音频抹成
|
||||
// tts_failed —— 交付物已经存在,退回文本既没有收益也违背她那次表达的意愿。
|
||||
// 反过来(先降级后成功)后到的成功仍然覆盖;后一批没有 speak 时保留结论。
|
||||
func TestApplyBatchVoice_KeepsCrossBatchSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
delivered := &OutboundVoice{Requested: true, Name: "abc.wav", Mime: "audio/wav", Size: 3}
|
||||
degraded := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
|
||||
|
||||
res := &Result{}
|
||||
applyBatchVoice(res, &toolRunResult{voice: delivered})
|
||||
applyBatchVoice(res, &toolRunResult{voice: degraded})
|
||||
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
|
||||
t.Fatalf("success then cross-batch failure voice = %+v, want the delivered abc.wav", res.Voice)
|
||||
}
|
||||
|
||||
// 先降级后成功:后到的成功就是这条消息最终的语音。
|
||||
res = &Result{}
|
||||
applyBatchVoice(res, &toolRunResult{voice: degraded})
|
||||
applyBatchVoice(res, &toolRunResult{voice: delivered})
|
||||
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
|
||||
t.Fatalf("degraded then success voice = %+v, want abc.wav", res.Voice)
|
||||
}
|
||||
|
||||
// 后一批没有 speak:前面那批的结论照旧算数。
|
||||
res = &Result{}
|
||||
applyBatchVoice(res, &toolRunResult{voice: delivered})
|
||||
applyBatchVoice(res, &toolRunResult{})
|
||||
if res.Voice == nil || res.Voice.Name != "abc.wav" {
|
||||
t.Fatalf("batch without voice = %+v, want the earlier abc.wav", res.Voice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且
|
||||
// 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上,
|
||||
// 而不让循环反向 import documents)。
|
||||
func TestVoiceStoreFunc(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotName string
|
||||
var gotData []byte
|
||||
var store VoiceStore = VoiceStoreFunc(func(_ context.Context, origName string, data []byte) (VoiceRef, error) {
|
||||
gotName, gotData = origName, data
|
||||
return VoiceRef{Name: "saved.wav", MIME: "audio/wav", Size: int64(len(data))}, nil
|
||||
})
|
||||
|
||||
ref, err := store.SaveAudio(context.Background(), "speak.wav", []byte{7, 8})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveAudio: %v", err)
|
||||
}
|
||||
if gotName != "speak.wav" || len(gotData) != 2 {
|
||||
t.Fatalf("forwarded (%q, %d bytes), want (speak.wav, 2 bytes)", gotName, len(gotData))
|
||||
}
|
||||
if ref.Name != "saved.wav" || ref.Size != 2 {
|
||||
t.Fatalf("ref = %+v, want the adapter's own result", ref)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ const (
|
||||
// SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`):
|
||||
// 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。
|
||||
SourcePlatform Source = "platform"
|
||||
// SourceTTS 表示正文是我们自己合成的语音(出站方向,design/25 §7.1):成功时
|
||||
// Name 指向那份可播副本,降级时 Status=failed 且正文照发文本、只带降级原因。
|
||||
SourceTTS Source = "tts"
|
||||
)
|
||||
|
||||
// Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文
|
||||
@@ -39,7 +42,8 @@ const (
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写)。
|
||||
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写);出站方向表示
|
||||
// **合成成功**(正文即这段语音的文字内容,Name 指向可播副本)。
|
||||
StatusTranscribed Status = "transcribed"
|
||||
// StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常
|
||||
// 的空结果,**不是**故障。
|
||||
@@ -85,6 +89,10 @@ type Ref struct {
|
||||
// DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0
|
||||
// (编码时该键缺席)。**绝不估算**(design/24 §10.4)。
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
// FallbackReason 只在**出站合成降级**时非空(Source=tts、Status=failed):说明为什么
|
||||
// 这条消息没能变成语音(能力未接线 / 预算超时 / 合成为空)。入站转写不使用它 ——
|
||||
// 入站的失败原因由正文那行诚实提示承载。
|
||||
FallbackReason string `json:"fallbackReason,omitempty"`
|
||||
}
|
||||
|
||||
// IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。
|
||||
@@ -110,6 +118,11 @@ func (r Ref) Validate() error {
|
||||
if r.DurationMs < 0 {
|
||||
return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs)
|
||||
}
|
||||
// 降级原因只属于失败态:成功还带原因会让人以为「其实降级了」,客户端也就无从按
|
||||
// status 单点分支(design/24 §10.4 的「客户端绝不按文案分支」同一条纪律)。
|
||||
if r.FallbackReason != "" && r.Status != StatusFailed {
|
||||
return fmt.Errorf("%w: fallbackReason with status %q", ErrBadRef, r.Status)
|
||||
}
|
||||
switch {
|
||||
case r.Name == "" && (r.MIME != "" || r.Size != 0):
|
||||
return fmt.Errorf("%w: mime/size without name", ErrBadRef)
|
||||
@@ -124,7 +137,7 @@ func (r Ref) Validate() error {
|
||||
// IsZero 报告这条事实是零值(= 这条消息不是语音消息)。
|
||||
func (r Ref) IsZero() bool {
|
||||
return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" &&
|
||||
r.Size == 0 && r.DurationMs == 0
|
||||
r.Size == 0 && r.DurationMs == 0 && r.FallbackReason == ""
|
||||
}
|
||||
|
||||
// Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里
|
||||
@@ -167,7 +180,9 @@ func Decode(raw string) (Ref, error) {
|
||||
}
|
||||
|
||||
// validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。
|
||||
func validSource(s Source) bool { return s == SourceASR || s == SourcePlatform }
|
||||
func validSource(s Source) bool {
|
||||
return s == SourceASR || s == SourcePlatform || s == SourceTTS
|
||||
}
|
||||
|
||||
func validStatus(s Status) bool {
|
||||
return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed
|
||||
|
||||
@@ -31,6 +31,14 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
|
||||
MIME: "audio/mpeg", Size: 4321,
|
||||
}},
|
||||
{"出站合成成功 + 可播副本", Ref{
|
||||
Source: SourceTTS, Status: StatusTranscribed,
|
||||
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg",
|
||||
Size: 8192, DurationMs: 1500,
|
||||
}},
|
||||
{"出站合成降级(正文照发文本)", Ref{
|
||||
Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded",
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -119,6 +127,11 @@ func TestEncodeRejectsBrokenRef(t *testing.T) {
|
||||
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
|
||||
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
|
||||
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
|
||||
{"降级原因挂在成功态", Ref{
|
||||
Source: SourceTTS, Status: StatusTranscribed,
|
||||
Name: "x.ogg", MIME: "audio/ogg", Size: 12, FallbackReason: "budget_exceeded",
|
||||
}},
|
||||
{"只有降级原因", Ref{FallbackReason: "budget_exceeded"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -149,6 +162,7 @@ func TestDecodeRejectsGarbage(t *testing.T) {
|
||||
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
|
||||
{"闭集外的状态", `{"source":"asr","status":"done"}`},
|
||||
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
|
||||
{"降级原因挂在成功态", `{"source":"tts","status":"transcribed","fallbackReason":"x"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -164,6 +178,33 @@ func TestDecodeRejectsGarbage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboundTTSJSONShape 把**出站**两种落库形状钉死(design/25 §7.1):
|
||||
// 成功 = source/status/name/mime/size(+ 可选 durationMs);降级 = source/status +
|
||||
// fallbackReason,**没有 name/url**(正文照发文本,客户端据此只挂一个小标)。
|
||||
func TestOutboundTTSJSONShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(Ref{
|
||||
Source: SourceTTS, Status: StatusTranscribed,
|
||||
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg", Size: 8192,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(成功): %v", err)
|
||||
}
|
||||
want := `{"source":"tts","status":"transcribed","name":"fedcba9876543210fedcba9876543210.ogg",` +
|
||||
`"mime":"audio/ogg","size":8192}`
|
||||
if raw != want {
|
||||
t.Fatalf("出站成功 JSON = %s\n want %s", raw, want)
|
||||
}
|
||||
|
||||
raw, err = Encode(Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded"})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(降级): %v", err)
|
||||
}
|
||||
if want := `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`; raw != want {
|
||||
t.Fatalf("出站降级 JSON = %s\n want %s", raw, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
|
||||
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
|
||||
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
|
||||
@@ -178,6 +219,8 @@ func TestRefIsVoiceAndHasCopy(t *testing.T) {
|
||||
{"零值(不是语音消息)", Ref{}, false, false},
|
||||
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
|
||||
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
|
||||
{"出站合成成功", Ref{Source: SourceTTS, Status: StatusTranscribed, Name: "n.ogg", MIME: "audio/ogg", Size: 4}, true, true},
|
||||
{"出站降级(算语音消息,但无可播副本)", Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "no_capability"}, true, false},
|
||||
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
|
||||
|
||||
// sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送)
|
||||
// 共用的唯一 IM 出站出口。它把内容变成 IM 渠道应收到的纯聊天气泡:
|
||||
// 共用的唯一 IM 出站出口(带音频的回复走 sendIMBubblesVoice,它只是多挂一条 Media)。
|
||||
// 它把内容变成 IM 渠道应收到的聊天气泡:
|
||||
// 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离,
|
||||
// 每个 <p> 段落作为独立
|
||||
// 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s,
|
||||
@@ -25,10 +26,19 @@ type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
|
||||
//
|
||||
// target 携带平台/渠道路由字段,并被复制到每个
|
||||
// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律
|
||||
// 不带 —— 见 outboundBubble);sender 是
|
||||
// 不带 —— 见 outboundRoute);sender 是
|
||||
// 适配器的出站调用。被剥成空的内容(纯动作行)
|
||||
// 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。
|
||||
func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error {
|
||||
return b.sendIMBubblesVoice(ctx, target, sender, content, nil)
|
||||
}
|
||||
|
||||
// sendIMBubblesVoice 是 sendIMBubbles 的**带音频**变体:voice 非 nil 时,那一份音频只
|
||||
// 挂在**第一个**气泡上,其余气泡仍是纯文本。这条「只挂第一个」不是优化而是正确性 ——
|
||||
// 同一条合成语音若跟着每个气泡各发一次,对方会收到 N 份同样的音频。
|
||||
//
|
||||
// voice 为 nil 时行为与 sendIMBubbles 逐字相同(因此主动推送等纯文本出口不受影响)。
|
||||
func (b *Bridge) sendIMBubblesVoice(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string, voice *adapter.Media) error {
|
||||
// splitBubbles 对每个气泡应用 stripNarration(因此 <action>/<narration> 与
|
||||
// 星号漂移的角色扮演形式都会为 IM 剥离),并按 <p> 切分。它是
|
||||
// 唯一的清洗入口,与旧的回复路径共用。
|
||||
@@ -42,6 +52,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
|
||||
time.Sleep(b.bubbleGapFn())
|
||||
}
|
||||
out := outboundBubble(target, bubble)
|
||||
if i == 0 && voice != nil {
|
||||
out = outboundVoiceBubble(target, bubble, *voice)
|
||||
}
|
||||
// 每个气泡各自发送一次,间隔才会落在消息之间。
|
||||
if err := sender(ctx, out); err != nil {
|
||||
return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err)
|
||||
@@ -50,7 +63,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
|
||||
return nil
|
||||
}
|
||||
|
||||
// outboundBubble 依据一条入站消息的**路由字段**构造一个纯文本出站气泡。
|
||||
// outboundRoute 依据一条入站消息的**路由字段**构造一个出站信封:只有路由字段,
|
||||
// 没有内容。outboundBubble(纯文本)与 outboundVoiceBubble(文本 + 音频)都从它出发,
|
||||
// 因此「哪些字段允许出站」只有一份白名单。
|
||||
//
|
||||
// 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的
|
||||
// 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称)
|
||||
@@ -60,8 +75,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
|
||||
// 之后**整条返回错误,主人连文本都收不到。
|
||||
//
|
||||
// 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage
|
||||
// 的字段默认不外发:出站是纯文本气泡,本派发器只接收文本。
|
||||
func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage {
|
||||
// 的字段默认不外发。⚠️ 出站的 `Media` 只能由出站语音自己填(那份副本是我们刚合成的,
|
||||
// 见 outbound_voice.go),**绝不**从 target 继承。
|
||||
func outboundRoute(target adapter.UnifiedMessage) adapter.UnifiedMessage {
|
||||
return adapter.UnifiedMessage{
|
||||
// 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。
|
||||
Platform: target.Platform,
|
||||
@@ -80,9 +96,29 @@ func outboundBubble(target adapter.UnifiedMessage, content string) adapter.Unifi
|
||||
IsGroup: target.Sender.IsGroup,
|
||||
GroupID: target.Sender.GroupID,
|
||||
},
|
||||
// 本气泡的内容:出站永远是纯文本。
|
||||
Content: content,
|
||||
ContentType: adapter.ContentText,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// outboundBubble 构造一个**纯文本**出站气泡:路由字段 + 文本内容。
|
||||
func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage {
|
||||
out := outboundRoute(target)
|
||||
out.Content = content
|
||||
out.ContentType = adapter.ContentText
|
||||
out.Timestamp = time.Now().UTC()
|
||||
return out
|
||||
}
|
||||
|
||||
// outboundVoiceBubble 构造**带音频**的出站气泡:与纯文本气泡同一套路由字段与同一份
|
||||
// 正文,只是把 ContentType 标成 voice 并挂上那一条 Media。
|
||||
//
|
||||
// 正文**照发**(不是「有音频就不发文字」):适配器把它当「文字 item + 语音 item」一起
|
||||
// 投递(weixin_ilink 的 sendToOne),对方既看得到文字也点得开音频;而任何一侧失败都
|
||||
// 只影响那一侧(音频发不出去时文字仍在)。
|
||||
func outboundVoiceBubble(target adapter.UnifiedMessage, content string, md adapter.Media) adapter.UnifiedMessage {
|
||||
out := outboundRoute(target)
|
||||
out.Content = content
|
||||
out.ContentType = adapter.ContentVoice
|
||||
out.Media = []adapter.Media{md}
|
||||
out.Timestamp = time.Now().UTC()
|
||||
return out
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func (b *Bridge) persistBatchUsers(ctx context.Context, parts []batchMessage, ta
|
||||
notify := b.persistVoiceFirstNotify(p.voiceRef)
|
||||
var row history.Message
|
||||
if target.private {
|
||||
row = b.persistMain(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image,
|
||||
row = b.persistMainWith(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image,
|
||||
target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
|
||||
} else {
|
||||
row = b.persistIsolatedWith(ctx, isolatedRow(history.RoleUser, p.prepared.text, p.document, p.image,
|
||||
|
||||
@@ -193,15 +193,31 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
if res != nil {
|
||||
mood = res.Mood
|
||||
}
|
||||
// 出站语音(design/25 §7.1):先把结算结论折成 voice 列事实,再让落库那条路走
|
||||
// 「INSERT → 写 voice 列 → notify」(M44 首帧纪律)—— 与入站语音共用同一份
|
||||
// persistVoiceFirstNotify,因此首帧上就带着 `voice`,客户端不必等重连。
|
||||
ref, hasVoice := b.outboundVoiceRef(resultVoice(res))
|
||||
notify := b.notifyPersisted
|
||||
if hasVoice {
|
||||
notify = b.persistVoiceFirstNotify(ref)
|
||||
}
|
||||
// 持久化助手回复(主人私聊 → 主会话 + message_persisted
|
||||
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
|
||||
// 送回渠道。
|
||||
if target.private {
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID)
|
||||
b.persistMainWith(ctx, mainRow(history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID)
|
||||
b.persistIsolatedWith(ctx, isolatedRow(history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID), notify)
|
||||
}
|
||||
if err := b.sendReply(ctx, msg, a, reply); err != nil {
|
||||
// 音频只在合成成功、且盘上那份副本读得回来时才挂上;读不回来就诚实退回纯文本
|
||||
// (正文照发,WARN 已由 outboundVoiceMedia 记下)。
|
||||
var voiceMedia *adapter.Media
|
||||
if hasVoice {
|
||||
if md, ok := b.outboundVoiceMedia(msg, ref); ok {
|
||||
voiceMedia = &md
|
||||
}
|
||||
}
|
||||
if err := b.sendReplyVoice(ctx, msg, a, reply, voiceMedia); err != nil {
|
||||
if errors.Is(err, adapter.ErrUnsupported) {
|
||||
b.log.Debug("channel does not support outbound reply, skipped",
|
||||
"platform", msg.Platform, "session", target.sessionID)
|
||||
|
||||
@@ -116,6 +116,9 @@ func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.Unified
|
||||
// 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST
|
||||
// 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。
|
||||
//
|
||||
// 入站与出站**共用**它:两边都是「结论已经在手里、列还没写」的同一形状(入站是转写结论,
|
||||
// 出站是合成结论),首帧纪律因此只有一处实现。
|
||||
//
|
||||
// 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法,
|
||||
// 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回,
|
||||
// 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。
|
||||
@@ -126,9 +129,9 @@ func (b *Bridge) persistVoiceFirstNotify(ref voiceref.Ref) func(history.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的用户消息 `voice` 列
|
||||
// (design/24 §10.2 第 5 步)。它由 runInboundTurn 在用户消息落库之后调用,因此
|
||||
// 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
|
||||
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的消息 `voice` 列
|
||||
// (design/24 §10.2 第 5 步;出站方向同列同形)。它由落库那条路在消息写进去之后调用,
|
||||
// 因此「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
|
||||
//
|
||||
// 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库
|
||||
// (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。
|
||||
@@ -152,7 +155,7 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
|
||||
if err != nil {
|
||||
// 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截,
|
||||
// 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。
|
||||
b.voiceLogger().Error("inbound voice ref not persisted: encode failed",
|
||||
b.voiceLogger().Error("voice ref not persisted: encode failed",
|
||||
"message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err)
|
||||
return
|
||||
}
|
||||
@@ -163,13 +166,13 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
|
||||
if !ok {
|
||||
// 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径):
|
||||
// 如实报告「没写成」,绝不假装写了。
|
||||
b.voiceLogger().Warn("inbound voice ref not persisted",
|
||||
b.voiceLogger().Warn("voice ref not persisted",
|
||||
"reason", "history store has no voice column path", "message", msg.ID,
|
||||
"source", ref.Source, "status", ref.Status)
|
||||
return
|
||||
}
|
||||
if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil {
|
||||
b.voiceLogger().Warn("inbound voice ref update",
|
||||
b.voiceLogger().Warn("voice ref update",
|
||||
"err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,20 @@ import (
|
||||
// 使适配器解析出消息来自的同一对端,并经
|
||||
// 共享的 IM 气泡派发器出口(剥离角色扮演/旁白 + 按 <p> 切分 + 0.5-2s 间隔)。
|
||||
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
|
||||
return b.sendReplyVoice(ctx, in, a, text, nil)
|
||||
}
|
||||
|
||||
// sendReplyVoice 是 sendReply 的**带音频**变体:voice 非 nil 时随第一个气泡一起送出
|
||||
// (出站语音,design/25 §7.1)。正文照发文本 —— 音频是这条回复的另一种呈现,不是
|
||||
// 文本的替代品,因此音频发不出去时对方仍读得到字。
|
||||
func (b *Bridge) sendReplyVoice(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string, voice *adapter.Media) error {
|
||||
if a == nil {
|
||||
a = b.adapter(in.Platform)
|
||||
}
|
||||
if a == nil {
|
||||
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
|
||||
}
|
||||
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
|
||||
return b.sendIMBubblesVoice(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
|
||||
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
|
||||
}, text)
|
||||
}, text, voice)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件承载**出站语音**(design/25 §7.1;切片 M58):把循环结算出的
|
||||
// `loop.OutboundVoice`(M57 的 speak 工具结论)折成两件东西 ——
|
||||
//
|
||||
// 1. 一条 `voice` 列事实(`voiceref.Ref`,source=tts):成功即
|
||||
// `{source:"tts",status:"transcribed",name,mime,size,durationMs}`,降级即
|
||||
// `{source:"tts",status:"failed",fallbackReason}`。它由 deliverReply 交给与入站
|
||||
// **同一条**「先写列、再广播」的落库口径(persistVoiceFirstNotify),因此出站语音
|
||||
// 的**第一帧**就带着 `voice`(M44 首帧纪律,入站已有先例)。
|
||||
// 2. 一份出站 `adapter.Media`:把盘上那份副本读回字节挂到第一个气泡上,渠道才真的
|
||||
// 发得出去音频(bubbles.go 的 outboundVoiceBubble)。
|
||||
//
|
||||
// 三条口径:
|
||||
// - **绝不写半截**:副本三字段(name/mime/size)不齐就不写 `transcribed`,宁可降级成
|
||||
// `failed` + 机器可读原因 —— 一个没有 mime 的 name 会让客户端拼出读不懂的对象。
|
||||
// - **拿不到字节就不挂音频**:附件存储未接线、或副本读不出来时,正文照发文本、只留一条
|
||||
// WARN。这与入站「没有字节就不落副本」是同一条诚实纪律的反方向。
|
||||
// - **绝不静默**:每一类失败都有一条带来源(`services/channelbridge/voice`)的日志。
|
||||
//
|
||||
// ⚠️ 出站 Media 的 URL 刻意留空:`/api/v1/media/<name>` 走 Bearer 鉴权(gateway 的
|
||||
// writeAuth),适配器没有、也不该有 owner 令牌,因此它只能拿**字节**,不能自取 URL。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// outboundVoiceFallbackCopyIncomplete 是本模块自己产生的降级码(speak 工具不会给出它):
|
||||
// 合成报告了成功,但副本事实不完整(缺 mime/size,或 name 不是合法的内容寻址名)。
|
||||
// 这是上游缺陷、不该发生,因此记 ERROR;但写进列里的原因仍要机器可读 —— 客户端据此
|
||||
// 与真实降级一样只挂小标,而不是去点一个播不出来的播放器。
|
||||
const outboundVoiceFallbackCopyIncomplete = "copy_incomplete"
|
||||
|
||||
// resultVoice 取出这一回合的出站语音结论;回合报错(res 为 nil)时没有结论。
|
||||
func resultVoice(res *loop.Result) *loop.OutboundVoice {
|
||||
if res == nil {
|
||||
return nil
|
||||
}
|
||||
return res.Voice
|
||||
}
|
||||
|
||||
// outboundVoiceRef 把出站语音结论折成一条 `voice` 列事实。第二个返回值报告「这一回合
|
||||
// 要不要写 voice 列」—— false 表示这次回复就是纯文本,落库路径与引入本文件之前逐字相同。
|
||||
//
|
||||
// 三种结局(与 loop.OutboundVoice 的注释一一对应):
|
||||
// - 未请求(`v == nil` 或 `!v.Requested`)⇒ 不写列,返回 false;
|
||||
// - 合成成功且有完整副本 ⇒ `transcribed` + 副本事实;
|
||||
// - 请求了但没有音频 ⇒ `failed` + 降级原因(来自 speak 工具,或本模块的 copy_incomplete)。
|
||||
//
|
||||
// 「Requested 却既无副本又无原因」是上游契约被破坏的状态:不写列(写一个没有原因的
|
||||
// failed 等于把「为什么」丢掉),但必须 WARN —— 静默吞掉会让这条语音彻底看不见。
|
||||
func (b *Bridge) outboundVoiceRef(v *loop.OutboundVoice) (voiceref.Ref, bool) {
|
||||
if v == nil || !v.Requested {
|
||||
return voiceref.Ref{}, false
|
||||
}
|
||||
log := b.voiceLogger()
|
||||
if v.Name != "" {
|
||||
if v.Mime == "" || v.Size <= 0 || !voiceref.ValidMediaName(v.Name) {
|
||||
log.Error("outbound voice copy is incomplete, degraded to text",
|
||||
"name", v.Name, "mime", v.Mime, "size", v.Size,
|
||||
"duration_ms", v.DurationMs)
|
||||
return voiceref.Ref{
|
||||
Source: voiceref.SourceTTS, Status: voiceref.StatusFailed,
|
||||
FallbackReason: outboundVoiceFallbackCopyIncomplete,
|
||||
}, true
|
||||
}
|
||||
ref := voiceref.Ref{
|
||||
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
|
||||
Name: v.Name, MIME: v.Mime, Size: v.Size,
|
||||
}
|
||||
// 时长只取上游如实给出且非负的值:Ref.Validate 拒绝负数,绝不为了「有个数」
|
||||
// 而估算(与入站同一口径)。
|
||||
if v.DurationMs > 0 {
|
||||
ref.DurationMs = v.DurationMs
|
||||
}
|
||||
return ref, true
|
||||
}
|
||||
if v.FallbackReason != "" {
|
||||
return voiceref.Ref{
|
||||
Source: voiceref.SourceTTS, Status: voiceref.StatusFailed,
|
||||
FallbackReason: v.FallbackReason,
|
||||
}, true
|
||||
}
|
||||
log.Warn("outbound voice requested without copy or fallback reason, treated as text",
|
||||
"cost_ms", v.CostMs, "budget_ms", v.BudgetMs)
|
||||
return voiceref.Ref{}, false
|
||||
}
|
||||
|
||||
// outboundVoiceMedia 把盘上那份副本读回字节,折成一个出站媒体项(design/25 §7.1)。
|
||||
// 第二个返回值报告「挂不挂得上」:false 时调用方照发纯文本,绝不发一个空音频。
|
||||
//
|
||||
// 目录与上限来自组合根注入的文档存储配置(与入站附件同一份 `documentsCfg`):副本就是
|
||||
// `documents.SaveAudio` 落下的内容寻址文件,因此 `Dir()` + 存储名就是它的路径 —— 不需要
|
||||
// (也没有)一条按名读回的存储 API。名字再校验一次是纵深防御:`filepath.Join` 前面绝不
|
||||
// 允许出现未经验证的渠道/上游字符串。
|
||||
func (b *Bridge) outboundVoiceMedia(msg adapter.UnifiedMessage, ref voiceref.Ref) (adapter.Media, bool) {
|
||||
if !ref.HasCopy() {
|
||||
return adapter.Media{}, false
|
||||
}
|
||||
log := b.voiceLogger()
|
||||
if !voiceref.ValidMediaName(ref.Name) {
|
||||
log.Error("outbound voice media skipped: illegal copy name", "name", ref.Name)
|
||||
return adapter.Media{}, false
|
||||
}
|
||||
store := b.inboundDocumentStore(msg)
|
||||
if store == nil || store.Dir() == "" {
|
||||
log.Warn("outbound voice media skipped: document store not wired",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name)
|
||||
return adapter.Media{}, false
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(store.Dir(), ref.Name))
|
||||
if err != nil {
|
||||
log.Warn("outbound voice media skipped: copy unreadable",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name, "error", err)
|
||||
return adapter.Media{}, false
|
||||
}
|
||||
if int64(len(data)) != ref.Size {
|
||||
// 列里的 size 与实际字节不一致:以实际字节为准(发出去的就是这些),但要说出来。
|
||||
log.Warn("outbound voice copy size differs from column",
|
||||
"name", ref.Name, "column_size", ref.Size, "actual_size", len(data))
|
||||
}
|
||||
return adapter.Media{
|
||||
Kind: adapter.MediaVoice,
|
||||
Name: ref.Name,
|
||||
Size: int64(len(data)),
|
||||
MIMEType: ref.MIME,
|
||||
Data: data,
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件是 M58 的**跨层整链**验收:出站语音结论(`loop.Result.Voice`,M57 的 speak 工具
|
||||
// 结算)→ 桥接器落库并写 `chat_messages.voice` 列 → 真网关的**实时帧** + REST 读面;
|
||||
// 以及同一回合的出站 `adapter.Media` 挂载。
|
||||
//
|
||||
// 手法与入站整链(inbound_voice_gateway_e2e_test.go)同源:**同一个**真 `history.Store`
|
||||
// 喂给桥接器与网关,断言因此打在真 WS 帧与真 REST 读面上,而不是两个替身各自的想象。
|
||||
// 唯一的差别是驱动方式:出站结论来自循环,所以这里用 `stubChatter` 直接给出带 `Voice`
|
||||
// 的 `Result` —— 真循环 + 假模型跑不出 `Result.Voice`(假模型不会去调 speak 工具),
|
||||
// 而本切片要钉的正是「结论已经在手里之后」那一段。
|
||||
//
|
||||
// 钉住四件事:
|
||||
// 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键),
|
||||
// 重新连 WS 的握手回放与 REST 读面逐字相同;
|
||||
// 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST、握手回放)
|
||||
// 上都可见,且正文照发原文(降级不是「少说一句话」);
|
||||
// 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音);
|
||||
// 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"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/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
const (
|
||||
// outboundVoiceText 是所有出站用例的回复正文(单段,便于把「正文原样」写成字面量)。
|
||||
outboundVoiceText = "晚安啦,做个好梦。"
|
||||
// outboundVoiceStoredText 是它经回合可见回复规整(turnreply.NormalizeParagraphs)后
|
||||
// **真正落库、真正上帧**的文本:单段被包进一对 `<p>`。降级用例按这个字面量比对,
|
||||
// 因此「合成失败有没有动正文」是一个逐字问题,而不是「大概还在」。
|
||||
outboundVoiceStoredText = "<p>晚安啦,做个好梦。</p>"
|
||||
// outboundVoiceMultiText 是两段回复:出站音频只该跟着第一段走。
|
||||
outboundVoiceMultiText = "第一段话,先说给你听。\n\n第二段话,说给明天听。"
|
||||
// outboundVoiceAudioMIME 是 `.ogg` 副本的下发类型(voice/media.go 的成对裁决产物)。
|
||||
outboundVoiceAudioMIME = "audio/ogg"
|
||||
)
|
||||
|
||||
// outboundVoiceE2E 打包出站语音整链环境:真桥接器(可注入 `Result.Voice` 的循环替身 +
|
||||
// 真历史存储 + 真音频落盘配置)+ 真网关(同一个历史存储)+ 组合根的持久化钩子。
|
||||
type outboundVoiceE2E struct {
|
||||
bridge *Bridge
|
||||
chat *stubChatter
|
||||
platform *fakeAdapter
|
||||
// uploads 是桥接器会去读副本的那个目录(`documents.Store.Dir()`)。
|
||||
uploads string
|
||||
ts *httptest.Server
|
||||
tok string
|
||||
}
|
||||
|
||||
// newOutboundVoiceE2E 装配整链环境。历史存储是**同一个句柄**喂给桥接器与网关 ——
|
||||
// 被验证的正是这条边界:桥接器写进 `voice` 列的事实,就是实时帧与 REST 读面投影出来的事实。
|
||||
func newOutboundVoiceE2E(t *testing.T, sess session.Session) *outboundVoiceE2E {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||||
log := slog.New(slog.NewTextHandler(&safeBuffer{}, nil))
|
||||
st := openGatewayDocStore(t, log)
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: outboundVoiceText}}
|
||||
ad := &fakeAdapter{name: "telegram"}
|
||||
b, err := New(Options{
|
||||
Log: log,
|
||||
Loop: chat,
|
||||
Sessions: &stubSessions{sess: sess},
|
||||
Owner: testOwnerProfile("telegram", "100"),
|
||||
History: st,
|
||||
// 真音频落盘配置:副本由 documents.Store.SaveAudio 落下,出站时按同一目录读回。
|
||||
DocumentStore: documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("构造桥接器: %v", err)
|
||||
}
|
||||
// 气泡间隔在生产里是 0.5-2s:多段用例不该为它付真实时间。
|
||||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||||
b.Add(ad)
|
||||
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: gatewayDocSecret})
|
||||
srv, err := gateway.New(
|
||||
&config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: gatewayDocSecret}},
|
||||
gateway.Deps{Auth: jwt, History: st, DataDir: dataDir},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("构造网关: %v", err)
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
// 🔴 组合根的同一接线(cmd/cyrene/wire_frontend.go:SetOnPersisted(gw.BroadcastPersisted))。
|
||||
b.SetOnPersisted(srv.BroadcastPersisted)
|
||||
|
||||
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("签发 token: %v", err)
|
||||
}
|
||||
return &outboundVoiceE2E{bridge: b, chat: chat, platform: ad, uploads: uploads, ts: ts, tok: tok}
|
||||
}
|
||||
|
||||
// dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧,
|
||||
// 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。
|
||||
func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn {
|
||||
t.Helper()
|
||||
conn, _ := e.dialOwnerWSHistory(t)
|
||||
return conn
|
||||
}
|
||||
|
||||
// dialOwnerWSHistory 同 dialOwnerWS,但把握手回放的 `history` 帧一并交回 —— 出站语音
|
||||
// 也得在「隔天重新打开客户端」的那条路上看得见(列有值即带),这条读面因此要能被断言。
|
||||
func (e *outboundVoiceE2E) dialOwnerWSHistory(t *testing.T) (*websocket.Conn, wsFrame) {
|
||||
t.Helper()
|
||||
wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("连接 WS: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
ev := readWSFrame(t, conn)
|
||||
if ev.Type != "history" {
|
||||
t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type)
|
||||
}
|
||||
return conn, ev
|
||||
}
|
||||
|
||||
// replayVoiceOfID 从握手回放帧的 `data.messages` 里按 id 找到那一行,返回它的 `voice`
|
||||
// 原始 JSON(键的在场性因此可判);该行没有 `voice` 键时返回 nil 与 false。
|
||||
func replayVoiceOfID(t *testing.T, ev wsFrame, id string) (json.RawMessage, bool) {
|
||||
t.Helper()
|
||||
var data struct {
|
||||
Messages []map[string]json.RawMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(ev.Data, &data); err != nil {
|
||||
t.Fatalf("解码握手回放帧 %s: %v", ev.Data, err)
|
||||
}
|
||||
for _, row := range data.Messages {
|
||||
var gotID string
|
||||
_ = json.Unmarshal(row["id"], &gotID)
|
||||
if gotID != id {
|
||||
continue
|
||||
}
|
||||
raw, ok := row["voice"]
|
||||
return raw, ok
|
||||
}
|
||||
t.Fatalf("握手回放里没有 id=%q 的那一行(回放=%s)", id, ev.Data)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// storeOutboundCopy 用**真**音频落盘分支(`documents.Store.SaveAudio`)把一份合成产物
|
||||
// 落到桥接器会去读的那个目录里,返回它签发的引用。名字/类型/大小都按生产口径算出来 ——
|
||||
// 绝不手拼一个「看起来像」的文件名,否则本文件测的就不是那条读回路径。
|
||||
func storeOutboundCopy(t *testing.T, uploads string, data []byte) documents.Ref {
|
||||
t.Helper()
|
||||
store := documents.NewStore(documents.StoreConfig{UploadsDir: uploads})
|
||||
ref, err := store.SaveAudio(context.Background(), "voice.ogg", data)
|
||||
if err != nil {
|
||||
t.Fatalf("落盘出站语音副本: %v", err)
|
||||
}
|
||||
if ref.Name == "" || ref.MIME != outboundVoiceAudioMIME || ref.Size != int64(len(data)) {
|
||||
t.Fatalf("落盘引用 = %+v, want 名字/%s/%d 字节", ref, outboundVoiceAudioMIME, len(data))
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// nextPersistedFrameWithRole 读到**下一条指定 role 的持久化帧**(跳过其余帧)。出站用例
|
||||
// 要的是**助手**那一行(本回合的用户行帧先到),所以按 role 过滤而不是按先后猜 ——
|
||||
// 也因此不需要 sleep 或重试。
|
||||
func nextPersistedFrameWithRole(t *testing.T, conn *websocket.Conn, role string) wsFrame {
|
||||
t.Helper()
|
||||
for i := 0; i < 10; i++ {
|
||||
ev := readWSFrame(t, conn)
|
||||
switch ev.Type {
|
||||
case "message_persisted", "channel_persisted":
|
||||
if messageRole(t, persistedMessage(t, ev)) == role {
|
||||
return ev
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("读了 10 帧仍没等到 %s 行的持久化帧", role)
|
||||
return wsFrame{}
|
||||
}
|
||||
|
||||
// frameContent 取帧内消息的正文。
|
||||
func frameContent(t *testing.T, msg map[string]json.RawMessage) string {
|
||||
t.Helper()
|
||||
var content string
|
||||
if err := json.Unmarshal(msg["content"], &content); err != nil {
|
||||
t.Fatalf("解析帧里的 content: %v", err)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// TestIMOutboundVoiceFirstFrameCarriesVoice 钉住出站方向的首帧纪律(M44 的同一条口径):
|
||||
// 合成成功的回复,其**第一帧**里就要有 `voice`,且与 REST 读面同一行逐字相等。
|
||||
func TestIMOutboundVoiceFirstFrameCarriesVoice(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||||
audio := []byte("OggS-outbound-voice-payload")
|
||||
ref := storeOutboundCopy(t, env.uploads, audio)
|
||||
env.chat.res = &loop.Result{
|
||||
Text: outboundVoiceText,
|
||||
Voice: &loop.OutboundVoice{
|
||||
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size, DurationMs: 1500,
|
||||
},
|
||||
}
|
||||
conn := env.dialOwnerWS(t)
|
||||
env.bridge.process(context.Background(), directMessage())
|
||||
|
||||
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
|
||||
if ev.Type != "message_persisted" {
|
||||
t.Fatalf("主人私聊的帧类型 = %q, want message_persisted", ev.Type)
|
||||
}
|
||||
msg := persistedMessage(t, ev)
|
||||
raw, ok := msg["voice"]
|
||||
if !ok {
|
||||
t.Fatalf("🔴 出站语音的**第一帧**缺 data.message.voice: %s", ev.Data)
|
||||
}
|
||||
shape := canonicalVoice(t, raw)
|
||||
for _, want := range []string{
|
||||
`"source":"tts"`, `"status":"transcribed"`,
|
||||
`"name":"` + ref.Name + `"`, `"mime":"` + outboundVoiceAudioMIME + `"`,
|
||||
fmt.Sprintf(`"size":%d`, ref.Size), `"durationMs":1500`,
|
||||
`"url":"/api/v1/media/` + ref.Name + `"`,
|
||||
} {
|
||||
if !strings.Contains(shape, want) {
|
||||
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
|
||||
}
|
||||
}
|
||||
// 与 REST 读面**同一行**逐字相等:同一份事实,不是两处各算。
|
||||
id := messageIDOf(t, msg)
|
||||
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
|
||||
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
|
||||
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
|
||||
}
|
||||
// 握手回放(隔天重新打开客户端):同一条消息在 `history` 帧里也带着这份出站语音。
|
||||
_, replay := env.dialOwnerWSHistory(t)
|
||||
replayRaw, ok := replayVoiceOfID(t, replay, id)
|
||||
if !ok {
|
||||
t.Fatalf("🔴 握手回放里这条出站语音缺 voice 键: %s", replay.Data)
|
||||
}
|
||||
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
|
||||
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
|
||||
}
|
||||
// 正文一字未改:语音是这条回复的另一种呈现,不是它的替代品。
|
||||
if got := frameContent(t, msg); got != outboundVoiceStoredText {
|
||||
t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIMOutboundVoiceFallbackIsVisibleAsFailed 钉住降级的可见性(切片 M58 的第 3 条):
|
||||
// `voice.status=failed` + `fallbackReason` 在**实时帧**与**历史回看**上都看得见,正文照发
|
||||
// 原文,且渠道侧只收到纯文本气泡(绝不发一个空音频)。
|
||||
func TestIMOutboundVoiceFallbackIsVisibleAsFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||||
env.chat.res = &loop.Result{
|
||||
Text: outboundVoiceText,
|
||||
Voice: &loop.OutboundVoice{Requested: true, FallbackReason: "tts_timeout"},
|
||||
}
|
||||
conn := env.dialOwnerWS(t)
|
||||
env.bridge.process(context.Background(), directMessage())
|
||||
|
||||
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
|
||||
msg := persistedMessage(t, ev)
|
||||
raw, ok := msg["voice"]
|
||||
if !ok {
|
||||
t.Fatalf("🔴 合成降级的**第一帧**缺 data.message.voice(客户端看不到「本来想用语音」): %s", ev.Data)
|
||||
}
|
||||
shape := canonicalVoice(t, raw)
|
||||
for _, want := range []string{
|
||||
`"source":"tts"`, `"status":"failed"`, `"fallbackReason":"tts_timeout"`,
|
||||
} {
|
||||
if !strings.Contains(shape, want) {
|
||||
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
|
||||
}
|
||||
}
|
||||
// 没有副本 ⇒ 副本四键**一起缺席**(不是空串、不是 0):客户端据此不画播放器。
|
||||
for _, absent := range []string{`"name"`, `"mime"`, `"size"`, `"url"`} {
|
||||
if strings.Contains(shape, absent) {
|
||||
t.Fatalf("降级的 voice = %s, 不该有 %s", shape, absent)
|
||||
}
|
||||
}
|
||||
// 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。
|
||||
id := messageIDOf(t, msg)
|
||||
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
|
||||
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
|
||||
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
|
||||
}
|
||||
// 握手回放(重新连 WS)同样带着这条降级:客户端的「本来想用语音」小标不只在实时帧上有。
|
||||
_, replay := env.dialOwnerWSHistory(t)
|
||||
replayRaw, ok := replayVoiceOfID(t, replay, id)
|
||||
if !ok {
|
||||
t.Fatalf("🔴 握手回放里这条降级回复缺 voice 键: %s", replay.Data)
|
||||
}
|
||||
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
|
||||
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
|
||||
}
|
||||
// 正文原样:降级只影响「有没有音频」,绝不影响说什么。
|
||||
if got := frameContent(t, msg); got != outboundVoiceStoredText {
|
||||
t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText)
|
||||
}
|
||||
if len(env.platform.sends) != 1 {
|
||||
t.Fatalf("渠道侧气泡数 = %d, want 1", len(env.platform.sends))
|
||||
}
|
||||
sent := env.platform.sends[0]
|
||||
if sent.ContentType != adapter.ContentText {
|
||||
t.Fatalf("降级气泡的 ContentType = %q, want %q", sent.ContentType, adapter.ContentText)
|
||||
}
|
||||
if len(sent.Media) != 0 {
|
||||
t.Fatalf("降级气泡带上了 Media = %+v, want 空(绝不发空音频)", sent.Media)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly 钉住出站 Media:音频只挂**第一个**气泡,
|
||||
// 字节就是盘上那份副本,第二个气泡仍是纯文本(同一条合成语音跟着每段各发一次,对方会收到
|
||||
// N 份同样的音频)。
|
||||
func TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||||
audio := []byte("OggS-multi-paragraph-voice")
|
||||
ref := storeOutboundCopy(t, env.uploads, audio)
|
||||
env.chat.res = &loop.Result{
|
||||
Text: outboundVoiceMultiText,
|
||||
Voice: &loop.OutboundVoice{
|
||||
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size,
|
||||
},
|
||||
}
|
||||
env.bridge.process(context.Background(), directMessage())
|
||||
|
||||
if len(env.platform.sends) != 2 {
|
||||
t.Fatalf("两段回复的气泡数 = %d, want 2(%+v)", len(env.platform.sends), env.platform.sends)
|
||||
}
|
||||
first := env.platform.sends[0]
|
||||
if !strings.Contains(first.Content, "第一段话") {
|
||||
t.Fatalf("第一个气泡的正文 = %q, want 第一段", first.Content)
|
||||
}
|
||||
if first.ContentType != adapter.ContentVoice {
|
||||
t.Fatalf("第一个气泡的 ContentType = %q, want %q", first.ContentType, adapter.ContentVoice)
|
||||
}
|
||||
if len(first.Media) != 1 {
|
||||
t.Fatalf("第一个气泡的 Media = %+v, want 恰好一条", first.Media)
|
||||
}
|
||||
md := first.Media[0]
|
||||
if md.Kind != adapter.MediaVoice {
|
||||
t.Fatalf("出站媒体 Kind = %q, want %q", md.Kind, adapter.MediaVoice)
|
||||
}
|
||||
if md.Name != ref.Name || md.MIMEType != outboundVoiceAudioMIME {
|
||||
t.Fatalf("出站媒体 = %+v, want name=%s mime=%s", md, ref.Name, outboundVoiceAudioMIME)
|
||||
}
|
||||
if md.Size != int64(len(audio)) {
|
||||
t.Fatalf("出站媒体 Size = %d, want %d", md.Size, len(audio))
|
||||
}
|
||||
if !bytes.Equal(md.Data, audio) {
|
||||
t.Fatalf("出站媒体字节 = %q, want 盘上那份副本 %q", md.Data, audio)
|
||||
}
|
||||
// URL 刻意留空:`/api/v1/media/` 走 Bearer 鉴权,适配器没有(也不该有)owner 令牌。
|
||||
if md.URL != "" {
|
||||
t.Fatalf("出站媒体 URL = %q, want 空(适配器只拿字节)", md.URL)
|
||||
}
|
||||
|
||||
second := env.platform.sends[1]
|
||||
if !strings.Contains(second.Content, "第二段话") {
|
||||
t.Fatalf("第二个气泡的正文 = %q, want 第二段", second.Content)
|
||||
}
|
||||
if second.ContentType != adapter.ContentText || len(second.Media) != 0 {
|
||||
t.Fatalf("第二个气泡 = %+v, want 纯文本且无 Media", second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboundVoiceRefShapes 是 outbound_voice.go 的纯函数面:三种结局与「上游契约被破坏」
|
||||
// 那一种都必须各归其位 —— 一个没有 mime 的 name 绝不写成 transcribed。
|
||||
func TestOutboundVoiceRefShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, _, _, _ := newTestBridge(t)
|
||||
const copyName = "0123456789abcdef0123456789abcdef.ogg"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
voice *loop.OutboundVoice
|
||||
wantOK bool
|
||||
wantRef string
|
||||
wantSize int64
|
||||
}{
|
||||
{name: "nil 结论", voice: nil, wantOK: false},
|
||||
{name: "未请求", voice: &loop.OutboundVoice{Requested: false}, wantOK: false},
|
||||
{
|
||||
name: "合成成功且副本完整",
|
||||
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Mime: outboundVoiceAudioMIME, Size: 8192, DurationMs: 1200},
|
||||
wantOK: true, wantRef: `{"source":"tts","status":"transcribed","name":"` + copyName + `","mime":"audio/ogg","size":8192,"durationMs":1200}`, wantSize: 8192,
|
||||
},
|
||||
{
|
||||
name: "缺 mime ⇒ 降级 copy_incomplete",
|
||||
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Size: 8192},
|
||||
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
|
||||
},
|
||||
{
|
||||
name: "名字不是内容寻址名 ⇒ 降级 copy_incomplete",
|
||||
voice: &loop.OutboundVoice{Requested: true, Name: "../../etc/passwd", Mime: outboundVoiceAudioMIME, Size: 10},
|
||||
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
|
||||
},
|
||||
{
|
||||
name: "请求了但没有音频 ⇒ 降级原因来自上游",
|
||||
voice: &loop.OutboundVoice{Requested: true, FallbackReason: "budget_exceeded"},
|
||||
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`, wantSize: 0,
|
||||
},
|
||||
{name: "请求了但既无副本又无原因", voice: &loop.OutboundVoice{Requested: true, CostMs: 90}, wantOK: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, ok := b.outboundVoiceRef(tc.voice)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("写列 = %v, want %v(ref=%+v)", ok, tc.wantOK, ref)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := voiceref.Encode(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("编码 voice ref: %v", err)
|
||||
}
|
||||
if raw != tc.wantRef {
|
||||
t.Fatalf("voice ref = %s, want %s", raw, tc.wantRef)
|
||||
}
|
||||
if ref.Size != tc.wantSize {
|
||||
t.Fatalf("ref.Size = %d, want %d", ref.Size, tc.wantSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// voiceRefWithCopy 造一条「盘上应当有一份副本」的 voice 事实。字段按生产口径配齐
|
||||
// (name/mime/size 同生共死,否则 Validate 会拒绝)—— 名字交给调用方给,本函数不猜。
|
||||
func voiceRefWithCopy(name string, size int64) voiceref.Ref {
|
||||
return voiceref.Ref{
|
||||
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
|
||||
Name: name, MIME: outboundVoiceAudioMIME, Size: size,
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboundVoiceMediaSkipsUnreadableCopy 钉住「拿不到字节就不挂音频」:附件存储未接线、
|
||||
// 或副本读不回来时,出站媒体一律不挂(正文照发文本,WARN 由 outbound_voice.go 记下)。
|
||||
func TestOutboundVoiceMediaSkipsUnreadableCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
const copyName = "0123456789abcdef0123456789abcdef.ogg"
|
||||
ref := voiceRefWithCopy(copyName, 1)
|
||||
|
||||
// ① 附件存储未接线(UploadsDir 与 DataDir 都空)⇒ inboundDocumentStore 返回 nil。
|
||||
bare, _, _, _ := newTestBridge(t)
|
||||
if md, ok := bare.outboundVoiceMedia(directMessage(), ref); ok {
|
||||
t.Fatalf("存储未接线却挂上了出站媒体 = %+v", md)
|
||||
}
|
||||
|
||||
// ② 目录配好了,但盘上没有这份副本 ⇒ 读不回来。
|
||||
empty := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||||
if md, ok := empty.bridge.outboundVoiceMedia(directMessage(), ref); ok {
|
||||
t.Fatalf("副本不存在却挂上了出站媒体 = %+v", md)
|
||||
}
|
||||
|
||||
// ③ 副本真在盘上 ⇒ 挂得上,且字节逐字相等。
|
||||
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||||
audio := []byte("OggS-readback")
|
||||
stored := storeOutboundCopy(t, env.uploads, audio)
|
||||
md, ok := env.bridge.outboundVoiceMedia(directMessage(), voiceRefWithCopy(stored.Name, stored.Size))
|
||||
if !ok {
|
||||
t.Fatalf("副本在盘上却没挂上出站媒体(name=%s)", stored.Name)
|
||||
}
|
||||
if !bytes.Equal(md.Data, audio) {
|
||||
t.Fatalf("读回的字节 = %q, want %q", md.Data, audio)
|
||||
}
|
||||
}
|
||||
@@ -77,12 +77,12 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
|
||||
// (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作,
|
||||
// 因此桥接器在没有存储的情况下行为不变。
|
||||
//
|
||||
// 写入本身走 persistMain(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。
|
||||
// **主动推送不经过本函数**:它直接调 persistMain,并额外走 notifyProactive 追加一条带
|
||||
// 写入本身走 persistMainWith(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。
|
||||
// **主动推送不经过本函数**:它直接调 persistMainWith,并额外走 notifyProactive 追加一条带
|
||||
// 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远
|
||||
// 不带它**。
|
||||
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message {
|
||||
return b.persistMain(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted)
|
||||
return b.persistMainWith(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted)
|
||||
}
|
||||
|
||||
// mainRow 构造一条写进 Web 主会话的行。它只写一次字段清单:persistToMain 与
|
||||
@@ -94,13 +94,16 @@ func mainRow(role, content, mood, document, image, platform, deviceID, adapterID
|
||||
}
|
||||
}
|
||||
|
||||
// persistMain 是「写进主会话」的**唯一**写入口径:persistToMain(普通消息)与
|
||||
// 主动推送(proactive.go)共用它,因此两条路的存储语义逐字一致 —— 未接线历史存储
|
||||
// 时空操作并返回零值、写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的
|
||||
// 完整消息。**差别只在 notify**:那是「这条要不要通知实时通道」的接缝,由调用方给出
|
||||
// —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送
|
||||
// 传 notifyProactive(带结构化判据的同一类帧,切片 P0b)。nil 表示本次写入不通知。
|
||||
func (b *Bridge) persistMain(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message {
|
||||
// persistMainWith 是「写进主会话」的**唯一**写入口径(与 persistIsolatedWith 同一手法):
|
||||
// persistToMain(普通消息)、主动推送(proactive.go)与入站/出站语音那条「先写 `voice` 列、
|
||||
// 再广播」的路径共用它,因此几条路的存储语义逐字一致 —— 未接线历史存储时空操作并返回零值、
|
||||
// 写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的完整消息。
|
||||
//
|
||||
// **差别只在 notify**:那是「这条要不要通知实时通道、以及先写列还是先广播」的接缝,由调用方
|
||||
// 给出 —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送
|
||||
// 传 notifyProactive(带结构化判据的同一类帧,切片 P0b),语音传 persistVoiceFirstNotify
|
||||
// (先写 `voice` 列再广播,M44 首帧纪律)。nil 表示本次写入不通知。
|
||||
func (b *Bridge) persistMainWith(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message {
|
||||
if b.history == nil {
|
||||
return history.Message{}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
|
||||
// 把主动消息镜像进主对话上下文(design/16
|
||||
// §4.4 + §4.6.1)。否则模型看不到自己主动说了什么,
|
||||
// 主人后续回复就会显得不连贯(该消息「丢失」了历史)。
|
||||
// 它用的是普通回复那条写入口径(persistMain,相同的列、相同的时间戳语义),
|
||||
// 它用的是普通回复那条写入口径(persistMainWith,相同的列、相同的时间戳语义),
|
||||
// 作用域恒为 MainSessionID,落库的同样是**规整后**的那一份(Web 与 IM 看到的必须是
|
||||
// 同一份),因此落库行为与本切片之前逐字相同;变的只有通知的第二步(见下)。
|
||||
// turn_id 传 ""(零值):主动推送是**她单方面开口**,没有对应的入站回合,也就没有
|
||||
@@ -97,13 +97,13 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
|
||||
// id),前端按 id 去重 ⇒ 仍是一个气泡、仍只提醒一次。
|
||||
//
|
||||
// ② 只在**确实落了库**时发:`message_persisted` 的载荷要求权威 id,客户端拿不到 id
|
||||
// 会整帧丢弃 —— 发一条没有 id 的帧等于假装提醒过。落库失败本身已由 persistMain
|
||||
// 会整帧丢弃 —— 发一条没有 id 的帧等于假装提醒过。落库失败本身已由 persistMainWith
|
||||
// 记 WARN(可见,不静默)。
|
||||
row := history.Message{
|
||||
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: out.Content,
|
||||
Platform: target.platform, DeviceID: proactiveDeviceID(target), AdapterID: target.platform, Mode: modeChat,
|
||||
}
|
||||
if full := b.persistMain(ctx, row, b.notifyPersisted); full.ID != "" {
|
||||
if full := b.persistMainWith(ctx, row, b.notifyPersisted); full.ID != "" {
|
||||
b.notifyProactive(full)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
// 本文件承载 IM 主动推送那条**实时通知**接缝(切片 P0b「IM 路由的主动消息也要在非前台
|
||||
// 补通知」)。它与 persist.go 的 notifyPersisted 并列:普通消息走那一条,主动推送走这
|
||||
// 一条。两条都写主会话,因此「写」是同一份代码(persistMain),差别只在通知。
|
||||
// 一条。两条都写主会话,因此「写」是同一份代码(persistMainWith),差别只在通知。
|
||||
//
|
||||
// 为什么必须分开而不是去猜:主人裁定「她说话该不该提醒我,与走哪条路由无关」,因此
|
||||
// 主动推送必须自己声明「我是主动推送」。判据是**结构化**的(本次写入走的是哪条通知
|
||||
|
||||
@@ -9,8 +9,12 @@ package gateway
|
||||
//
|
||||
// 四条硬口径:
|
||||
//
|
||||
// - **🎤 判据 = `voice` 键存在**(契约明写):非语音消息整键缺席(`omitempty` 的
|
||||
// 指针 nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
|
||||
// - **`voice` 键存在 = 这条消息有语音事实**:非语音消息整键缺席(`omitempty` 的指针
|
||||
// nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
|
||||
// ⚠️ 出站合成降级(source=tts、status=failed + fallbackReason)同样**有键**,但
|
||||
// 没有可播副本(name/mime/size/url 四键一起缺席)⇒ 客户端按 `status` 分支:只挂一个
|
||||
// 「本来想用语音」的小标、**不**画播放器,也不打「来自语音」的 🎤(正文是文本,
|
||||
// 不是转写文本)。契约文档对 🎤 判据的措辞由塔统一收口。
|
||||
// - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现
|
||||
// (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现
|
||||
// 「`url` 指着一个不存在的 `name`」。
|
||||
@@ -29,7 +33,8 @@ package gateway
|
||||
// ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的,
|
||||
// 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的
|
||||
// **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge
|
||||
// 的 persistBatchUsers / persistVoiceFirstNotify),本文件因此无需再区别对待实时帧与读面。
|
||||
// 的 persistBatchUsers / persistVoiceFirstNotify),出站语音(outbound_voice.go)走的是
|
||||
// 同一条顺序纪律。本文件因此无需再区别对待实时帧与读面。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -45,10 +50,12 @@ import (
|
||||
// `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余
|
||||
// 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。
|
||||
type historyVoice struct {
|
||||
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)。
|
||||
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)|
|
||||
// tts(我们自己合成的出站语音)。
|
||||
Source string `json:"source"`
|
||||
// Status ∈ transcribed(正文即转写文本)| empty(转写成功但没识别出文字)|
|
||||
// failed(没拿到转写文本,正文是一行中文诚实提示)。闭集三态。
|
||||
// Status ∈ transcribed(正文即转写文本 / 出站合成成功)| empty(转写成功但没识别出
|
||||
// 文字)| failed(没拿到转写文本,正文是一行中文诚实提示;出站方向 = 合成降级)。
|
||||
// 闭集三态。
|
||||
Status string `json:"status"`
|
||||
// Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -58,6 +65,10 @@ type historyVoice struct {
|
||||
Size int64 `json:"size,omitempty"`
|
||||
// DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
// FallbackReason 只在**出站合成降级**时出现(source=tts、status=failed):说明这条
|
||||
// 消息为什么没能变成语音(能力未接线 / 预算超时 / 合成为空)。正文此时照发文本,
|
||||
// 客户端据 status=failed 只挂一个小标,**绝不**把它当成可播气泡。
|
||||
FallbackReason string `json:"fallbackReason,omitempty"`
|
||||
// URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
@@ -148,6 +159,11 @@ func historyVoiceOf(ref voiceref.Ref) *historyVoice {
|
||||
return nil
|
||||
}
|
||||
out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)}
|
||||
// 降级原因只在失败态下发(`Ref.Validate` 已保证它不会挂在其它状态上):客户端据此
|
||||
// 显示「这条本来想用语音」的小标,正文仍是原文本。
|
||||
if ref.Status == voiceref.StatusFailed {
|
||||
out.FallbackReason = ref.FallbackReason
|
||||
}
|
||||
if ref.HasCopy() {
|
||||
// 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。
|
||||
out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size
|
||||
|
||||
Reference in new issue
Block a user