编辑 | blame | 历史 | 原始文档

Phase 3 Coding Plan — TaskInstance 持久化

File: phase-3-plan.md
Date: 2026-08-31
状态:已实施。 对照事实见 phase-3-review.md。下一阶段 phase-4-plan.md。
约束文档:Xiaozhi Device Capability Specification v1.0.md
代码事实(开工时):phase-2-review.md(Current Architecture V3)
路线图:roadmap.md
验收:phase-3-test.md
与原文 V1.1「Phase 2 = type=task + 表 + 评分」冲突时,以 Spec + 本文为准:本阶段只有表和查询,没有新 Device 能力,没有评分。

本阶段 不是 再做一个 App,也 不是 做倒背/口算/接龙,也 不是 改固件。
本阶段把 Phase 2 已经写进 data/task_sessions/*.json 的字段,落到 manager-api,让一次跟读/复读可以按 id 查到。

给 Coding Agent:先读 Spec §0、§4–5、§17、§20;再读 phase-2-review.md §4;再读 session.py、runtime.py、manage_api_client.py、AgentChatHistoryController.java、ShiroConfig.java、现有 Liquibase 风格。


0. 与 Spec 的对照(本阶段只钉这些)

Spec ID 能力 Phase 3 做什么 不做什么
— Task Runtime 中层 Session → task_instance / task_step / task_event 不把 Instance 权威放到设备
D1 / D6 / D16 已落地 只入库 events / turns 不改完成事件运输、不改跟读步进
D8 v1 音频归档 尽量把现有 report 的 audio_id 写到 step 不做 v1.1 本地文件上传;不改 Opus
D13 SD 布局 沿用 Phase 2 /{app}/{task}/lessons 不改 mkdir MCP/MQTT;不做 resource_id
D11 / D14 wait / TASK_PAUSE 不做 —
S4 / S8 评估 / branch 禁止 表上可以留空 score 列,但本阶段不写值、不跑比对
§22 新 App 零新传感器 遵守 不新增 WS type、不为 Instance 加 Device 命令

运输可变、语义不变:Instance 走 HTTP(已有 manage_api 通道)。禁止为了 instance_id 新增 type=task。设备 不必 缓存 instance_id。


1. 目标与非目标

1.1 Objective

  1. 一次跟读或复读结束后,用 HTTP GET 能查到与 JSON 文件一致的:app/task、status、turns(prompt + asr)、events(含 audio.completed / listen_timeout)。
  2. 主键 = 现有 task_session_id(uuid),不要再用 WS session_id 当长期主键。
  3. Agent ≠ App:Task 行 不得 塞进 ai_agent_chat_history 冒充正式模型;chat report 管道保留。
  4. manager-api 挂了或上报失败:**不得**影响语音(与现有 chat report 相同:只打日志)。

1.2 Why Now

Phase 2 字段已稳定。没有 Instance,Phase 4 分数无处挂,Phase 5 调度无法对账。现在建表不会空。

1.3 Acceptance Criteria(必须全部满足)

能力:

  1. 完整跟读一次 → GET /task/instance/{id} 的 steps 条数 = 有效跟读句数(含最后一轮),prompt_text / asr_text 与 data/task_sessions/{id}.json 一致。
  2. 复读自然结束 → instance end_reason=play_completed,events 含 audio.completed。
  3. 退出跟读 / 断 WS → cancelled + 对应 end_reason(user_exit / disconnected),已有 turns 不丢。
  4. GET 按 macAddress 可列出最近 instance(最少 20 条)。
  5. 旧路径仍在:JSON 文件继续写(双写);chat 天气/笑话仍走 ai_agent_chat_history。
  6. 关闭或配错 manage_api:**跟读/复读/Chat 仍能跑完**。

冻结纪律:

  1. 固件零提交(含 app_task.cc / MCP / MQTT)。Phase 2 已烧录的 bin 必须能对 Phase 3 Server 工作。
  2. 不新增 Xiaozhi WS type、不新增 MQTT action、不新增 MCP 工具。
  3. 不评分、不出题、不改 repeat、不改 SD 路径约定、不把 apps.json 下发到 SD。
  4. 不改 hello/listen/tts/stt 与 Opus。

1.4 Out of Scope

  • Phase 4:文本比对、score、retry JSON
  • Phase 4b:倒背 / 口算 / 接龙
  • Phase 5:Server 日历
  • Phase 6:DOWNLOAD / resource_id / 搬家 SD
  • manager-web / 小程序 UI(本阶段 curl / 智控台 Swagger 即可)
  • 对象存储、Redis Session、微服务拆分
  • type=task / mode.set / 设备缓存 instance_id
  • wait Skill、TASK_PAUSE、indicate、长按退出

2. 架构(本阶段)

Device(Phase 2 bin,不动)
  [skill_event] / Opus listen / MCP play
        ↓
xiaozhi-server App Runtime
  TaskSession.save()  →  data/task_sessions/{id}.json     (保留)
                  ↘ HTTP upsert(server secret)
                    manager-api
                      ai_task_instance
                      ai_task_step
                      ai_task_event
                      (可选)step.audio_id → ai_agent_chat_audio.id

查询:oauth2 GET(家长/控制台以后用;本阶段用 token 或先 server 内网测)

task_session_id 在 begin_session 已经生成。Phase 3 不要再发一个 id 给设备。

跟读下一句仍然是 Spec D16 Server loop。表只是投影,不是新的 Step 引擎。


3. 表结构(最小)

Liquibase:manager-api/src/main/resources/db/changelog/202608311800.sql
必须登记 db.changelog-master.yaml。**禁止改已经执行过的 changeset**(含 202504151206.sql 的 URL 占位)。

命名与现网一致:utf8mb4、DATETIME(3)、InnoDB。
不要 DROP TABLE IF EXISTS 业务表。

3.1 ai_task_instance

列 类型 说明
id VARCHAR(32) PK = task_session_id
mac_address VARCHAR(50) 设备
agent_id VARCHAR(32) NULL 若连接能取到则填;没有就空
ws_session_id VARCHAR(64) Phase 2 ws_session_id
app_id VARCHAR(32)
task_id VARCHAR(32)
source VARCHAR(24) voice / mqtt_immediate / schedule / …
interaction_mode VARCHAR(32) 存代码名 SHADOWING 即可
status VARCHAR(16) running / completed / cancelled
end_reason VARCHAR(32) 与 JSON 相同
started_at BIGINT epoch ms
ended_at BIGINT NULL
created_at / updated_at DATETIME(3)

索引:(mac_address, started_at)、(app_id, task_id, started_at)、(status)。

3.2 ai_task_step

对应 turns[]。一条有效跟读一轮一行。

列 类型 说明
id BIGINT PK AI
instance_id VARCHAR(32) FK 逻辑关联即可(InnoDB FK 可选;建议有)
step_index INT turns[].index
prompt_text TEXT 跟读可能是 SD 路径
asr_text TEXT
echo_rejected TINYINT(1) 默认 0
audio_id VARCHAR(32) NULL 指向 ai_agent_chat_audio.id
recorded_at BIGINT

唯一:(instance_id, step_index)。

复读没有 turns 是合法的(只有 events)。

3.3 ai_task_event

对应 events[]。

列 类型 说明
id BIGINT PK AI
instance_id VARCHAR(32)
name VARCHAR(64) audio.completed / audio.stopped / listen_timeout / echo_rejected
app_id / task_id VARCHAR(32) 可空则继承 instance
at_ms BIGINT JSON at

禁止把 score、parser、difficulty 做成 NOT NULL。若预留 score 列,必须可空且本阶段恒为 NULL。


4. HTTP API

鉴权对齐现网:

路径 过滤器 调用方
PUT /task/instance/{id} server(ServerSecretFilter,与 chat report 相同) xiaozhi-server
GET /task/instance/{id} oauth2(控制台用户) 测试 / 以后家长
GET /task/instance oauth2 列表;query: macAddress 必填,可选 appId、limit

不要把 PUT 设成 anon。
不要新建第二套密钥。

4.1 Upsert 载荷

与 TaskSession.to_dict() 对齐,camelCase 或 snake 二选一,**Python / Java 必须同一套**。推荐 snake_case 与 JSON 文件一致,减少映射 bug:

{
  "task_session_id": "uuid",
  "ws_session_id": "",
  "device_id": "mac",
  "agent_id": "",
  "app_id": "english_learning",
  "task_id": "shadowing",
  "source": "voice",
  "interaction_mode": "SHADOWING",
  "started_at": 0,
  "ended_at": 0,
  "status": "running",
  "end_reason": "",
  "turns": [
    {
      "index": 0,
      "prompt_text": "/english_learning/shadowing/lessons/unit01/01.mp3",
      "asr_text": "...",
      "echo_rejected": false,
      "recorded_at": 0,
      "audio_id": null
    }
  ],
  "events": [
    { "name": "audio.completed", "app_id": "", "task_id": "", "at": 0 }
  ]
}

语义:按 id 整单覆盖 steps/events(先删后插或按 unique key upsert)。调用方每次 session.save() 都可 PUT 全文。不要做复杂 patch 协议。

幂等:同一 id 重复 PUT 成功,最终与最后一次 body 一致。

4.2 GET 响应

返回上表同一 JSON,外加 mac_address 若与 device_id 需要并存则只保留一个字段并在文档写死。

列表:{ "list": [ { id, app_id, task_id, status, end_reason, started_at } ], "total": n }
不要在列表里返回全部 turns。

4.3 audio_id(尽力而为)

现有 POST /agent/chat-history/report 返回 Result<Boolean>,Python 拿不到 id。

本阶段允许两条路径,**选工作量小的一条,不要两条都做**:

A(推荐)。 report DTO 增加可选 taskInstanceId + stepIndex;Java 写入 chat_audio 后更新对应 ai_task_step.audio_id。跟读 enqueue_asr_report 带上这两个可选字段。

B。 本阶段 step.audio_id 恒空;GET 仍能查文本。在 PR 说明留给 Phase 4。验收 #1 不强制音频。

禁止为了挂音频再上传一份 WAV。


5. Server 改动清单

5.1 manager-api(Java)

ID 任务 说明
J1 Liquibase SQL + master yaml 只增新文件
J2 Entity / Dao / Service 模块建议 xiaozhi.modules.task,不要塞进 agent chat 包当「一种聊天」
J3 TaskInstanceController PUT upsert、GET one、GET list
J4 ShiroConfig PUT /task/instance/** → server;GET 走默认 oauth2
J5 校验 app_id/task_id 长度与字符与固件约定一致;非法 400;缺 id 400
J6 可选 A report DTO 扩展 + 回写 audio_id

manager-web:**零提交**(除非 Swagger 自动生成,不要手改 Vue)。

5.2 xiaozhi-server(Python)

ID 任务 文件 说明
S1 HTTP 客户端 config/manage_api_client.py upsert_task_instance(payload);无 client 或异常 → log,return None
S2 投影 core/app_runtime/session.py TaskSession.save() 成功写盘后 异步/同步均可 调用 upsert;失败不影响 save
S3 连接字段 若 conn.agent_id 已有则写入 payload 没有就空
S4 可选 A enqueue_asr_report / report() 附带 instance/step
S5 pytest tests/test_app_runtime.py to_dict 字段;upsert 被调用;upsert 抛错时 save 仍成功(mock)

不要在 runtime.py 再复制一套 schema。upsert 只从 TaskSession.to_dict() 出发。

跟读/复读/skill_event 的 begin/end/add_turn 不要改行为,只借现有 save() 挂钩。


6. Client 改动清单

空。 禁止改 xiaozhi-esp32-2.2.6。

旧固件无 duration_ms、无 ensure_app 时:Server 仍按 Phase 2 兼容(时长缺省、建目录失败忽略)。Phase 3 不得依赖新固件字段。


7. 测试

详见 phase-3-test.md。本 PR 最低限度:

  1. pytest:schema 投影 + upsert 失败隔离。
  2. 手工:跟读整段 → GET instance 对得上 JSON;Chat 笑话仍在 chat_history。
  3. 回归:phase-2-voice-test.md 第 0、2、5 节(跟读 + 复读结束事件)不得回退。

不要本阶段验收倒背/口算/接龙。


8. 实施顺序

P3-A  Liquibase + Entity + PUT upsert + GET
      用 curl + server secret 写入一条手工 JSON,能读回

P3-B  Python manage_api_client + TaskSession.save() 挂钩
      跟读一次,库里出现行;关掉 manager-api 再跟读,设备不停

P3-C  (可选)report 回写 audio_id
      做不完就留空列,不要卡住 A/B

P3-D  pytest + phase-3-test.md 勾选

未完成 A 不要改 Python 热路径。未完成 B 不得宣称 Phase 3 Done。未完成 D 不得开 Phase 4 评分。


9. 编码约束(Spec §16–17 落到本 PR)

  1. 固件零 diff。
  2. 不得把 ASR/LLM/评分/口算/词库写入 Client 或 Java「设备逻辑」。
  3. 不得新增 WS type / MQTT / MCP。
  4. 不得把 Task 行写入 ai_agent_chat_history 当权威。
  5. 不得修改已执行 Liquibase 的 checksum 内容。
  6. Chat 热路径与 Mode 分流逻辑原则上不改;只在 save() / report 旁路加上报。
  7. 每个 PR 先 Chat 回归,再查 GET。

10. 风险与回归

风险 缓解
每次 save 全量删插 steps 并发丢行 单连接单 session;不要并行 save 同一 id
manage_api 超时拖死语音 upsert 必须 catch;必要时 create_task 后台发,但要保证 end 时最终一致(可在 end_session 再 PUT 一次)
与 chat_history 双写让产品以为「聊天记录就是跟读」 API 路径分开;文档写清 Agent ≠ App
把 Phase 3 做成评分 验收表不含 score;列可空
为 instance_id 改 detect 文本 禁止
新 App 又去改 C++ 建目录 已有 ensure_app;只改 apps.json

11. Data Flow

开始跟读
  begin_session → 写 JSON + PUT running(turns=[])

每句有效 ASR
  add_turn → save → PUT 全文
  enqueue_asr_report(可选带 instance/step)

audio.completed(跟读)
  add_event → save → PUT;开听窗(逻辑不变)

结束
  finish(reason) → save → PUT completed/cancelled
  GET /task/instance/{id} 可查

复读:可能零 turn、若干 audio.* event。同样 upsert。


12. State Machine

DeviceState / InteractionMode:与 Phase 2 相同,禁止加边。

Instance:
  (无)──begin──► running ──finish(finished|play_completed)──► completed
                      └──finish(user_exit|disconnected|play_stopped|preempted)──► cancelled

设备执行器状态不是本阶段工作。


13. 完成后(人类流程)

Coding
  → 勾选 phase-3-test.md
  → Completion Review(对照本文 §1.3)  ✅ phase-3-review.md
  → Architecture V4(表 + HTTP)
  → Roadmap Review
  → phase-4-plan.md(时钟步进 + 后评估;无 JUDGE、无当场再播)

禁止本阶段「顺便」评分、倒背、Scheduler、SD 搬家、type=task、manager-web 跟读页。