lty/qy_lty/docs/设备聊天记录_字幕落库方案.md
pmc c1722413ad feat: update AI app, device interaction, and docs
- Update aiapp views
- Update device_interaction consumers and views
- Update docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:55:42 +08:00

296 lines
20 KiB
Markdown
Raw 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.

# 设备端聊天记录上报功能 — 修改计划(方案 B服务器端字幕落库
## Context
设备端 Unity 项目LTY_Project运行在 RK3588 上)需要把"用户在设备前与 Lila 的语音对话"以文字形式落到服务器,让手机端 App 能在原有 `/api/ai/rtc-chat-history/` 接口里看到。**不需要在设备屏幕显示文字,也不需要打字输入**。
调研后发现:火山引擎对话式 AI 的"服务端字幕回调"链路**已经全部搭好且在跑**
- 设备端 [getJson.cs:70-72](Assets/Scripts/getJson.cs#L70) 已经在 RTC AgentConfig 里配置了 `EnableConversationStateCallback=true``ServerMessageURLForRTS=httpBaseUrl+"api/device/rtc-token/conversation_status/"` —— 火山引擎会把房间内 ASR 字幕(用户和 AI 的)和会话状态推到这个 URL。
- 服务器 [device_interaction/views.py:1199-1421](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1199) 的 `conversation_status` action 已经在接收并解析两种二进制格式:
- `subv`(字幕格式,[views.py:1258-1302](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1258)):包含 `text` / `userId` / `definite` / `paragraph` / `sequence` / `language` / `mode` / `timestamp` —— 其中 `text` 就是用户和 AI 的对话文本。**当前只 log 出来,没有写入数据库**。
- `conv`(对话状态格式,[views.py:1309-1376](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1309)):包含 `TaskId` / `UserID` / `RoundID` / `Stage.Code/Description` —— 已经在通过 WebSocket 转推给设备/手机驱动状态机。
所以方案 B 的本质是:**只在服务器端 `conversation_status` 接口的 `subv` 分支里加几十行写库逻辑**,把字幕文本作为 `ChatMessage` 写到与手机端相同的表里。设备端、手机端、火山引擎一律不动。
## 关键设计决策(已与用户确认)
- **设备端零改动**:不复制 ASR 客户端、不挂 ChatLogManager、不改 LoginRTC、不改 WebSocketConnection。
- **手机端零改动**:手机端 App 现有的 `GET /api/ai/rtc-chat-history/?page_size=50` 在服务器开始落库后会自然返回设备端产生的消息(与手机端 App 自己跟 Lila 聊天的记录混在同一个 user 名下,与之前用户确认的"不加 device_mac 字段"一致)。
- **bot 关联**:设备端字幕也归到 `RTC_Voice_Agent` 这个 Bot 名下(与手机端 App 用同一个 Bot保证 [RTCChatHistoryAPIView](C:/Users/admin/Desktop/Lila-Server/qy_lty/aiapp/views.py#L440) 的 `filter(user=request.user, bot=bot)` 查询直接覆盖。
- **落库时机**:每条字幕到达就处理,仅在判定为"一句话最终结果"时写入 `ChatMessage`
## RTC userId ↔ ParadiseUser 映射(关键事实)
从 [device_interaction/views.py:1142](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1142) 可知,`get_by_mac` 返回的 RTC `user_id` 就是 `str(user_device.user.id)`ParadiseUser 主键的字符串形式)。这传给设备端 [getJson.cs:67](Assets/Scripts/getJson.cs#L67) 的 `targetUserIds.Add(UserId)`,最终火山字幕回调里 `subtitle.userId` 字段:
- `userId == "bot01"` → AI 说的话assistant来自 [getJson.cs:69](Assets/Scripts/getJson.cs#L69) `agentConfig["UserId"] = "bot01"` 的硬编码
- `userId == "<纯数字字符串>"` → 用户说的话,**该数字就是 ParadiseUser.id**
这意味着用户消息可以直接从字幕字段反查 ParadiseUser无需额外映射。
AI 消息(`bot01`)的 user 归属需要别的方法解决 —— 见下面"AI 字幕 user 归属"小节。
## 服务器端 — 核心修改
**唯一改动文件**`C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py`
### 1. 修改 `conversation_status` action 的 `subv` 分支([views.py:1258-1302](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1258)
当前代码:解析出 `subtitle_items` 列表后只构建 response_data 返回。
需要在 `subtitle_items` 构建后,紧接着遍历每条 subtitle 并按下面的规则写入 `ChatMessage`
```python
# 伪代码(详细见执行阶段)
for item in subtitle_items:
text = item['text'].strip()
user_id_in_subtitle = item['userId']
is_definite = item['definite']
is_paragraph_end = item['paragraph']
sequence = item['sequence']
if not text:
continue
# 只在"一句话结束"时写库(见下面"流式字幕去重"小节)
if not (is_definite and is_paragraph_end):
# 中间结果先在 Redis 里累积,不落库
_accumulate_partial_subtitle(task_id_or_round_key, user_id_in_subtitle, text, sequence)
continue
# 反查 ParadiseUser
if user_id_in_subtitle == 'bot01':
sender = ChatMessage.SENDER_BOT # 'assistant'
paradise_user = _resolve_user_for_bot_subtitle(...) # 见 AI 字幕 user 归属
else:
sender = ChatMessage.SENDER_USER # 'user'
try:
paradise_user = ParadiseUser.objects.get(id=int(user_id_in_subtitle))
except (ParadiseUser.DoesNotExist, ValueError):
logger.warning('subtitle userId %s 无法解析为 ParadiseUser跳过', user_id_in_subtitle)
continue
if paradise_user is None:
continue
# 拿 RTC Bot
try:
bot = Bot.objects.get(name='RTC_Voice_Agent')
except Bot.DoesNotExist:
logger.error('RTC_Voice_Agent Bot 未配置,跳过字幕落库')
continue
# 拼接累积的中间结果(如果之前累积过)
final_text = _flush_accumulated_subtitle(task_id_or_round_key, user_id_in_subtitle, text)
ChatMessage.objects.create(
user=paradise_user,
bot=bot,
message=final_text,
sender=sender,
message_type=ChatMessage.MESSAGE_TYPE_TEXT,
)
```
实际选择"直接落库 vs 累积后落库"的策略由执行阶段在第一次跑通后看真实字幕事件流来定 —— 见下面"流式字幕去重"小节的两种策略。
### 2. AI 字幕 user 归属(关键难点)
字幕外层 JSON `{message, binary, signature}` 里**没有 task_id**[views.py:1232-1234](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1232))。`subv` 二进制内部解析出来也只有 `userId/text/sequence/...`**也没有 task_id**[views.py:1281-1291](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1281))。所以"`userId='bot01'` 时归属哪个用户"需要外部上下文。
**首选方案:用最近一次 `conv` 状态回调的 user_id 作为上下文**
`conv` 状态回调在 [views.py:1333-1334](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1333) 已经解析出 `TaskId``UserID`(这里的 UserID 来自火山,等于 ParadiseUser.id 的字符串)。在 `conv` 分支处理时把 `(TaskId, UserID, RoundID)` 写到 Redis short-TTL如 30 秒key 形如 `rtc_active_round:<TaskId>` value=`{user_id, round_id, last_seen_ts}`
但字幕回调没有 task_id没法按 task_id 反查 —— 退而求其次:**维护一个全局"最近活跃用户"的列表**按时间倒序AI 字幕到达时取距其最近的那个用户。这在并发设备数较少时有效;如果同时有多个设备 RTC 房间在跑,需要更精细的关联。
**更稳妥的备选方案:在 `_resolve_user_for_bot_subtitle` 里查 Redis**
由于 `get_by_mac` 已经把 `rtc_room:{user_id}:{task_id}` 写到 Redis[views.py:1169](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1169)),可以在这里**额外加一份反向索引** `rtc_active_user:bot01_last:{round_or_task} → user_id`,由 `conv` 分支负责更新。
**最稳妥的兜底方案:等火山回调 URL 实测**
火山引擎 RTS webhook **可能**在 URL query string 里带 `?task_id=xxx`(很多服务端 webhook 都这么干),或者在 HTTP header 里带(如 `X-Volc-Task-Id`)。设备端配置的 URL 是固定的 `api/device/rtc-token/conversation_status/`(无 query但火山服务器实际调用时是否会附加需要在生产日志`logger.info('JSON data: %s', json_data)` [views.py:1229](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1229) 已经在打)里观察。
执行阶段第一步是**先打开生产日志看一次真实的 subv 字幕回调 raw payload**,确认能不能直接拿到 task_id 或类似关联字段。如果能,整个映射问题秒杀;如果不能,用 Redis 维护"最近活跃 task→user 映射"。
### 3. 流式字幕去重 / "一句话最终"判定
火山引擎对话式 AI 字幕是流式的,同一句话会推多条 `definite=false` 中间结果,最后推 `definite=true` 最终结果;多句话之间用 `paragraph=true` 标记段落结束。
参考火山官方文档([views.py:1206](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1206) 注释里给的 https://www.volcengine.com/docs/6348/1415216的字段定义
- `definite=true`:当前 utterance 已确定
- `paragraph=true`:当前段落(可能含多个 utterance已结束
- `sequence`:序号,单调递增
**两种落库策略,执行阶段二选一**
**策略 A简单**:只在 `definite=true && paragraph=true` 时把 `text` 直接落库。优点:实现简单、不需要状态。缺点:火山的 paragraph 颗粒度可能太大(一段话含好几个完整意群),落库的 message 可能很长;或者反过来颗粒度太小(一个 utterance 就是一段),导致同一轮对话被拆成多条。
**策略 B拼接**:用 Redis 的 List/String 累积同一对话回合(按 `task_id+userId+round_key`)的所有 `definite=true` text遇到 `paragraph=true` 时把累积结果拼接成一条 ChatMessage 落库。需要超时清理(如累积 60 秒还没收到 paragraph_end 就强制落库)。
执行阶段第一步先用策略 A 跑通,观察 Unity Editor + 服务器日志里的真实字幕颗粒度,再决定要不要切策略 B。
### 4. (可选轻量优化)`get_by_mac` 写一份反向索引
修改 [views.py:1167-1184](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1167) 在已有 `cache.set(redis_key, token_data, expire_time)` 后追加:
```python
# 反向索引task_id → user_id用于字幕回调中 bot01 字幕的 user 归属
cache.set(f"rtc_task_user:{task_id}", user_id, expire_time)
```
这样 `conversation_status` 处理 `conv` 回调时能 `cache.get(f"rtc_task_user:{task_id}")` 直接拿到 user_id不依赖时序假设。
只在执行阶段确认"火山字幕回调里有 task_id 关联"时这步才有意义;如果没有 task_id这层映射救不了字幕分支字幕分支根本不知道当前 task_id 是哪个)。
## 验证
按顺序:
### 1. 摸火山真实字幕回调的 schema最关键的第一步
在不改任何代码的情况下,先收集一次真实字幕事件:
- 服务器开 DEBUG 日志,确认 [views.py:1229](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1229) `logger.info('JSON data: %s', json_data)` 和 [views.py:1275](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1275) `logger.info('Parsed subtitle data: %s', subtitle_data)` 都能输出
- 设备端真机或 Editor Play → 进入 Game 场景 → RTC 加房 → 对着麦说一句话
- 看服务器日志里:
- 外层 JSON 除了 `message/binary/signature` 还有没有别的字段(可能藏着 task_id
- HTTP 请求 URL可能带 `?task_id=`)和 headers可能带 `X-Volc-*`)—— 需要在 view 函数最顶端临时加 `logger.info('headers=%s, query=%s', dict(request.headers), request.query_params.dict())`
- subtitle_data 内的所有字段,特别是 `userId='bot01'` 时有没有别的字段能关联到 user
这一步直接决定第 2 节"AI 字幕 user 归属"用哪种方案。
### 2. 改代码 + 跑通用户消息
先实现"用户消息"分支subtitle.userId 是数字 → ParadiseUser—— 这部分不依赖 task_id 映射,最容易跑通。
- 改完后用 `python manage.py runserver` 起本地服务
- 设备端切到本地([RootManager.cs:30](Assets/Scripts/RootManager.cs#L30) `LocalTest=true`
- Editor Play 加 RTC 房 → 说一句话 → 在 Django shell 里 `ChatMessage.objects.filter(sender='user').order_by('-id')[:5]` 看是否有新记录
### 3. 跑通 AI 消息
按第 1 步实测结果选第 2 节的方案,实现 `bot01` → user 归属。
然后让 Lila 回一句话,验证 `ChatMessage.objects.filter(sender='assistant').order_by('-id')[:5]`
### 4. 三端联调
用手机端 App 登录绑定该设备的同一个用户账号进入聊天记录界面确认能看到刚才设备前对话的字幕user 和 assistant 各一条)。
### 5. 真机验证 + 长稳
烧 APK 到 RK3588对话 5-10 分钟,看:
- ChatMessage 表数量是否符合预期(无重复、无丢失)
- 服务器 error log 有没有写库失败(数据库唯一约束、字段长度等)
- ChatMessage.message 字段最大 2048[aiapp/models.py:39](C:/Users/admin/Desktop/Lila-Server/qy_lty/aiapp/models.py#L39)),超长字幕需要 truncate
### 6. 回归
确认设备端 RTC 视频/音频/对话状态机aiState一切正常 —— 因为只改了服务器端,设备端逻辑应该零影响。
## 需修改文件清单
服务器端(**唯一改动**
- `C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py`
-`VolcEngineTokenViewSet.conversation_status` action 的 `subv` 分支([第 1278-1302 行](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1278))追加字幕落库逻辑(约 30-50 行)
- 顶部加 `from aiapp.models import ChatMessage, Bot``from userapp.models import ParadiseUser`
- 临时调试期间加 headers / query 的 logger.info[第 1217 行](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1217) 附近)—— 跑通后可保留或删除
- 可选:在 `get_by_mac` 的 [第 1184 行](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1184) 后追加 `cache.set(f"rtc_task_user:{task_id}", user_id, expire_time)`
- 可选:在 `conv` 分支 [第 1356 行](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1356) 附近追加"最近活跃 task→user"映射写入
服务器端**不需要 migration**ChatMessage 表结构不变,复用现有字段)。
服务器端**不需要确认 RTC_Voice_Agent Bot 存在** —— 已经被手机端用着,必然存在;执行时跑一次 `Bot.objects.get(name='RTC_Voice_Agent')` 验证一次。
设备端:**无修改**。
手机端:**无修改**。
火山引擎:**无配置变化**(设备端 getJson.cs 已经配置好了)。
## 风险与回滚
- 主要风险:字幕落库逻辑写错 → 数据库写脏数据。缓解:先在本地服务器跑,写完后用 SQL 批量 DELETE 测试期产生的脏数据再上正式环境。
- 回滚:只需 `git revert` views.py 的那个 commit因为没有 schema 改动。
---
## 实施记录2026-04-27 ~ 04-28
### 与原方案的偏差
| 项 | 原方案 | 最终落地 |
|---|---|---|
| 设备端改动 | 零改动 | **必须改** —— 加 `Config.SubtitleConfig.SubtitleMode = 1` |
| 落库策略 | 先 A 跑通再视情况切 B | 直接上 BA 在第一次部署后即发现颗粒度问题)|
| AI 字幕归属 | 三选一task_id 主、last_active_user 兜底、Redis 反向索引)| 三层叠加:归属锁 + task_id 反向索引 + last_active_user 兜底 |
| Traefik access log | 未提及 | 加了 `k8s/traefik-config.yaml`HelmChartConfig作为永久诊断通道 |
### 服务端最终改动([device_interaction/views.py](../device_interaction/views.py)
| # | 位置 | 改动 |
|---|------|------|
| 1 | 顶部 imports | 加 `ChatMessage` / `Bot` / `ParadiseUser` 引用,加模块级 `_merge_subv_segments` 辅助函数(自适应累积式 vs 增量式合并)|
| 2 | `get_by_mac` 末尾 | 写入 `rtc_task_user:{task_id} → user_id` 反向索引 |
| 3 | `conversation_status` 入口 | 永久 INFO 日志记录 webhook headers + query用于排查未来字幕归属异常|
| 4 | `conv` 分支 | 每次刷新 `rtc_task_user:{task_id}`TTL 1h`rtc_last_active_user`TTL 60s|
| 5 | `subv` 分支 | 完整 strategy B 落库逻辑AI 归属锁 `rtc_current_bot_owner`、按 sequence 排序累积、自适应合并、`(sender,user_id,sequence)` 维度防重、文本截断到 2048、Bot.id 缓存 1h、**空文本 paragraph 终止信号兼容** |
### 设备端改动([getJson.cs](C:/Unity2022project/LTY_Project/Assets/Scripts/getJson.cs)
`Config` 块(与 ASRConfig/TTSConfig/LLMConfig/FunctionCallingConfig 同级)追加:
```csharp
JsonData subtitleConfig = new JsonData();
subtitleConfig["SubtitleMode"] = 1;
config["SubtitleConfig"] = subtitleConfig;
```
为什么必须改默认Mode=0字幕来源于 TTS与音频时间戳对齐但**字幕生成稍慢**。火山发 `paragraph=true` 是按 LLM 完成时刻发的TTS 端字幕生成器不一定追上 → DB 里 AI 消息末段被截断音频却是完整的。Mode=1 字幕直接取 LLM 原始输出,与 TTS 时间无关,**完整性可保证**,且能保留 emoji/括号等原始符号。
文档依据:[volcengine.com/docs/6348/1337284 #服务端实现](https://www.volcengine.com/docs/6348/1337284?lang=zh#.5pyN5Yqh56uv5a6e546w)。注意这份文档跟设备 webhook 引用的 `/docs/6348/1415216` 不是同一份,**SubtitleMode 仅在前者里说明**。
### 火山官方文档没明说的关键 bug
`SubtitleMode=1` 下,`paragraph=true` 是**独立的空文本终止信号**
- 多条 `definite=True, paragraph=False`:每条 text 是一个真实分句
- 最后一条 `definite=True, paragraph=True`**text 是空字符串**,作为段落结束的纯信号
最初策略 B 实现里有 `if not text.strip(): continue` 顺手吞掉了这个空文本事件 —— Mode=0 下不暴露Mode=0 的 paragraph=true 事件 text 非空),切到 Mode=1 后 **paragraph=true 永远到不了 flush 触发器**buffer 一直累积到 TTL 过期被丢弃AI 消息全部丢失。
修复:
```python
# 跳过条件加 paragraph_end 例外
if not text.strip() and not is_paragraph_end:
continue
# buffer.append 前加守卫,避免空信号污染
if text.strip():
buf.append({'seq': sequence, 'text': text})
```
### 验证结果
2026-04-28 08:16-08:17 三轮对话(巴西/日本/阳江天气)全部完整入库:
- 3 条 user 消息5-11 字符)
- 3 条 assistant 消息92-107 字符),句末完整(标点齐全),保留括号叙述与 emoji
### 提交链路
| 提交 | 改动 |
|------|------|
| `c70bee7` | feat: 服务端 strategy B 字幕落库 + 双层 AI 归属views.py|
| `c85f6f2` | feat: 启用 Traefik access log 诊断通道k8s + CI|
| `0330124` | fix: 兼容 Mode=1 下 paragraph=true 的空文本终止信号 |
| (设备端) | feat: 加 SubtitleConfig.SubtitleMode=1getJson.cs需重新打包烧录|
### 经验
1. **服务端字幕是流式 delta**:每分句一条独立 webhook需要按 sequence 累积合并到 paragraph=true不是单次推送
2. **paragraph=true 的语义跨 SubtitleMode 不一致**Mode=0 末段含完整文本Mode=1 是独立空文本终止信号 —— 两种都要兼容
3. **`errorOccurred` 不是字幕截断的根因**:之前一度怀疑它,最终证明它只是中间状态、跟字幕完整性无关
4. **"音频完整、字幕截断" → 先查 `SubtitleMode`**:这个配置藏在 RTC 字幕功能文档(`/docs/6348/1337284`)里,不在对话式 AI 的字幕回调文档(`/docs/6348/1415216`)里,容易遗漏