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>
This commit is contained in:
pmc 2026-04-29 11:55:42 +08:00
parent 0330124b19
commit c1722413ad
5 changed files with 232 additions and 7 deletions

View File

@ -458,13 +458,29 @@ class RTCChatHistoryAPIView(APIView):
page_size = int(request.query_params.get('page_size', 50))
page_size = min(page_size, 200)
# since_id 增量拉取B' 方案 WebSocket 漏推后,客户端用此参数补漏
# 不传 since_id返回最近 page_size 条(兼容老逻辑)
# 传 since_id返回 id > since_id 的所有消息,按时间升序,最多 page_size 条
since_id_raw = request.query_params.get('since_id')
queryset = ChatMessage.objects.filter(
user=request.user,
bot=bot
).order_by('timestamp')
total = queryset.count()
messages = queryset[max(0, total - page_size):]
if since_id_raw:
try:
since_id = int(since_id_raw)
queryset = queryset.filter(id__gt=since_id)
messages = queryset[:page_size]
total = queryset.count()
has_more = total > page_size
except ValueError:
return error_response(message='since_id 必须是整数')
else:
total = queryset.count()
messages = queryset[max(0, total - page_size):]
has_more = total > page_size
data = {
'messages': [
@ -477,7 +493,7 @@ class RTCChatHistoryAPIView(APIView):
for msg in messages
],
'total': total,
'has_more': total > page_size,
'has_more': has_more,
}
return success_response(data=data)
@ -494,6 +510,29 @@ class RTCChatHistoryAPIView(APIView):
if sender not in ('user', 'assistant'):
return error_response(message='sender 必须是 user 或 assistant')
# B' 灰度期去重:手机端老 App 仍走 POST与 strategy B webhook 路径并存会双倒
# 同一 (user, bot, sender, message) 在 ±2s 内已存在则跳过
# 该机制同时保护 strategy B 自身重放 / 客户端重试场景
from datetime import timedelta
from django.utils import timezone
now = timezone.now()
existing = ChatMessage.objects.filter(
user=request.user,
bot=bot,
sender=sender,
message=message_text,
timestamp__gte=now - timedelta(seconds=2),
timestamp__lte=now + timedelta(seconds=2),
).first()
if existing is not None:
logger.info('RTC 聊天 POST 去重命中: user=%s sender=%s msg_id=%s',
request.user.id, sender, existing.id)
return success_response(data={
'id': existing.id,
'timestamp': existing.timestamp.isoformat(),
'deduplicated': True,
}, message='消息已存在(去重)')
chat_msg = ChatMessage.objects.create(
user=request.user,
bot=bot,

View File

@ -605,7 +605,7 @@ class DeviceConsumer(AsyncWebsocketConsumer):
"""
try:
message = event['message']
# 发送字幕消息到 WebSocket
await self.send(text_data=json.dumps({
'type': 'conversation_subtitle',
@ -614,6 +614,24 @@ class DeviceConsumer(AsyncWebsocketConsumer):
logger.info(f"Sent subtitle to WebSocket: {len(message.get('subtitles', []))} items")
except Exception as e:
logger.error(f"Error in conversation_subtitle: {str(e)}")
async def chat_message_persisted(self, event):
"""
手机端 B' 方案:字幕落库后由 strategy B 通过 group_send 触发
将已持久化的 ChatMessage 推回客户端用于 UI 静默替换 ASR 版本为 LLM 原始版本
"""
try:
message = event['message']
await self.send(text_data=json.dumps({
'type': 'chat_message_persisted',
'message': message
}))
logger.info(
f"Sent chat_message_persisted to WebSocket: id={message.get('id')} "
f"sender={message.get('sender')} source_client={message.get('source_client')}"
)
except Exception as e:
logger.error(f"Error in chat_message_persisted: {str(e)}")
async def forward_message(self, event):
"""

View File

@ -1411,15 +1411,37 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
if full_text:
try:
ChatMessage.objects.create(
chat_msg = ChatMessage.objects.create(
user_id=int(paradise_user_id),
bot_id=rtc_bot_id,
message=full_text[:2048],
sender=sender,
message_type=ChatMessage.MESSAGE_TYPE_TEXT,
)
logger.info('字幕落库成功: sender=%s user=%s len=%d segs=%d',
sender, paradise_user_id, len(full_text), len(buf_sorted))
logger.info('字幕落库成功: sender=%s user=%s len=%d segs=%d id=%s',
sender, paradise_user_id, len(full_text), len(buf_sorted), chat_msg.id)
# B' 方案:通过 WebSocket 把落库消息推给同 user_id 的客户端
# 客户端按 chat_msg.id 去重 + 按 (sender, timestamp ±10s) 匹配本地待替换队列做静默替换
# source_client 字段用于区分手机端 / 设备端会话;目前服务端尚无稳定区分手段,暂传 'unknown'
# TODO 决策点 #3 落定后填充 'phone' / 'device'
try:
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
f"device_{paradise_user_id}",
{
'type': 'chat_message_persisted',
'message': {
'id': chat_msg.id,
'sender': sender,
'message': full_text[:2048],
'timestamp': chat_msg.timestamp.isoformat(),
'source_client': 'unknown',
}
}
)
except Exception as ws_err:
logger.warning('字幕落库 WebSocket 转推失败 (不影响主流程): %s', ws_err)
except Exception as e:
logger.error('字幕落库失败: %s, sender=%s, text=%r',
e, sender, full_text[:100])

View File

@ -23,6 +23,72 @@
<!-- 新的修改记录添加在此处下方,最新的在最前面 -->
### [2026-04-29] 手机端聊天记录切换服务端字幕落库B' 方案 服务端部分)
配套手机端方案文档:`LTY_App_Project_URP/docs/手机端聊天记录_切换服务端字幕落库方案.md`。手机端已实施 B'(本地 ASR 实时显示 + 服务端 webhook 静默替换服务端需要补三件事strategy B 落库后 group_send 推回客户端、DeviceConsumer 加 handler、RTCChatHistoryAPIView 灰度期去重 + since_id 增量拉取。
#### 修改 1strategy B 落库成功后 group_send 转推
- **文件路径**: `device_interaction/views.py`
- **修改类型**: 新增功能
- **修改内容**:
- 在 `conversation_status` action 内字幕落库分支(约 L1414 `ChatMessage.objects.create(...)` 处):
- 把 `create()` 返回值赋给变量 `chat_msg`,落库成功 log 加上 `id` 字段
- 落库成功后追加 `channel_layer.group_send` 调用,向 `device_{paradise_user_id}` 群组发送 `type='chat_message_persisted'` 消息payload 含 `id` / `sender` / `message` / `timestamp` / `source_client`
- 用独立 `try/except` 包住,转推失败仅 warning 日志,不影响主落库流程
- `source_client` 暂传 `'unknown'`(决策点 #3 落定后改为 `'phone'` / `'device'`
- **修改原因**:
- 手机端 B' 方案需要服务端在字幕入库后通过 WebSocket 把"权威 LLM 原始版本"推回客户端
- 手机端按 `chat_msg.id` 去重 + 按 `(sender, timestamp ±10s)` 匹配本地待替换队列做静默替换,达到 UI 与 DB 字符级一致
- 不影响设备端:设备端不订阅 `chat_message_persisted` 类型即可DeviceConsumer handler 仅向已实现处理的客户端透传)
#### 修改 2DeviceConsumer 加 chat_message_persisted handler
- **文件路径**: `device_interaction/consumers.py`
- **修改类型**: 新增功能
- **修改内容**:
- 在 `conversation_subtitle` handler 之后新增 `chat_message_persisted` handler
- 接收 group_send 事件后通过 `self.send` 把 JSON 推到 WebSocket 客户端
- 日志记录 `id` / `sender` / `source_client` 用于后续排查
- **修改原因**:
- Channels 协议要求 group_send 的 `type` 字段值在 Consumer 上有同名方法处理,否则消息被丢弃且报警
- 必须与修改 1 同步部署,否则 strategy B 的 group_send 调用会失败
#### 修改 3RTCChatHistoryAPIView 灰度期 POST 去重 + GET since_id 支持
- **文件路径**: `aiapp/views.py`
- **修改类型**: 新增功能 + 增强
- **修改内容**:
- `RTCChatHistoryAPIView.post()` 入口加去重判定:同一 `(user, bot, sender, message)``±2s` 时间窗内已存在则跳过 `create`,返回 `deduplicated: true`
- `RTCChatHistoryAPIView.get()` 支持 `since_id` query 参数:传入则返回 `id > since_id` 的消息(升序,最多 page_size 条),未传则保持原最近 page_size 条逻辑
- **修改原因**:
- **灰度期双倒保护**:手机端 App 发版到用户手里需要时间,老版仍走 POST 落库strategy B webhook 此时也在落库 → 同一对话产生重复行。POST 去重让两条路径并存而不致脏库
- **重放保护**strategy B 自身被火山重试或客户端重连补提时,去重也能挡住
- **WebSocket 漏推兜底**B' 方案手机端 5s 超时未收到 `chat_message_persisted` 时调 `GET ?since_id=<last>` 增量拉取替换队列里待修正的消息
#### 关联代码(手机端,仅记录依赖关系)
- 手机端 `Assets/Scripts/Manager/WebSocketNetworking.cs` 已新增 `chat_message_persisted` 类型分发分支
- 手机端 `Assets/Scripts/AI/ChatLogManager.cs` 已新增 `OnServerChatPersisted` 方法、`_pendingReplaceQueue` 与 5s 超时兜底
- 手机端 `Assets/Scripts/AI/getJson.cs` 已加 `Config.SubtitleConfig.SubtitleMode=1`
#### 部署顺序与回滚
- **部署顺序**:服务端先部署(修改 1+2+3 三处一同上线)→ 验证 group_send 通道工作 → 手机端再发版
- **回滚**:三处改动都用独立 try/except 包住,可独立 git revert
- 修改 1 revertstrategy B 主流程不受影响,只是不再 group_send手机端 UI 替换路径变为 5s 超时兜底
- 修改 2 revert与修改 1 必须同时 revert否则 group_send 收方为空报警
- 修改 3 revertPOST 不再去重(灰度期会出现双倒,需人工清理 DBGET 不再支持 since_id手机端兜底拉取无效
#### 待跟进 TODO
- 决策点 #3:服务端区分手机端 / 设备端 RTC sessionmac 标记 / task_id 命名规则)→ `source_client` 字段填充真实值,让两端按需过滤
- 决策点 #5:服务端验证打断时是否仍 flush 部分内容;如不 flush手机端打断分支应跳过入待替换队列以避免 5s 超时空触发
- Phase 0 步骤 1DB 双轨验证SQL 见 `LTY_App_Project_URP/docs/手机端聊天记录_切换服务端字幕落库方案.md`
- Phase 0 步骤 4清理历史脏数据如发现
---
### [2026-03-17] 修复手机号登录时 IntegrityError
- **文件路径**: `userapp/views.py`

View File

@ -213,3 +213,83 @@ cache.set(f"rtc_task_user:{task_id}", user_id, expire_time)
- 主要风险:字幕落库逻辑写错 → 数据库写脏数据。缓解:先在本地服务器跑,写完后用 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`)里,容易遗漏