# Phase 3 Completion Review > Date: 2026-08-31 > Basis: **current code**(不是 `phase-3-plan.md` 的原文愿望) > Compared against: `phase-3-plan.md` · `phase-3-test.md` · `phase-2-review.md` · `roadmap.md` · `Xiaozhi Device Capability Specification v1.0.md` > Repositories: `xiaozhi-esp32-2.2.6`(Client,本阶段零 diff)· `xiaozhi-esp32-server`(Python + manager-api) 本文件只记录事实、差异、债务与对 Spec / Roadmap 的修正。下一阶段施工见 `phase-4-plan.md`,验收见 `phase-4-test.md`。 相关文档索引: | 文档 | 角色 | |------|------| | `Xiaozhi Device Capability Specification v1.0.md` | Device 语义冻结 | | `roadmap.md` | 阶段切分 | | `phase-2-review.md` | Current Architecture V3(文件 Session) | | `phase-3-plan.md` | 本阶段原文施工单(Instance 落库) | | `phase-3-test.md` | GET 对账 + 语音回归 + 失败隔离 | | `phase-2-voice-test.md` | 口令底表(本阶段不重烧) | --- ## 1. 当前代码盘点(以仓库为准) ### 1.1 Phase 3 实际改动的文件 #### Client(`xiaozhi-esp32-2.2.6`) **无业务提交。** 跟读/复读仍用 Phase 2 bin:`[skill_event]`、一句一 MP3、`ensure_app` mkdir。 #### Server Python(`xiaozhi-server`) | 文件 | 角色 | |------|------| | `core/app_runtime/session.py` | `agent_id`;`save()` 写盘后 `_schedule_upsert`(`create_task`,无 running loop 则跳过) | | `config/manage_api_client.py` | `upsert_task_instance`:单次 `_async_request` PUT;`report()` 可选 `taskInstanceId`/`stepIndex` | | `core/handle/reportHandle.py` | `enqueue_asr_report` 六元组透传 instance/step | | `core/connection.py` | `_process_report` 默认参数,转发到 manage_api | | `core/app_runtime/runtime.py` | 跟读有效轮 `enqueue_asr_report(..., task_session_id, step_index)` | | `tests/test_app_runtime.py` | `to_dict` 含 Phase 3 字段;upsert 调度抛错时 `save` 仍成功 | 跟读步进、听窗、`begin_session`/`add_turn` 语义 **未改**。upsert 只挂在已有 `save()` 上。 #### manager-api(Java) | 文件 | 角色 | |------|------| | `db/changelog/202608311800.sql` | 新表;已登记 `db.changelog-master.yaml`。**未改**已执行 changeset | | `modules/task/**` | Entity / Dao / DTO / `TaskInstanceService` / `TaskInstanceController` | | `security/config/ShiroConfig.java` | `/task/instance` 与 `/task/instance/**` → `server` | | `agent/dto/AgentChatHistoryReportDTO.java` | 可选 `taskInstanceId` / `stepIndex` | | `agent/.../AgentChatHistoryBizServiceImpl.java` | report 成功后 `attachTaskStepAudio` | #### manager-web / manager-mobile **零提交。** 查询靠 curl / Swagger / server secret。 ### 1.2 实际增加的模块 | 模块 | 位置 | 职责 | |------|------|------| | Task Instance 投影 | manager-api `xiaozhi.modules.task` | Session JSON → 三表;权威仍是 Server Runtime 内存 + 文件,库是查询副本 | | HTTP upsert | `PUT /xiaozhi/task/instance/{id}` | 全文覆盖 turns/events | | HTTP GET | `GET .../{id}`、`GET ...?macAddress=` | 详情含 turns;列表不含正文 | | audio_id 挂钩 | 计划 §4.3 **路径 A** | chat report 回写 `ai_task_step.audio_id` | 没有:Assessment、`type=task`、设备缓存 `instance_id`、新 WS/MQTT/MCP、租户列、manager-web 跟读页。 ### 1.3 实际增加的 API / 协议 | 类型 | 名称 | 说明 | |------|------|------| | REST PUT | `/task/instance/{id}` | server secret;snake_case 与 JSON 文件对齐 | | REST GET | `/task/instance/{id}` | **计划写 oauth2,实施为 server**(Shiro 按路径不能按方法拆) | | REST GET | `/task/instance?macAddress=&appId=&limit=` | 同上 | | REST | 现有 `POST /agent/chat-history/report` | 增可选字段,无新路径 | | WS / MQTT / MCP | **无** | 设备仍不知 instance_id | 响应包在现网 `Result` 里(`code` + `data`),不是裸 DTO。context-path:`/xiaozhi`。 ### 1.4 数据库 | 表 | 主键 | 内容 | |----|------|------| | `ai_task_instance` | `id` = `task_session_id`(32 hex) | mac / agent_id / app_id / task_id / status / end_reason / 时间戳 | | `ai_task_step` | BIGINT + UNIQUE `(instance_id, step_index)` | prompt / asr / echo / audio_id / recorded_at | | `ai_task_event` | BIGINT | name / app / task / at_ms;FK CASCADE | **没有** `score` / `expected_text` / `tenant_id` 列(计划允许预留 score 且恒 NULL;实施选择 **不建该列**,留给 Phase 4 新 changeset)。 覆盖 steps 时:新 `audio_id` 为空则保留旧行(避免与 report 竞态)。 ### 1.5 Client 架构变化 相对 V3:**无。** 设备不持有 instance_id,不读库。 ### 1.6 Server 架构变化 ```text TaskSession.save() ├── data/task_sessions/{id}.json (保留,失败只 log) └── create_task → PUT /task/instance/{id} 失败 print/log,不抛回热路径 无 ManageApiClient / 无 running loop → 跳过 跟读有效 ASR add_turn → save → PUT enqueue_asr_report(instance, step) → 现有 WAV BLOB → Java attach audio_id ``` 权威状态:**当前连接内存 + JSON 文件**;manager-api 是最终一致投影。manage_api 挂了,跟读仍按 Phase 2 跑。 ### 1.7 Client ↔ Server 实际通信流程 与 Phase 2 相同。新增的只是 Server→manager-api 的 HTTP,设备看不见。 ```text 开始跟读 begin_session → JSON + PUT running(turns=[]) 每句有效 ASR add_turn → save → PUT 全文 report(可选 audio_id 回写) audio.completed add_event → save → PUT;开听窗(逻辑不变) 结束 finish → save → PUT completed/cancelled GET /xiaozhi/task/instance/{id} 可查(需 server secret) ``` 复读:零 turn、若干 `audio.*` event,同样 upsert。Chat **不** `begin_session`。 ### 1.8 Task Runtime 当前实现状态 | 能力 | 状态 | |------|------| | TaskSession JSON | **有**(Phase 2) | | TaskInstance 可查询 | **有**(HTTP GET;双写) | | Step 行 = 跟读一轮 | **有**(投影,不是 Step 引擎) | | Event 入库 | **有** | | D8 audio 挂 step | **尽力**:路径 A 已接;取决于 report 配置与竞态 | | Assessment / score | **无** | | expected_text(可评分原文) | **无**;`prompt_text` 跟读时是 **SD 路径** | | Scheduler / 资源商店 / 租户 | **无** | 结论:这是 **可查询的薄 Runtime(JSON + 三表)**,仍不是 V1.1 的「Phase 2 = 评分」。评分已正确留到 Phase 4。 ### 1.9 测试情况 | 项 | 现状 | |----|------| | pytest `tests/test_app_runtime.py` | **有**(15 条量级:口令、session、skill_event、SD 路径、Phase 3 schema、upsert 失败隔离) | | Java 单测 / Maven 在本仓库对话中的编译记录 | **无** | | `phase-3-test.md` 语音 + GET 对账 | **有书面清单**;仓库内无跑测勾选记录 | | 固件测试 | 不适用(零 diff) | --- ## 2. Phase 3 Implementation Review(计划 vs 代码) ### 2.1 对照 `phase-3-plan.md` §1.3 验收 | # | 计划验收 | 代码事实 | 结论 | |---|---------|----------|------| | 1 | 跟读 GET steps = 有效句数,prompt/asr 与 JSON 一致 | upsert 全文覆盖;prompt 为 SD 路径 | **代码满足**(需按 test.md 人工 GET) | | 2 | 复读 `end_reason=play_completed` + `audio.completed` | 未改结束路径,只多 PUT | **代码满足** | | 3 | 退出/断线 cancelled + 已有 turns 不丢 | `finish` + save;断线 `end_session(disconnected)` | **代码满足** | | 4 | GET 按 mac 列出最近 ≥20 | `limit` 默认 20、上限 100 | **满足** | | 5 | JSON 双写;Chat 仍走 chat_history | chat 不 begin_session | **满足** | | 6 | manage_api 挂了语音仍跑 | upsert catch + 后台 task | **代码满足** | | 7 | 固件零提交 | 无 Client 业务 diff | **满足** | | 8 | 无新 WS/MQTT/MCP | 无 | **满足** | | 9 | 不评分、不改 repeat/SD、不下发 apps.json | 无 score 列、无评分调用 | **满足** | | 10 | 不改 hello/listen/tts/stt 与 Opus | 未改 | **满足** | 计划 §4 GET 走 oauth2:**未按原文做**。见 2.2。 计划 §4.3 选 A 或 B:实施了 **A**(report 回写 audio_id)。 ### 2.2 哪些设计被修改(相对 `phase-3-plan.md`) 1. **GET 鉴权 = server,不是 oauth2。** Shiro 按路径挂过滤器,不能按 HTTP 方法拆。与 chat report 同一密钥即可测通。家长/控制台 oauth2 留给 Phase 7,不要为本阶段再做第二套过滤器。 2. **未预留 `score` 列。** 计划允许可空且恒 NULL。实施选择不建,避免空列被当成「已经有评估」。Phase 4 用 **新 changeset** 加列。 3. **PUT 单次请求,不做 6×10s 重试。** 避免刷爆 manage_api;失败只 log。 4. **覆盖 steps 合并旧 `audio_id`。** 计划写先删后插;实施删插但空 audio 保留旧值,减少与 report 竞态。 这些应视为 **Phase 3 的真实架构(Current Architecture V4)**。 ### 2.3 哪些设计没有实现(原文有、代码无或未验证) - GET oauth2(有意改为 server) - 表上的空 `score` 列(有意推迟) - 仓库内 Java 编译 / 真实 HTTP 联调记录 - `phase-3-test.md` 人工勾选(语音 + GET) - manager-web 页面(计划本就 Out of Scope) ### 2.4 实施中发现的问题(对 Phase 4 有约束) 1. **`prompt_text` 在 SD 跟读下是文件路径**(如 `/english_learning/shadowing/lessons/unit01/01.mp3`),不是英语原文。Phase 4 **禁止**拿路径做文本比对。必须另有 `expected_text`。 2. **回声检测对路径是关掉的**(`_is_clip_path`)。不要把「评分用 expected_text」和「0.82 回声拒绝」绑成同一条:听窗内用户跟对了也会像范读。评分阈值必须独立,且只在已经过听窗门闩的有效轮上算。 3. **`uk_task_step_instance_index` 唯一。** 同一听窗多次 ASR 必须 **按 index 更新同一 turn**,不能追加。 4. 无 running asyncio loop 时 upsert 静默跳过(例如某些同步测试路径);热路径有 loop,正常。 5. 全量删插同一 id 不要并行 `save`(单连接单 session,现网如此)。 ### 2.5 与 Spec v1.0 的对照 | Spec ID | Phase 3 计划 | 代码 | 宣称 | |---------|--------------|------|------| | 中层 Task Runtime | Session → 表 | 有 | **可查询副本已落地**;权威仍在 Server 进程 | | D1 / D6 / D16 | 只入库,不改运输/步进 | 遵守 | 保持 | | D8 v1 | 尽量挂 audio_id | 路径 A | **关联管道有**;非每条必有 id | | D13 | 沿用 `/{app}/{task}/lessons` | 未搬目录 | 保持 | | D11 / D14 | 不做 | 无 | — | | S4 / S8 | 禁止 | 无评分、无 JUDGE | **正确** | | §22 | 无新传感器 | 固件零 diff | **满足** | ### 2.6 与原文 V1.1 / 租户讨论的差异 V1.1 把评分和表绑在同一 Phase。Spec 已拆开。本阶段 **没有** 评分,正确。 场景切换词、租户:**本阶段未做、也不该做。** 词表仍在 `runtime.py`;`tenant_id` 未进表、未进固件。见 §5 纪律。 --- ## 3. 与 Master Plan 核心抽象对照(更新) | 抽象 | 当前是否存在 | 实现位置 | 完成程度 | Phase 4 是否动 | |------|--------------|----------|----------|----------------| | **App** | 是 | `apps.json` | 连接级;无订阅表 | 可加 `assessment` 块;不升级商店 | | **Task** | 是(薄) | `apps.json` tasks | 一种动作 | 跟读加评估策略 JSON | | **Step** | 是(行) | `ai_task_step` / `turns[]` | 投影 | **写入 score / expected_text** | | **Skill** | 是 | MCP + 固件查找表 | 双表仍在 | 不合并;不新命令 | | **InteractionMode** | 是 | `SHADOWING` | = Spec FOLLOW_READ | 不重命名 | | **TaskInstance** | **是** | 三表 + JSON | 可 GET | 不改主键;不把分数改挂 chat_history | | **Event** | 是 | 表 + JSON | 无第四套总线 | 可加 `assessment.*` 事件名(仍走现有 events[]) | | **Audio** | 聊天附件 + 可选挂 step | report | 尽力 | 不新上传通道 | | **Assessment** | 否 | — | 0% | **后评估定量 metrics;不指挥步进** | | **Schedule / Tenant** | 否 | — | — | **禁止** | --- ## 4. Current Architecture V4(基于代码) ### 4.1 Client V4 与 V3 相同。固件冻结面不因落库而移动。 ### 4.2 Server V4 ```text xiaozhi-server ├── App Runtime │ apps.json │ SHADOWING:SD 句文件 + 听窗 + D16(无对错) │ TaskSession → JSON + 异步 PUT ├── detect:[skill_event] → [app_task] → [voice_entry] └── report:chat-history;可选回写 step.audio_id manager-api ├── PUT/GET /task/instance (server secret) ├── ai_task_instance / step / event └── 不计算分数、不执行 Skill ``` ### 4.3 通信 V4 ```text Device ──WS──► xiaozhi-server Runtime ├── JSON 文件 └── HTTP PUT ──► manager-api Instance └── HTTP report ──► chat_audio ──► step.audio_id 查询:server secret GET /xiaozhi/task/instance/{id} 设备从不调用上述 HTTP。 ``` ### 4.4 SD 布局 仍为 V3: ```text /{app_id}/{task_id}/lessons/{lesson_id}/file.mp3 ``` --- ## 5. 技术债务与架构纪律(Phase 3 之后) ### P0 — 不解决就无法安全做 Phase 4 | ID | 债务 | 放哪 | |----|------|------| | P0-1 | 跟读 `prompt_text` = SD 路径,不能当评分原文 | **Phase 4 用同 stem 的 `01.txt` / transcripts 作 `expected_text`;评估不挡播放** | ### P1 — 近期 | ID | 债务 | 放哪 | |----|------|------| | P1-1 | GET 仅 server secret | Phase 7 家长 UI 再拆 oauth2 | | P1-2 | 人工未勾选 `phase-3-test.md` | 开 Phase 4 编码前建议先对一次 GET;不挡写计划 | | P1-3 | 无 Java 编译记录 | 加列 changeset 时用现网风格,禁止改 `202608311800.sql` | | P1-4 | 词表硬编码在 `runtime.py` | 保持到 Phase 6 catalog;**禁止**把「开始跟读」写进固件 | | P1-5 | 双技能表 | 保持 | ### P2 — 以后再说 | ID | 债务 | |----|------| | P2-1 | D11 `wait` / D14 PAUSE | | P2-2 | 租户 / 机构教材隔离(最早缝在 Phase 6,产品化 Phase 7) | | P2-3 | 音素/发音/情绪分析 | | P2-4 | 倒背 / 口算 / 接龙(Phase 4b) | | P2-5 | `type=task`、对象存储、manager-web 跟读页 | **现在就遵守、不必编码:** - 固件不再增加业务 App 口令;系统级仅 Chat + 离线复读机 + 「切换模式」总入口 - 设备永不持久化 `tenant_id` - 打卡/分析只消费 Instance + audio,不加新 Device 能力 --- ## 6. 结论:Phase 3 是否 Done **功能 Done(带条件):** - `task_session_id` 可在 manager-api 按 id / mac 查询 - JSON 双写保留;PUT 失败不拖死语音(pytest 覆盖调度失败) - Agent ≠ App:Chat 不进 `ai_task_instance` - 固件零 diff;无新运输种类;无评分 - audio_id 挂钩(路径 A)已接 **工程未 Done:** - `phase-3-test.md` 依赖人工 GET / 语音勾选 - Java 侧无仓库内编译证明 - 跟读原文与路径未分离(P0-1,交给 Phase 4) **因此:** 可以宣称 Task Runtime **可归档且可查询**。 **不可以**宣称已评估、已打卡产品化、已多租户。 **不可以**为分数新增 `JUDGE` 命令或改 C++。 下一阶段只做一件事:把跟读热路径收成 **MP3 序列 + 定时录音**,分析全部后置为定量 metrics。见 `phase-4-plan.md`。