Skip to content

TimeFlow 多轮语音助手改造方案 #145

Description

@znnnnnnn-wil

TimeFlow 多轮语音助手改造方案

版本:V3
状态:讨论确认稿
范围:账号设备绑定、安全 WebSocket、多轮上下文、指代消解、语音日程增删改查、语音地点解析

1. 背景

TimeFlow 当前的语音处理方式是一次录音对应一次独立的日程解析,模型调用只接收本次 ASR 文本,没有持久化对话上下文,也没有真正的账号认证和账号数据隔离。

本次改造目标是在保持实现相对简单的前提下,将其升级为可持续多轮交互的语音日程助手,同时通过账号与设备绑定为未来多设备支持预留基础。

2. 本期目标

本期实现:

  • 建立账号与客户端设备的绑定关系。
  • 使用短期、单次 WebSocket ticket 完成连接认证。
  • 同一 App 进程内 WebSocket 临时断线重连时,可以继续当前对话。
  • App 第一次打开,或 App 进程被清除后再次启动时,始终创建新对话。
  • 使用一张 conversation_records 表保存消息和对话状态事件。
  • 每次模型调用携带最近 20 条上下文消息。
  • 支持“这个、那个、刚才的、第二个”等简单指代消解。
  • 支持通过语音查询、新增、修改、删除日程。
  • 所有日程写操作在真正执行前由用户确认。
  • 支持从语音中提取地名并搜索地点。
  • 不明确时通过多轮对话让用户从候选地点或候选日程中选择。

3. 本期不做

本期明确不包含:

  • 不同设备之间共享对话。
  • 多设备之间实时同步日程。
  • 账号下全部设备的 WebSocket 广播。
  • device_schedule_states 等设备日程同步表。
  • 多设备系统日历、闹钟同步。
  • 地理围栏多设备策略调整。
  • Redis 和多实例部署。
  • 长期对话摘要、向量检索或长期记忆。
  • App 冷启动后恢复或继续上一次对话。

这些能力以后确有需求时再单独设计,不进入本期实现。

4. 核心身份模型

本期区分四种身份:

对象 标识 生命周期
账号 user_id 账号长期有效
账号设备 account_device_id 账号撤销设备前有效
对话 conversation_id 一段对话期间有效
WebSocket 连接 connection_id 每次连接重新生成

WebSocket 断线后无法复用原来的底层连接。同一 App 进程内发生临时断线时,重连会生成新的 connection_id,但进程内正在使用的 conversation_id 可以保持不变。如果 App 进程已经被清除,新的 App 进程不再使用旧 conversation_id,而是创建新对话。

本期同一个 account_device_id 只允许存在一个活跃 WebSocket。新连接认证成功后,服务端主动关闭旧连接。

5. 账号认证前提

本方案假设系统已有或同期接入账号登录能力,并能够向客户端签发 access token。

如果账号来自外部认证服务,user_id 保存外部账号主体标识;如果完全自建认证,应独立设计账号和凭证表,不能把密码或长期账号凭证放入设备表。

设备 UUID 只是设备标识,不是登录凭证。仅知道某个设备 UUID,不能获得对应账号权限。

6. 账号设备绑定

6.1 account_devices

新增最小化设备绑定表:

id UUID PRIMARY KEY
user_id TEXT NOT NULL
device_uuid UUID NOT NULL
created_at TIMESTAMPTZ NOT NULL
revoked_at TIMESTAMPTZ NULL

UNIQUE(user_id, device_uuid)

字段说明:

  • id:服务端生成的 account_device_id
  • user_id:认证后的账号 ID。
  • device_uuid:当前客户端安装实例的稳定 UUID。
  • created_at:账号首次绑定该设备的时间。
  • revoked_at:账号撤销该设备的时间;为空表示仍然有效。

本期不保存设备名称、平台、App 版本和最后在线时间。如以后确有展示或管理需求,再增加相关字段。

6.2 客户端不判断“首次登录”

客户端不需要单独维护 is_first_login

每次 App 启动统一执行:

  1. 从持久化存储读取 device_uuid
  2. 如果不存在,使用安全随机 UUID 生成器创建并保存。
  3. 完成登录或刷新 access token。
  4. 调用幂等设备绑定接口。
  5. 服务端存在绑定时返回原来的 account_device_id
  6. 服务端不存在绑定时创建记录并返回新的 account_device_id
  7. 客户端申请 WebSocket ticket。
  8. 客户端自动建立 WebSocket。

设备绑定接口:

POST /api/v1/devices/bind
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "device_uuid": "550e8400-e29b-41d4-a716-446655440000"
}

响应:

{
  "account_device_id": "服务端生成或已经存在的 UUID"
}

客户端可以在每次启动或登录后重复调用该接口,不会产生重复绑定。

7. WebSocket ticket

7.1 申请 ticket

设备绑定完成后,客户端自动申请 ticket:

POST /api/v1/ws-tickets
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "account_device_id": "..."
}

服务端验证:

  • access token 对应的 user_id
  • account_device_id 是否属于该账号。
  • 设备是否已经被撤销。

验证通过后返回:

{
  "ticket": "安全随机字符串",
  "expires_in": 30
}

客户端立即连接:

wss://api.example.com/ws?ticket=<ticket>

以上步骤全部由 App 自动完成,用户仍然只会感知到“打开 App 后自动连接”。

7.2 TTL

TTL 是 Time To Live,即数据的有效存活时间。

本期 ticket 的 TTL 暂定为 30 秒。ticket 发出 30 秒后,无论是否使用都失效;成功使用一次后立即删除,不能再次连接。

7.3 内存 ticket 存储

当前项目以单进程示例运行,因此本期使用内存保存 ticket:

ticket_hash
    → user_id
    → account_device_id
    → expires_at

服务端只保存 ticket 哈希,不保存原始 ticket。

WebSocket 验证成功时,使用类似以下语义的原子消费操作:

ticket_record = ticket_store.pop(ticket_hash)

如果找不到、已经过期或已经被使用,则拒绝连接。

过期记录可以在读取时顺便删除,也可以通过简单的后台定时任务清理。

7.4 什么时候迁移到 Redis

是否需要 Redis 不单纯取决于用户数量,而取决于是否出现多个后端进程或实例。

出现以下情况时需要将 ticket 存储迁移到 Redis:

  • Uvicorn 使用多个 worker。
  • 后端启动多个进程。
  • 部署多个容器或服务器。
  • ticket HTTP 请求和 WebSocket 连接可能落到不同实例。

本期定义统一的 WebSocketTicketStore 接口,并实现 InMemoryWebSocketTicketStore。以后只需替换为 RedisWebSocketTicketStore,上层认证流程不变。

后端重启会导致尚未使用的内存 ticket 失效。客户端连接失败后重新申请 ticket 即可,这对于 30 秒临时凭证是可以接受的。

8. WebSocket 连接上下文

ticket 验证成功后,服务端建立可信连接上下文:

ConnectionContext
├── user_id
├── account_device_id
├── connection_id
└── connected_at

后续业务处理器从 ConnectionContext 取得身份,不接受客户端在业务消息中自行指定 user_idaccount_device_id

连接管理器本期保存:

account_device_id → WebSocket connection

同一账号设备重连时:

  1. 验证新连接。
  2. 为新连接生成 connection_id
  3. 注册新连接。
  4. 主动关闭旧连接。
  5. 旧连接不再允许执行后续业务消息。

认证成功响应:

{
  "type": "session.ready",
  "account_device_id": "...",
  "connection_id": "...",
  "server_time": "..."
}

本期不建立账号下全部设备的连接索引,也不进行账号级广播。

9. 单表对话记录

9.1 设计原则

本期不分别建立 conversationsconversation_messages,而是合并成一张 conversation_records 表。

不把全部消息保存进一行 JSON 数组,而是每条消息或状态变化各占一行。conversation_id 相同的记录共同组成一段对话。

9.2 conversation_records

id BIGSERIAL PRIMARY KEY
conversation_id UUID NOT NULL
user_id TEXT NOT NULL
account_device_id UUID NOT NULL
record_type TEXT NOT NULL
role TEXT NULL
content TEXT NULL
payload JSONB NOT NULL DEFAULT '{}'
client_message_id UUID NULL
operation_id UUID NULL
created_at TIMESTAMPTZ NOT NULL

UNIQUE(conversation_id, client_message_id)
UNIQUE(operation_id, record_type)

建议索引:

(user_id, account_device_id, conversation_id, id)
(conversation_id, record_type, id)

9.3 记录类型

本期使用以下 record_type

user.message
assistant.message
tool.result
schedule.candidates
schedule.focus.changed
location.candidates
location.selected
pending_action.set
pending_action.cleared
operation.completed
operation.failed

普通用户消息:

{
  "record_type": "user.message",
  "role": "user",
  "content": "把周五的会议改到四点",
  "payload": {}
}

工具结果:

{
  "record_type": "tool.result",
  "role": "tool",
  "content": null,
  "payload": {
    "tool_name": "schedule.search",
    "result": {}
  }
}

9.4 对话创建与生命周期

App 进程启动或用户主动新建对话时,由服务端生成新的 conversation_id 并返回给客户端。此时不立即写数据库;用户发送第一条消息后,第一条 user.message 就代表这段对话正式开始。

如果用户尚未发送任何消息就结束 App,该空对话不会留下数据库记录。

conversation_id 只保存在当前 App 进程内,不写入客户端长期持久化存储。

生命周期规则:

  • App 第一次打开:创建新对话。
  • App 进程被用户清除、被系统回收或彻底结束后再次启动:创建新对话。
  • 同一 App 进程内只是 WebSocket 临时断开并自动重连:继续当前 conversation_id
  • 用户主动点击“新对话”:创建新的 conversation_id
  • 新对话不会自动加载上一段对话的消息,模型上下文也不包含上一段对话。
  • 旧对话记录仍可保留在数据库中用于日志和问题排查,但本期客户端不提供恢复入口。

同一 App 进程内重连并继续当前对话时,服务端必须校验:

record.user_id == connection.user_id
record.account_device_id == connection.account_device_id
record.conversation_id == requested_conversation_id

本期不同设备之间不共享对话。即使登录同一个账号,不同 account_device_id 也不能读取彼此的对话记录。

客户端只在进程内存中保存当前 conversation_id。进程结束后该值自然丢失,下次启动直接创建新对话,不查询或恢复上一次对话。

以后需要跨设备共享时,可以保留 user_id 归属并调整设备访问规则,无需改变每条记录的基本结构。

10. 最近 20 条上下文

数据库保存完整对话记录,模型调用时只加载最近 20 条上下文消息。

计入 20 条的记录类型:

user.message
assistant.message
tool.result

地点候选、日程候选、焦点和待确认操作作为结构化状态单独加载,不占普通消息条数。

每次模型调用包含:

  1. 系统提示词。
  2. 当前时间和时区。
  3. 最近 20 条有效消息。
  4. 当前日程焦点和最近候选。
  5. 当前地点候选。
  6. 当前待确认操作。
  7. 当前用户消息。

历史消息必须按照实际角色传入模型:

system
user
assistant
tool

不能把全部历史拼接成一条用户消息。

除 20 条数量限制外,再设置一个可配置的 token 上限。消息过长时从最早记录开始裁剪,但必须保留当前用户消息、当前待确认操作、当前焦点以及必要的最近工具结果。

本期不做长期摘要和向量检索。

11. 消息顺序与幂等

客户端每次发送一条新的用户消息时生成 client_message_id。网络重试时继续使用原来的 client_message_id,不能重新生成。

唯一约束:

UNIQUE(conversation_id, client_message_id)

用于保证网络重试或重连后重复发送相同消息时,不会重复执行模型调用和日程操作。

request_id 标识某一次网络请求,重试时可以变化;client_message_id 标识用户逻辑上的同一条消息,重试时必须保持不变。

conversation_records.id 使用数据库自增的 BIGSERIAL。读取同一对话的历史记录时按 id 排序,因此不再维护每个对话自己的 sequence,也不需要序号分配锁。

同一对话一次只处理一条用户消息:

  • 前端在助手处理中禁用下一次录音或发送。
  • 后端使用简单的进程内忙碌状态做兜底。
  • 如果同一对话仍在处理上一条消息,后端直接返回 CONVERSATION_BUSY
  • 本期不实现消息排队。

这样可以防止上一条查询还没有生成候选列表时,下一条“删除第二个”被提前处理。

12. 语音处理流程

改造后的语音流程:

开始录音
→ 建立语音流
→ 上传音频
→ ASR 生成最终文本
→ 写入 user.message
→ 加载最近 20 条上下文和结构化状态
→ 判断日程意图与目标
→ 执行日程查询,或生成需要用户确认的日程操作
→ 写入工具结果和 assistant.message
→ 返回助手结果

voice.stream.start 是客户端在开始上传语音前发送的一条 WebSocket 控制消息,不是数据库表,也不是直接发给模型的提示词。它用于告诉服务端“哪段对话现在要开始一条新的语音消息”。

字段含义:

字段 作用
request_id 匹配本次开始语音请求与服务端响应
conversation_id 指明这条语音属于哪段对话
client_message_id 防止断线重试时重复处理同一条语音消息
audio_format 音频数据格式
sample_rate_hz 音频采样率
channels 音频声道数
current_location 客户端可选提供的当前经纬度

消息示例:

{
  "type": "voice.stream.start",
  "request_id": "...",
  "conversation_id": "...",
  "client_message_id": "...",
  "payload": {
    "audio_format": "pcm_s16le",
    "sample_rate_hz": 16000,
    "channels": 1,
    "current_location": {
      "latitude": 22.543,
      "longitude": 114.057
    }
  }
}

current_location 可为空。只有语音中未明确城市且需要搜索地点时才使用。

活跃语音流按 connection_id 管理,避免旧连接的语音流影响重连后的新连接。

ASR 原始音频默认不持久化,只保存最终识别文本。

13. 日程工具能力

13.1 查询工具

schedule.list
schedule.search
schedule.get

查询可以直接执行,不需要用户确认。

所有日程工具都从 ConnectionContext 取得 user_id,所有仓储查询必须同时匹配 schedule_iduser_id

查询结果超过一条时,追加 schedule.candidates 记录,支持用户通过“第一个”“第二个”“项目会那个”等后续表达选择目标。

唯一目标确定后追加 schedule.focus.changed

13.2 创建日程

schedule.create

根据用户输入生成日程草稿。能力名称统一叫“创建日程”,不使用“提议”作为工具名称。

为了防止 ASR 识别错误,第一次调用只生成待确认草稿;用户确认后才真正写入 schedules

13.3 修改日程

schedule.update

先确定唯一日程目标,再生成修改后的字段差异。能力名称叫“修改日程”,但实际数据库更新仍然要等用户确认后执行。

如果当前正在确认一个尚未创建的日程草稿,用户说“改到四点”时,应修改当前草稿,而不是查询数据库中的已有日程。

13.4 删除日程

schedule.delete

先确定唯一日程目标,再生成删除确认内容。能力名称叫“删除日程”,但实际删除仍然要等用户确认后执行。

14. 写操作确认

考虑到 ASR 可能识别错误,本期所有写操作统一确认:

  • 新增需要确认。
  • 修改需要确认。
  • 删除需要确认。
  • 查询不需要确认。

待确认操作写入 pending_action.set

{
  "record_type": "pending_action.set",
  "operation_id": "...",
  "payload": {
    "type": "update",
    "schedule_id": "schedule_123",
    "patch": {
      "start_time": "2026-08-07T16:00"
    },
    "base_updated_at": "...",
    "expires_at": "..."
  }
}

一个对话同一时间只允许存在一个未清除的待确认操作。

用户可以通过按钮或语音回复“确认”“取消”。

确认时重新验证:

  • 操作属于当前对话。
  • 对话属于当前账号设备。
  • 日程属于当前账号。
  • 操作没有过期。
  • 目标日程在等待期间没有发生变化。
  • 相同 operation_id 没有完成过。
  • 日程字段仍然符合业务规则。

执行成功后依次追加:

operation.completed
pending_action.cleared
assistant.message

执行失败时追加 operation.failed,并根据失败类型决定是否保留待确认状态。

待确认操作保存在数据库中,因此后端重启后不会丢失。

15. 日程指代消解

日程指代优先依赖最近的结构化记录,不完全交给模型猜测。

解析顺序:

  1. 检查当前是否存在待确认草稿或待确认操作。
  2. 检查最近的 schedule.focus.changed
  3. 检查最近的 schedule.candidates
  4. 使用当前语句中的标题、日期、时间等条件缩小候选。
  5. 只有一个候选时确定目标。
  6. 多个候选时要求用户补充或选择。
  7. 没有候选时提示未找到。

示例:

用户:周五下午三点创建项目会
助手:已生成草稿,请确认
用户:改到四点

第二句优先修改当前待确认草稿。

用户:查询周五的会议
助手:找到三条会议
用户:把第二个删掉

“第二个”从最近的 schedule.candidates 中解析。

最终执行前必须重新使用 schedule_id + user_id 查询数据库,不能直接信任模型生成的日程 ID。

16. 语音地点解析

16.1 提取内容

从 ASR 文本中提取:

  • 地点原文。
  • 语音中是否明确包含城市。
  • 城市名称(如有)。
  • 用于搜索的地点关键词。

本期不设计置信度评分。

16.2 语音中包含城市

例如:

明天下午去北京国贸开会

处理流程:

在北京范围内搜索“国贸”
  • 搜索结果只有一个:直接写入日程草稿。
  • 搜索结果有多个:使用地图服务的默认排序,展示前三个。
  • 没有结果:按照地点解析失败处理。

16.3 语音中没有城市

例如:

明天下午去万象城

处理流程:

以用户当前位置为中心
→ 搜索 50 公里范围内符合关键词的地点
→ 按距离从近到远排序
  • 搜索结果只有一个:直接写入日程草稿。
  • 搜索结果有多个:展示距离最近的前三个。
  • 没有结果:按照地点解析失败处理。
  • 客户端无法提供当前位置:询问用户地点所在城市。

候选展示示例:

1. 万象城(福田区幸福路 88 号),距你 2.3 公里
2. 万象天地(南山区深南大道),距你 8.6 公里
3. 龙岗万象汇(龙岗区翔鸽路),距你 18.2 公里

16.4 多轮地点选择

多个地点候选写入 location.candidates

{
  "record_type": "location.candidates",
  "payload": {
    "location_text": "万象城",
    "candidates": [
      {
        "index": 1,
        "name": "万象城",
        "address": "福田区幸福路 88 号",
        "latitude": 22.543,
        "longitude": 114.057,
        "distance_meters": 2300
      }
    ]
  }
}

用户可以回复:

第一个
第二个
福田区那个
都不是

解析时读取最近一条未完成的 location.candidates,不重新搜索。

确定后追加 location.selected,并把地点写入当前日程草稿:

{
  "location_name": "万象城",
  "location_address": "福田区幸福路 88 号",
  "latitude": 22.543,
  "longitude": 114.057
}

候选地点确定之前,不允许确认最终日程草稿。

16.5 地点解析失败

如果地图服务请求失败或没有搜索结果:

  • 日程包含明确时间:仍允许创建时间日程,只保存用户说出的地点原文;location_addresslatitudelongitude 为空。
  • 用户只提供地点、没有时间,准备创建位置提醒:暂时不能创建,需要用户补充城市、重新选择地点或补充时间。

最终规则:

语音包含城市
→ 在指定城市搜索地点

语音不包含城市
→ 在当前位置 50 公里内搜索
→ 按距离升序

一个结果
→ 自动写入草稿

多个结果
→ 展示前三个
→ 通过多轮对话选择

零结果或地图服务失败
→ 有时间则保存地点原文并继续创建时间日程
→ 纯位置提醒则要求用户补充地点

17. schedules.user_id 的账号隔离方式

本期不重命名 schedules.user_id。这个字段表示“这条日程属于哪个账号”。需要修正的是它当前始终写入 default_user 的使用方式。

当前固定 default_user 的方式需要删除。修改后:

  • ScheduleService 每次处理请求时都接收当前已认证账号的 user_id,不再在服务对象中固定保存 default_user
  • user_id 来自 WebSocket ticket 验证后生成的 ConnectionContext
  • 客户端业务消息不能自行声明 user_id,否则客户端可以伪装成其他账号。
  • 创建日程时,将当前连接的 user_id 写入 schedules.user_id
  • 查询日程列表时,只查询当前 user_id 的日程。
  • 查询、修改和删除单条日程时,同时使用 schedule_id 和当前 user_id 查找。

例如,账号 A 要修改 schedule_123 时,数据库实际查询条件相当于:

WHERE id = 'schedule_123'
  AND user_id = '账号A'

如果 schedule_123 实际属于账号 B,账号 A 会得到“日程不存在”,不能读取或修改它。即使别人知道了某条日程的 ID,也不能跨账号操作。

所谓“每次调用显式接收 user_id”,就是将调用方式从类似:

schedule_service.list(query)

调整为:

schedule_service.list(connection_context.user_id, query)

这里的 connection_context.user_id 由服务端认证得到,不由客户端填写。

建议业务接口形态:

upsert(user_id, command)
list(user_id, query)
get(user_id, schedule_id)
delete(user_id, schedule_id)

这样可以完成账号数据隔离,同时避免本期进行字段重命名迁移。

18. 前端改造

前端需要完成:

  • 使用安全随机 UUID 替换时间戳加 Math.random() 的设备 ID。
  • 持久化保存 device_uuid
  • 登录后调用幂等设备绑定接口。
  • 每次 WebSocket 连接前自动申请 ticket。
  • ticket 过期或失效时自动重新申请。
  • 只在当前 App 进程内存中保存 conversation_id,不做长期持久化。
  • 同一 App 进程内 WebSocket 临时重连时继续当前对话。
  • App 冷启动时始终创建新对话,不加载上一段对话。
  • 从服务端加载对话记录,不再只依赖 React 内存状态。
  • 展示用户最终 ASR 文本。
  • 展示助手回复、日程草稿、日程候选和地点候选。
  • 支持按钮或语音确认、取消。
  • 支持“第一个”“第二个”“某某区那个”等选择表达。
  • 在语音流开始时按需附带当前经纬度。
  • 不加载其他设备的对话。

19. 后端模块建议

identity/
├── account_device_model
├── account_device_repository
├── account_device_service
├── websocket_ticket_store
└── connection_context

conversation/
├── conversation_record_model
├── conversation_record_repository
├── context_builder
├── reference_resolver
└── pending_action_service

assistant/
├── assistant_orchestrator
├── intent_parser
├── schedule_tools
└── location_search_service

ticket 存储接口:

WebSocketTicketStore
├── issue(...)
└── consume(...)

本期实现 InMemoryWebSocketTicketStore,未来多进程时替换为 RedisWebSocketTicketStore

地图服务调用放在后端统一处理,客户端只提供当前经纬度并展示候选结果。

20. 实施顺序

第一阶段:账号设备与安全连接

  • 新增 account_devices 表。
  • 修改客户端设备 UUID 生成与存储。
  • 实现幂等设备绑定接口。
  • 实现内存 ticket 存储。
  • 改造 WebSocket 握手。
  • 建立 ConnectionContext
  • 实现同一账号设备新连接替换旧连接。

第二阶段:账号日程隔离

  • 删除业务层固定的 default_user
  • 日程服务方法显式接收认证后的 user_id
  • 所有日程仓储操作增加账号过滤。
  • 修复当前 device_iduser_id 混用。

第三阶段:单表对话持久化

  • 新增 conversation_records 表。
  • 实现新进程创建新对话,以及同一 App 进程内 WebSocket 重连继续当前对话。
  • 使用自增记录 ID 确定消息顺序,并实现 client_message_id 幂等。
  • 实现按 conversation_id 读取当前对话记录。

第四阶段:多轮上下文

  • ASR 结果写入 user.message
  • 加载最近 20 条消息。
  • 使用正确角色调用模型。
  • 保存助手消息和工具结果。
  • 限制同一对话同时只处理一轮请求。

第五阶段:语音日程增删改查

  • 实现日程查询工具。
  • 实现创建、修改、删除日程能力及确认流程。
  • 实现待确认操作。
  • 支持按钮和语音确认、取消。
  • 增加重复操作保护。

第六阶段:指代与地点选择

  • 实现日程候选和当前焦点。
  • 实现“第二个”“刚才那个”等日程指代。
  • 实现城市内地点搜索。
  • 实现当前位置 50 公里地点搜索和距离排序。
  • 实现地点前三候选展示和多轮选择。
  • 实现地图失败后的时间日程降级。

第七阶段:验证与稳定性

  • 完成账号隔离测试。
  • 完成 ticket 过期和重复使用测试。
  • 完成 WebSocket 重连测试。
  • 完成同账号不同设备对话隔离测试。
  • 完成最近 20 条上下文测试。
  • 完成重复消息和重复确认测试。
  • 完成地点搜索、排序、选择和降级测试。
  • 完成同一 App 进程内 WebSocket 重连后继续当前对话和读取待确认状态的测试。
  • 完成 App 新进程启动后始终创建新对话的测试。

21. 验收标准

完成后应满足:

  • 只知道 device_uuid 无法冒充账号设备。
  • ticket 超过 TTL 后不能建立连接。
  • ticket 使用一次后不能再次使用。
  • 同一账号设备重连后旧连接失效。
  • 后端重启导致临时 ticket 失效时,客户端能够自动重新申请。
  • 客户端不需要判断是否首次登录。
  • 同一 App 进程内 WebSocket 临时断线重连后能够继续当前对话。
  • App 第一次打开或进程被清除后再次启动时始终创建新对话,不加载上一段对话。
  • 同账号不同设备看不到彼此的对话。
  • 不同账号不能读取或修改彼此日程。
  • 模型最多加载最近 20 条消息,并受 token 上限控制。
  • 重复发送同一 client_message_id 不会重复处理。
  • 语音可以查询、新增、修改和删除日程。
  • 所有写操作必须经过确认。
  • 重复确认不会重复执行操作。
  • “改到四点”“删除第二个”等表达能够结合上下文处理。
  • 多个日程候选时不会由模型自行猜测。
  • 语音中带城市时能够在指定城市搜索地点。
  • 语音中不带城市时能够在当前位置 50 公里内搜索并按距离排序。
  • 单一地点结果能够自动写入草稿。
  • 多个地点结果能够展示前三个并通过多轮对话选择。
  • 地图解析失败时,带明确时间的日程仍可以保存地点原文并创建。
  • 纯位置提醒在缺少有效地点坐标时不会被错误创建。

22. 后续扩展预留

本期完成后,未来可以在不推翻核心设计的情况下增加:

  • Redis ticket 存储。
  • 多后端实例。
  • 跨设备共享对话。
  • 对话汇总表和对话列表。
  • 账号级日程广播。
  • 长期上下文摘要。
  • 更完整的地点排序和个性化地点偏好。

这些能力不影响本期交付,也不应提前进入当前实现范围。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions