# 设备端聊天记录上报功能 — 修改计划（方案 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 | 直接上 B（A 在第一次部署后即发现颗粒度问题）|
| 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=1（getJson.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`）里，容易遗漏
