lty/qy_lty/docs/设备聊天记录_字幕落库方案.md
pmc 3b7c5c85f5
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 9m14s
feat: update device interaction views, docs, and CLAUDE.md
- 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>
2026-04-27 17:06:21 +08:00

216 lines
15 KiB
Markdown
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.

# 设备端聊天记录上报功能 — 修改计划(方案 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 改动。