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:
parent
0330124b19
commit
c1722413ad
@ -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,
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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=<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 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`
|
||||
|
||||
@ -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`)里,容易遗漏
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user