# 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 仍能跑完**。 **冻结纪律:** 7. **固件零提交**(含 `app_task.cc` / MCP / MQTT)。Phase 2 已烧录的 bin 必须能对 Phase 3 Server 工作。 8. 不新增 Xiaozhi WS `type`、不新增 MQTT action、不新增 MCP 工具。 9. 不评分、不出题、不改 `repeat`、不改 SD 路径约定、不把 `apps.json` 下发到 SD。 10. 不改 `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. 架构(本阶段) ```text 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: ```json { "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`,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. 实施顺序 ```text 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 ```text 开始跟读 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 ```text DeviceState / InteractionMode:与 Phase 2 相同,禁止加边。 Instance: (无)──begin──► running ──finish(finished|play_completed)──► completed └──finish(user_exit|disconnected|play_stopped|preempted)──► cancelled ``` 设备执行器状态不是本阶段工作。 --- ## 13. 完成后(人类流程) ```text 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 跟读页。