From c1722413ad52a98a4530808464836acd506afc9f Mon Sep 17 00:00:00 2001 From: pmc <740076875@qq.com> Date: Wed, 29 Apr 2026 11:55:42 +0800 Subject: [PATCH] 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 --- qy_lty/aiapp/views.py | 45 ++++++++++++- qy_lty/device_interaction/consumers.py | 20 +++++- qy_lty/device_interaction/views.py | 28 ++++++++- qy_lty/docs/修改记录.md | 66 +++++++++++++++++++ qy_lty/docs/设备聊天记录_字幕落库方案.md | 80 ++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 7 deletions(-) diff --git a/qy_lty/aiapp/views.py b/qy_lty/aiapp/views.py index 4ea7900..986cb98 100644 --- a/qy_lty/aiapp/views.py +++ b/qy_lty/aiapp/views.py @@ -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, diff --git a/qy_lty/device_interaction/consumers.py b/qy_lty/device_interaction/consumers.py index 165a39c..c1dc9fa 100644 --- a/qy_lty/device_interaction/consumers.py +++ b/qy_lty/device_interaction/consumers.py @@ -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): """ diff --git a/qy_lty/device_interaction/views.py b/qy_lty/device_interaction/views.py index c3f87f3..f1ee9a5 100644 --- a/qy_lty/device_interaction/views.py +++ b/qy_lty/device_interaction/views.py @@ -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]) diff --git a/qy_lty/docs/修改记录.md b/qy_lty/docs/修改记录.md index 727be68..550c2a4 100644 --- a/qy_lty/docs/修改记录.md +++ b/qy_lty/docs/修改记录.md @@ -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 增量拉取。 + +#### 修改 1:strategy 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 仅向已实现处理的客户端透传) + +#### 修改 2:DeviceConsumer 加 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 调用会失败 + +#### 修改 3:RTCChatHistoryAPIView 灰度期 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=` 增量拉取替换队列里待修正的消息 + +#### 关联代码(手机端,仅记录依赖关系) + +- 手机端 `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 revert:strategy B 主流程不受影响,只是不再 group_send,手机端 UI 替换路径变为 5s 超时兜底 + - 修改 2 revert:与修改 1 必须同时 revert,否则 group_send 收方为空报警 + - 修改 3 revert:POST 不再去重(灰度期会出现双倒,需人工清理 DB);GET 不再支持 since_id(手机端兜底拉取无效) + +#### 待跟进 TODO + +- 决策点 #3:服务端区分手机端 / 设备端 RTC session(mac 标记 / task_id 命名规则)→ `source_client` 字段填充真实值,让两端按需过滤 +- 决策点 #5:服务端验证打断时是否仍 flush 部分内容;如不 flush,手机端打断分支应跳过入待替换队列以避免 5s 超时空触发 +- Phase 0 步骤 1:DB 双轨验证(SQL 见 `LTY_App_Project_URP/docs/手机端聊天记录_切换服务端字幕落库方案.md`) +- Phase 0 步骤 4:清理历史脏数据(如发现) + +--- + ### [2026-03-17] 修复手机号登录时 IntegrityError - **文件路径**: `userapp/views.py` diff --git a/qy_lty/docs/设备聊天记录_字幕落库方案.md b/qy_lty/docs/设备聊天记录_字幕落库方案.md index b917638..d027d53 100644 --- a/qy_lty/docs/设备聊天记录_字幕落库方案.md +++ b/qy_lty/docs/设备聊天记录_字幕落库方案.md @@ -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 | 直接上 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`)里,容易遗漏