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

15 KiB
Raw Permalink Blame History

设备端聊天记录上报功能 — 修改计划(方案 B服务器端字幕落库

Context

设备端 Unity 项目LTY_Project运行在 RK3588 上)需要把"用户在设备前与 Lila 的语音对话"以文字形式落到服务器,让手机端 App 能在原有 /api/ai/rtc-chat-history/ 接口里看到。不需要在设备屏幕显示文字,也不需要打字输入

调研后发现:火山引擎对话式 AI 的"服务端字幕回调"链路已经全部搭好且在跑

  • 设备端 getJson.cs:70-72 已经在 RTC AgentConfig 里配置了 EnableConversationStateCallback=trueServerMessageURLForRTS=httpBaseUrl+"api/device/rtc-token/conversation_status/" —— 火山引擎会把房间内 ASR 字幕(用户和 AI 的)和会话状态推到这个 URL。
  • 服务器 device_interaction/views.py:1199-1421conversation_status action 已经在接收并解析两种二进制格式:
    • subv(字幕格式,views.py:1258-1302):包含 text / userId / definite / paragraph / sequence / language / mode / timestamp —— 其中 text 就是用户和 AI 的对话文本。当前只 log 出来,没有写入数据库
    • conv(对话状态格式,views.py:1309-1376):包含 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保证 RTCChatHistoryAPIViewfilter(user=request.user, bot=bot) 查询直接覆盖。
  • 落库时机:每条字幕到达就处理,仅在判定为"一句话最终结果"时写入 ChatMessage

RTC userId ↔ ParadiseUser 映射(关键事实)

device_interaction/views.py:1142 可知,get_by_mac 返回的 RTC user_id 就是 str(user_device.user.id)ParadiseUser 主键的字符串形式)。这传给设备端 getJson.cs:67targetUserIds.Add(UserId),最终火山字幕回调里 subtitle.userId 字段:

  • userId == "bot01" → AI 说的话assistant来自 getJson.cs:69 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

当前代码:解析出 subtitle_items 列表后只构建 response_data 返回。

需要在 subtitle_items 构建后,紧接着遍历每条 subtitle 并按下面的规则写入 ChatMessage

# 伪代码(详细见执行阶段)
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_idviews.py:1232-1234)。subv 二进制内部解析出来也只有 userId/text/sequence/...也没有 task_idviews.py:1281-1291)。所以"userId='bot01' 时归属哪个用户"需要外部上下文。

首选方案:用最近一次 conv 状态回调的 user_id 作为上下文

conv 状态回调在 views.py:1333-1334 已经解析出 TaskIdUserID(这里的 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} 写到 Redisviews.py:1169),可以在这里额外加一份反向索引 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 已经在打)里观察。

执行阶段第一步是先打开生产日志看一次真实的 subv 字幕回调 raw payload,确认能不能直接拿到 task_id 或类似关联字段。如果能,整个映射问题秒杀;如果不能,用 Redis 维护"最近活跃 task→user 映射"。

3. 流式字幕去重 / "一句话最终"判定

火山引擎对话式 AI 字幕是流式的,同一句话会推多条 definite=false 中间结果,最后推 definite=true 最终结果;多句话之间用 paragraph=true 标记段落结束。

参考火山官方文档(views.py:1206 注释里给的 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 在已有 cache.set(redis_key, token_data, expire_time) 后追加:

# 反向索引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 logger.info('JSON data: %s', json_data)views.py:1275 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 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 字段最大 2048aiapp/models.py:39),超长字幕需要 truncate

6. 回归

确认设备端 RTC 视频/音频/对话状态机aiState一切正常 —— 因为只改了服务器端,设备端逻辑应该零影响。

需修改文件清单

服务器端(唯一改动

  • C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py
    • VolcEngineTokenViewSet.conversation_status action 的 subv 分支(第 1278-1302 行)追加字幕落库逻辑(约 30-50 行)
    • 顶部加 from aiapp.models import ChatMessage, Botfrom userapp.models import ParadiseUser
    • 临时调试期间加 headers / query 的 logger.info第 1217 行 附近)—— 跑通后可保留或删除
    • 可选:在 get_by_mac第 1184 行 后追加 cache.set(f"rtc_task_user:{task_id}", user_id, expire_time)
    • 可选:在 conv 分支 第 1356 行 附近追加"最近活跃 task→user"映射写入

服务器端不需要 migrationChatMessage 表结构不变,复用现有字段)。

服务器端不需要确认 RTC_Voice_Agent Bot 存在 —— 已经被手机端用着,必然存在;执行时跑一次 Bot.objects.get(name='RTC_Voice_Agent') 验证一次。

设备端:无修改。 手机端:无修改。 火山引擎:无配置变化(设备端 getJson.cs 已经配置好了)。

风险与回滚

  • 主要风险:字幕落库逻辑写错 → 数据库写脏数据。缓解:先在本地服务器跑,写完后用 SQL 批量 DELETE 测试期产生的脏数据再上正式环境。
  • 回滚:只需 git revert views.py 的那个 commit因为没有 schema 改动。