feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
This commit is contained in:
1 parent
120d926cf8
commit
ede00d9928
17 files changed
+2125
-31
No files matched your search
@@ -0,0 +1,96 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// voiceMigrationID 是「语音事实」列的迁移 id(design/24 §10.4;切片 CODE-VOICE)。
|
||||
const voiceMigrationID = "0020_chat_message_voice"
|
||||
|
||||
// TestMigrateVoiceColumnReversible 验证 0020(chat_messages.voice,语音来源事实 + 音频
|
||||
// 副本引用)可上下翻滚:up 建列、重复 up 是空操作、down 删列、再 up 恢复;并钉住列口径
|
||||
// —— 默认空串(**不是 NULL**)与「老行 = 不是语音消息」逐字等价。它用自己的内存库,
|
||||
// 绝不碰 data/cyrene.db。
|
||||
//
|
||||
// 0020 的编号**小于**既有的 0021/0022(那两条先落地),因此本测试刻意按
|
||||
// `upMigrationIDs` 的下标算回滚步数(照 0018 的写法),不假设「最后一条就是自己」。
|
||||
func TestMigrateVoiceColumnReversible(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := openMemory(t)
|
||||
|
||||
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
|
||||
t.Fatal("chat_messages.voice should exist after MigrateUp")
|
||||
}
|
||||
|
||||
// 重复 up:已记录的迁移被跳过,列仍在且不报错(幂等)。
|
||||
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateUp second call: %v", err)
|
||||
}
|
||||
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
|
||||
t.Fatal("chat_messages.voice should still exist after idempotent re-run")
|
||||
}
|
||||
|
||||
// 恰好回滚到 0020(含它之后新加的迁移),因此日后再加迁移也不会让本测试失效。
|
||||
ids := upMigrationIDs(t)
|
||||
idx := -1
|
||||
for i, id := range ids {
|
||||
if id == voiceMigrationID {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
t.Fatalf("%s not found in %v", voiceMigrationID, ids)
|
||||
}
|
||||
if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err)
|
||||
}
|
||||
if columnExists(t, ctx, conn, "chat_messages", "voice") {
|
||||
t.Fatal("chat_messages.voice should be dropped by the 0020 down")
|
||||
}
|
||||
|
||||
// 重新 up 恢复端到端可用。
|
||||
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
|
||||
t.Fatalf("re-MigrateUp: %v", err)
|
||||
}
|
||||
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
|
||||
t.Fatal("chat_messages.voice should exist again after re-MigrateUp")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateVoiceColumnDefaultIsEmptyString 钉住列口径:**用空串不用 NULL**(与
|
||||
// document / turn_id / client_msg_id 同族)。插入一条不带 voice 的消息(显式不写该列)
|
||||
// 必须读回空串 —— 「老行」与「这条消息不是语音」因此是同一个值,读侧只有一种空情形。
|
||||
//
|
||||
// 若有人把默认值改成 NULL,SQL 层 Scan 会失败或读出 NULL 语义,客户端看到的 `voice`
|
||||
// 键行为随之漂移(design/24 §10.4:非语音消息**键缺席**,不是 `{}` 也不是 `null`)。
|
||||
func TestMigrateVoiceColumnDefaultIsEmptyString(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := openMemory(t)
|
||||
|
||||
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
"m-voice-default", "web", "user", "hi", 1000); err != nil {
|
||||
t.Fatalf("insert without voice: %v", err)
|
||||
}
|
||||
// 用 *string 读回:默认值若是 NULL,Scan 会得到 nil(这里就会红)。
|
||||
var voice *string
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT voice FROM chat_messages WHERE id = ?`, "m-voice-default").Scan(&voice); err != nil {
|
||||
t.Fatalf("select voice: %v", err)
|
||||
}
|
||||
if voice == nil {
|
||||
t.Fatal("voice is NULL, want the empty-string default(绝不使用 NULL)")
|
||||
}
|
||||
if *voice != "" {
|
||||
t.Fatalf("voice = %q, want \"\"", *voice)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 0020_chat_message_voice down: 回滚「语音事实」列。
|
||||
--
|
||||
-- voice 只被 chat_messages 自己引用(无索引、无视图、无约束牵涉),因此直接删列即可。
|
||||
-- 回滚后的状态是「服务端不再记得这条消息来自一条语音」:列里的来源事实与音频副本引用
|
||||
-- 随列一起丢弃。
|
||||
--
|
||||
-- ⚠️ 音频副本本体(<DataDir>/uploads/documents 里的文件与 `documents` 元数据行)**不在
|
||||
-- 本迁移的范围内**:删掉这一列等于一次性放掉这些副本的最后一份引用,它们的 ref_count
|
||||
-- 不会再被消息写路径增减,此后只能由既有 GC(保留期 + ref_count = 0)收走。这是**有意**
|
||||
-- 的取舍:迁移绝不代替 GC 去删用户数据(同 0015 的 down 口径)。
|
||||
|
||||
ALTER TABLE chat_messages DROP COLUMN voice;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 0020_chat_message_voice: 给聊天消息挂上「语音事实」(design/24 §10.4;切片 CODE-VOICE)。
|
||||
--
|
||||
-- 一条语音消息在历史里要能被辨认成语音(客户端据此显示 🎤 与播放控件),并且要能
|
||||
-- 说清它的**正文从哪来**。这两件事都是「整条消息」的属性,不是某个内容片段的属性,
|
||||
-- 因此落在消息行的一列上(与 0015 的 document 同族):
|
||||
--
|
||||
-- {"source":"asr","status":"transcribed","name":"a1b2…ogg","mime":"audio/ogg",
|
||||
-- "size":12345,"durationMs":3200}
|
||||
--
|
||||
-- JSON **单对象**(不是数组:一条消息只有一条被归一/落副本的语音,design/24 §10.1
|
||||
-- 的 N7 明确「第 2..N 条语音既不转写也不落盘」),形状即 internal/runtime/voice 的
|
||||
-- Ref —— 它就是历史 DTO 里 `voice` 对象**减去 url** 的那份数据(url 由服务端按 name
|
||||
-- 拼出,绝不落库)。
|
||||
--
|
||||
-- 字段语义(逐条见 design/24 §10.4):
|
||||
-- source 转写文本从哪来:asr(服务端语音识别)/ platform(渠道平台自带转写)。
|
||||
-- status 转写结果三态闭集:transcribed / empty / failed。客户端按它分支,
|
||||
-- **绝不按正文文案分支**。
|
||||
-- name 音频副本的服务端存储名(内容寻址、32 位小写十六进制 + 音频白名单扩展名)。
|
||||
-- mime 保存时成对裁决所得的下发 MIME(audio/*)。
|
||||
-- size 副本字节数。name/mime/size 三者**同生共死**:有副本时全在,无副本时全空
|
||||
-- (绝不出现「有 url 指着一个不存在的 name」这种形状)。
|
||||
-- durationMs 只有渠道**如实给出**或转码产物**可确证**时才 >0,绝不估算。
|
||||
--
|
||||
-- 列口径与同族可选列(document / turn_id / client_msg_id)逐字一致:
|
||||
-- `NOT NULL DEFAULT ''`,**绝不用 NULL** —— 于是「老行」与「这条消息不是语音」是同一个
|
||||
-- 值,读侧只有一种空情形要处理(空串 ⇒ DTO 里 `voice` 键**缺席**)。
|
||||
--
|
||||
-- 这一列**不需要索引**:它只被随行读出,没有任何按它过滤/排序/聚合的查询(与 0018 的
|
||||
-- turn_id 同一口径)。引用计数的增减发生在消息写路径上按**新旧列内容**逐条解码比较
|
||||
-- (design/24 §10.5 的加粗警告),不走 SQL 过滤;因此建索引只会给每次写加一笔无收益的
|
||||
-- 维护开销。
|
||||
--
|
||||
-- ⚠️ 引用计数(design/24 §10.5,本列落地时**必须同时做**,否则音频副本会静默坏掉):
|
||||
-- `history.refs` 的 retain/release/sync 今天只认 `document` 列;voice 列里的 name 必须
|
||||
-- 并入**同一次**增减(与消息行同一事务、按新旧名字集合的差增减、保留重复),否则
|
||||
-- 音频副本的 ref_count 恒为 0 ⇒ GC 在保留期后删掉仍被消息引用的音频 ⇒ 历史里的语音
|
||||
-- 变成永远播不出来的死链,而且**没有任何错误**。
|
||||
--
|
||||
-- 回填(本迁移不做,也不该做):老行一律空串 = 「不是语音消息」,行为与加入本列之前
|
||||
-- 逐字相同。存量语音消息的正文是转写文本,但没有来源事实可考 —— 凭空补一个
|
||||
-- `{"source":"asr",...}` 就是编造(与「绝不假装」的口径冲突)。
|
||||
--
|
||||
-- 0003 的 chat_messages 表在此已存在,故 ALTER ADD COLUMN 是非破坏性的正确一步;
|
||||
-- 全新库由 internal/runtime/history.NewStore 幂等地前滚(含该列)。表名沿用复数口径
|
||||
-- (援引 AGENTS §10 已登记的例外,与既有迁移一致)。
|
||||
|
||||
ALTER TABLE chat_messages ADD COLUMN voice TEXT NOT NULL DEFAULT '';
|
||||
Reference in new issue
Block a user