All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 9m14s
- Update device_interaction views - Update admin README and CLAUDE.md - Add affinity system design doc - Add device chat record subtitle storage scheme doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
216 lines
15 KiB
Markdown
216 lines
15 KiB
Markdown
# 设备端聊天记录上报功能 — 修改计划(方案 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 改动。
|