Files
AskaEth 8820b0b316 fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:

1. 🔴 隐私:upstreamStatus 不再读也不记响应体
   FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
   等于把她的原话写进日志。现在只记 status / content_type / response_bytes
   (字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
   新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
   变异(把 body 塞回 WARN)实测打红。

2. format 键彻底删除(假开关)
   上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
   嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
   VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
   用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。

3. Stats 接进 degrade 的 WARN(attempts/failures)
   否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
   变异(去掉两个字段)打红。

4. redactURL 兜底含 "@" 整体打码
   解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
   覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。

另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
  时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
  用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
  变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。

Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
2026-10-26 13:06:39 +08:00

93 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 本文件把**半云端 emotionTTS** 接成她的一级发音人(切片 M35),并保持三条红线:
//
// 1. **配置走面板文件** `$DATA_DIR/config/voice-tts.json`(零新 `.env` 键,design/13
// §2.1)。文件缺失/坏掉 ⇒ 这一级**不启用**,既有语音链逐字不变。
// 2. **文本优先**:合成失败只回落既有链 + 一条带来源的 WARN,绝不把"这条回复"
// 判成失败(聊天正文早已发出/落库)。
// 3. **不新建端点/帧族**:只换网关 `SetVoice` 的注入点,因此既有两条出站口
// (`POST /api/v1/audio/speech` 与 WS `voice_output`)同时用上它。
//
// 为什么包一层而不是插进链里:既有链由 `multimodal/voice.New` 按 env 组装,各级
// 列表不外露;`.env` 已冻结,不能为新能力加键。包装既不动那条链,也让"失败回落"
// 发生在**合成入口**这一处,而不是散在两条出站口里。
import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// voiceTTSSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
const voiceTTSSource = "cmd/cyrene/voice-tts"
// voiceForGateway 返回注入给网关的语音服务:emotionTTS 已按面板配置启用时是
// 「emotionTTS + 既有链」的门面,否则**就是既有链本身**(行为逐字不变)。
//
// 它只在 `wireFrontend` 接线时调用一次;每次调用都重新读一次面板文件(启动期
// 读一次的口径与 `backthink.json` 一致)。
func (a *app) voiceForGateway() gateway.VoiceService {
if a.voice == nil {
return nil
}
cfg, enabled := a.voiceTTSConfig()
if !enabled {
return a.voice
}
client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, voiceTTSSource)))
service := voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, voiceTTSSource)))
logger.WithSource(a.log, voiceTTSSource).Info("emotionTTS voice enabled",
"base_url", client.RedactedBaseURL(),
"personas", len(cfg.Personas),
"default_set", cfg.Default != "",
"timeout_sec", int(cfg.Timeout/time.Second),
"config", runtimeconfig.VoiceTTSConfigRelPath)
return service
}
// voiceTTSConfig 读面板配置并映射成 provider 配置。第二个返回值报告"这一级是否
// 启用"——坏文件、缺文件、显式 false 都得出 false(并在坏文件时留一条 WARN)。
//
// 它**不**在这里做"缺省填什么"的裁决:默认值在 `voicetts.Config.Normalized` 一处。
// 🔴 它也**不**给 personas/default 补任何角色:发音人只能由面板显式给出(全局定死
// 一个 character 正是主人点名的病害)。
func (a *app) voiceTTSConfig() (voicetts.Config, bool) {
log := logger.WithSource(a.log, voiceTTSSource)
path := runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg))
tuning, err := runtimeconfig.LoadVoiceTTSTuning(path)
if err != nil {
// 坏文件 ⇒ **这一级不启用**:宁可没有它,也不要用一份读不懂的配置去驱动
// 一台外部服务。既有链照旧服务(文本优先)。
log.Warn("voice tts config unreadable, emotionTTS stays disabled",
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
return voicetts.Config{}, false
}
if !tuning.EnabledOn() {
log.Debug("emotionTTS not enabled in panel config",
"path", runtimeconfig.VoiceTTSConfigRelPath, "hint", "set {\"enabled\": true}")
return voicetts.Config{}, false
}
if legacy := tuning.LegacyGlobalCharacter(); legacy != "" {
// 旧版的全局 character:**忽略**,但绝不静默 —— 运维需要知道换成 personas。
log.Warn("voice tts config: global `character` is deprecated and ignored",
"character", legacy, "config", runtimeconfig.VoiceTTSConfigRelPath,
"hint", "use {\"personas\": {\"<personaId>\": \"<characterId>\"}} so the voice follows the persona")
}
return voicetts.Config{
Enabled: true,
BaseURL: tuning.BaseURL,
Personas: tuning.Personas,
Default: tuning.Default,
Timeout: time.Duration(tuning.TimeoutSec) * time.Second,
}, true
}
// voiceFallbackChain 声明 a.voice 满足 emotionTTS 门面要的底链面(编译期断言)。
// 它把"既有链少了一个方法"这种改动的代价,从**运行期 panic** 提前到**编译失败**。
var _ voicetts.Chain = (*voice.Chain)(nil)