# Phase 4 Coding Plan — 时钟步进 + 后评估(定量) > File: `phase-4-plan.md` > Date: 2026-08-31 > 约束文档:`Xiaozhi Device Capability Specification v1.0.md` > 代码事实:`phase-3-review.md`(Current Architecture V4) > 路线图:`roadmap.md` > 验收:`phase-4-test.md` > **覆盖**此前「S8 当场判对错再播同一句」的草案。热路径是 **MP3 序列 + 定时录音**;分析全部后置、定量、允许失败。 本阶段 **不是** 再做一个 App,也 **不是** 做倒背/口算/接龙(那是 Phase 4b,且那些是 **流内及时评估**,不能套本阶段后评估),也 **不是** 改固件。 给 Coding Agent:先读 Spec §1.3 S4、§15、§17、§22;再读 `phase-3-review.md` §2.4(`prompt_text` 是路径);再读 `runtime.py` 听窗 / `_schedule_shadowing_listen_timeout` / `handle_shadowing_turn`、`session.py`、`TaskTurnDTO.java`、`202608311800.sql`(**禁止改已执行 changeset**)。 --- ## 0. 产品模型(本阶段钉死,后面很多 App 共用) ```text 已知:每个范读 01.mp3 有对应 01.txt(同 stem,资源包契约) 播放时长 duration_ms 来自已有 D1 完成事件(设备已报) 热路径(必须流畅,任何分析失败都不得打断): play 01.mp3 audio.completed 立即开录音/听窗 = listen_ratio × duration_ms (v0:1.2~1.5,默 1.2) 定时结束 → 上传尽力而为 → play 02.mp3 … 直到序列结束 → 回 Chat 禁止: 等 ASR 成功才播下一句 等上传成功才播下一句 用文本比对决定再播同一句或下一句 在设备上读出「正确/错误」「超过 82% 算好」 ``` 跟读、故事复述、打卡录音:**后评估**——给定听窗,时间到就下一步;家长稍后看 metrics。 倒背数字、成语接龙、默认口算:**不是本阶段模型**。听窗结束后必须马上评估并 `speak` 反馈,属于 Phase 4b 的 `assessment.timing=inline`(见 `roadmap.md`「评估时机」)。不要在本 PR 里把它们做成后评估。 Spec **S8 `branch(if correct)` 不是本产品默认热路径。** 留给以后可选的「精练模式」,本阶段不做。S4 评估仍在 Server,但是 **异步/可延迟**,写在 Instance 上,不指挥喇叭。 --- ## 1. 与 Spec 的对照 | Spec ID | 能力 | Phase 4 做什么 | 不做什么 | |---------|------|----------------|----------| | S4 | Assessment | 后评估:对已归档 ASR/音频相对 `expected_text` 算 **定量指标** | 及格线、好坏结论、临床诊断 | | S8 | branch if correct | **不做** | 再播同一句、JUDGE、retry | | D1 | duration_ms | 用完成事件时长算听窗 | 不改固件报时 | | D16 | 步骤级 loop | Server 仍按 clip 序列下发下一句 play;**触发器只有时钟** | 设备自己播整课;评估驱动步进 | | D6/D7/D8 | 听/归档 | 听窗内尽力 ASR + report;失败忽略 | 不改 Opus | | D11 wait | | 仍用 Server 定时,不等待 Skill | | | D13 | 资源 | `01.mp3` ↔ `01.txt` 为已知契约;热路径不 MCP 读卡上 txt | 不搬目录 | | 表 | | 可空 `expected_text` + `metrics` JSON | 不建 `passed` 当产品结论 | --- ## 2. 目标与非目标 ### 2.1 Objective 1. 跟读 = **预定 MP3 序列**。句间只等 `listen_ratio × duration_ms`(下限保持现网最少 800ms,除非配置显式改)。 2. **有效 ASR 不再提前切下一句**(相对 Phase 2 的行为变更:说完也要等到时钟;时钟到了没说完也切)。 3. 录音/ASR/PUT/比对 **全部旁路**:失败只 log,序列照走。 4. `expected_text` 来自与 basename 对应的 txt 契约(见 §4),供后评估使用;**不**拿 SD 路径当原文。 5. 后评估写入 **定量** `metrics[]`(方法名 + 数值),不写「好/坏/及格」。评估可在本句时钟已经进入下一句之后才完成。 6. `listen_ratio` 可配置;预留按用户水平取值的函数口(v0 用 task/JSON 常数即可)。 7. 复读 / Chat / 翻译不评估、不改时钟语义。 ### 2.2 Why Now Phase 3 已有 Instance 可挂结果。若不把「分析」从热路径拆开,后面每个 App 都会把喇叭卡在云端推理上。跟读是最便宜的钉钉。 ### 2.3 Acceptance Criteria **热路径:** 1. 说错、不说、ASR 失败、关掉 manage_api:听窗结束后 **仍播下一句**,不重播同一句、不卡死。 2. 跟对也不提前切句(等到时钟)。允许 ±200ms 量级定时误差。 3. 全程不讲笑话;退出跟读仍立刻回 Chat。 4. 复读结束仍只招呼一次。 **后评估:** 5. 有 txt 原文且本句有 ASR 时:在「一定时间内」(本阶段允许会话结束后数秒内后台完成即可)GET 到该 step 的 `expected_text` + `metrics`(至少一项 `text_similarity_v0` 的 0–1 数值)。 6. 无 ASR / 无 txt / 评估抛错:`metrics` 空,**不影响**已发生的播放。 7. 任何 API/页面 **不得**把单一阈值渲染成必须的对错结论(本阶段无 UI;字段里不要 `passed`/`grade`)。 **纪律:** 8. 固件零提交。无新 WS/MQTT/MCP。无 JUDGE。不改 Opus。不加租户。业务口令不进 C++。 ### 2.4 Out of Scope - 当场纠错再播、max_retry、及格线产品化 - 音素/流利度/情绪/智商结论/心理诊断(以后也是新 method 的定量值,不是本阶段) - 4b 三个 Task 的题库 - 完整用户等级引擎(只留 `resolve_listen_ratio` 口) - 热路径 MCP 读设备上的 `01.txt` - manager-web 打卡页、租户 - 设备默认语言 / 翻译语言对(Roadmap Phase 6 / 6b / 7) --- ## 3. 架构 ```text Device(Phase 2 bin) play clip i → [skill_event]completed|duration_ms → 听流 ↑ ↓ xiaozhi-server 时钟 旁路(失败忽略) sleep(listen_ratio * duration_ms) ASR 尽力 play clip i+1 (不等旁路) report 音频尽力 后台 assess → metrics PUT Instance 尽力 ``` **步进权威是时钟,不是评估器。** 评估可以在 xiaozhi-server 用 `create_task` 做(本阶段允许),也可以以后改成 manager-api Worker;**不得** `await assess()` 再 play。 --- ## 4. expected_text = 与 MP3 同名的 txt(已知条件) 资源包约定(SD 与云端清单同一套 stem,Phase 6 下载两者;本阶段不新做 DOWNLOAD): ```text .../lessons/unit01/01.mp3 .../lessons/unit01/01.txt # UTF-8,范读原文 ``` Server **解析顺序**(热路径不读设备 SD): 1. `task.transcripts["01.mp3"]` 或 `["01"]`(manifest 已带正文,等于 txt 已同步到云)。 2. 进程内音频缓存目录若存在同 stem 的 `.txt` 则读文件(仅 Server 本地/已下载包,**禁止**为此加 MCP)。 3. 兼容现网:`sentences[i]` 与排序后 clips 下标对齐。 4. 都没有 → `expected_text=null`,本句不评估,播放不变。 禁止对 `prompt_text`(路径)做相似度。 `apps.json` 跟读 task 示例: ```json "listen_ratio": 1.2, "assessment": { "enabled": true, "methods": ["text_similarity_v0"], "defer": true } ``` 不要 `threshold`、`max_retry`、`passed`。 `listen_ratio` 允许 1.2–1.5;超出则夹紧。以后 `resolve_listen_ratio(conn, task)` 可按用户水平返回不同值;v0 读 JSON。 --- ## 5. 定量指标(禁止做成结论) `core/app_runtime/assessment.py` 纯函数: ```text normalize(s): 小写、去标点空白;ASR 先 _plain_user_text metrics = [ { "method": "text_similarity_v0", "value": <0-1 SequenceMatcher ratio> } ] ``` - **只产出 method + value**(可选 `unit` 省略为 ratio)。 - **禁止**函数返回 `passed` / `grade` / `level=good`。 - 家长端以后若要展示「建议再练」,那是 **另一个产品层** 用同一数字做解释,不是本阶段字段。 - 益智/心理:以后加 `method=...` 新行,仍然只定量。 评估抛错或缺少 asr/expected:返回 `[]`,不抛到热路径。 与 `is_echo_of_prompt`(0.82)完全分开。听窗门闩保持。不要用 echo 阈值当任何产品含义。 --- ## 6. 热路径相对 Phase 2/3 的行为变更 | 现网 Phase 2/3 | Phase 4 | |----------------|---------| | 听窗内有效 ASR → **立刻**下一句 | ASR 只归档;**时钟到了**才下一句 | | 超时才下一句 | 超时 = 时钟正常开火(主路径) | | 无后评估 | 时钟后/会话后写 metrics | 实施:`handle_shadowing_turn` **不要**再调用 `_play_shadowing_clip` / `index++`。只 `upsert_turn` + `enqueue_asr_report`。 下一句只放在听窗 timeout 回调里(已有 `_schedule_shadowing_listen_timeout`)。退出口令仍立即回 Chat。 播放中 ignore、未到听窗 ignore:保持。 --- ## 7. Turn / 表 同一 `step_index` 一行。窗口内多次 ASR:v0 **保留最后一次** 文本与音频意图(更新同一 turn)。 ### 7.1 JSON / PUT(snake_case) | 字段 | 说明 | |------|------| | `prompt_text` | 仍为 SD 路径 | | `expected_text` | txt 正文,可空 | | `asr_text` | 可空(没说/没认出) | | `metrics` | 数组,可 `[]`;元素 `{method, value}` | | `assessed_at` | epoch ms,可空表示尚未评估 | **不要** `passed` / `retry_count` / `grade`。 可选 event:`listen_window_closed`(时钟开火)。不是新 WS。 ### 7.2 Liquibase 新文件,例如 `db/changelog/202609011100.sql`,登记 master。**禁止改** `202608311800.sql`。 ```text ai_task_step expected_text TEXT NULL metrics JSON NULL assessed_at BIGINT NULL ``` MySQL JSON 类型与现网其它表一致即可;读回为 list。 缺 metrics 的旧 PUT 必须 200。 覆盖插入:若新 payload `metrics` 为空且旧行已有,**保留旧 metrics**(后评估晚于一次空 PUT 时不被冲掉)。显式非空数组以新为准。 Java DTO `@JsonIgnoreProperties(ignoreUnknown=true)` 继续。 --- ## 8. HTTP 不新增路径。PUT/GET 仍 `/task/instance/{id}`,server secret。 后评估完成后再 PUT 一次全文(或只靠那次带 metrics 的 save)即可。不要为 metrics 单独做第二套鉴权。 --- ## 9. 改动清单 ### 9.1 manager-api | ID | 任务 | |----|------| | J1 | 新列 + DTO | | J2 | upsert 合并空 metrics(见 §7.2) | | J3 | 不要校验「必须有 score」;不要出现 passed 列 | manager-web 零提交。 ### 9.2 xiaozhi-server | ID | 任务 | |----|------| | S1 | `listen_ratio` 夹紧 1.2–1.5;timeout = max(800, ratio * duration_ms) | | S2 | ASR 有效 → 只 upsert_turn + report,**不** play next | | S3 | 仅 timeout 回调:index++ 并 play next / 结束 | | S4 | `resolve_expected_text`(§4) | | S5 | `assessment.py` 只返回 metrics 列表;`create_task` 里跑,**await 不挡 play** | | S6 | pytest | | S7 | 复读/Chat/翻译不碰评估 | --- ## 10. 测试 见 `phase-4-test.md`。最低限度: 1. pytest:时钟推进与 ASR 解耦;metrics 无 passed;路径不能当 expected。 2. 语音:说错仍进下一句;说对也不秒切(等到窗)。 3. manage_api 停掉:序列仍走完。 4. Phase 3 GET 字段仍在;多了 expected_text/metrics。 --- ## 11. 实施顺序 ```text P4-A Liquibase + Java 新列(无 passed) P4-B 时钟与 ASR 解耦 + listen_ratio + pytest P4-C 后台 metrics(txt 契约)+ 空 metrics 不冲旧值 P4-D phase-4-test.md ``` 宣称 Done = B+D(解耦是产品核心)。C 做不完则 metrics 全空,但 **不得**回退到「比对失败就重播」。未完成 D 不得开 4b。 --- ## 12. 编码约束 1. 固件零 diff。 2. 热路径不得 `await` 评估/比对/HTTP。 3. 不得把对错、及格、retry 写入设备或 `speak`。 4. 不得新增 WS/MQTT/MCP。 5. 不得改已执行 Liquibase。 6. 不得对 SD 路径做 SequenceMatcher。 7. 指标字段禁止 `passed`/`good`/`bad`/`grade`。 8. 先回归复读/Chat,再测「闭嘴等到下一句」。 --- ## 13. Data Flow ```text completed(duration_ms) 开窗 T = clamp(ratio)*duration_ms 窗内 ASR? → upsert asr + 异步 report + 异步 assess T 到 → 下一 clip(无论上面成功与否) assess(可晚于下一 clip) expected_text from 01.txt 契约 metrics=[{method, value}] → save/PUT ``` --- ## 14. State Machine ```text 跟读句(Server): play → (completed) → recording_window --timer--> next play | finish └── ASR/report/assess 旁路 Instance:running / completed / cancelled(不加 graded) ``` --- ## 15. 风险 | 风险 | 缓解 | |------|------| | 把草稿「说错重播」做回去 | 验收明确禁止;pytest 说错也 index++ | | 空 PUT 冲掉晚到的 metrics | §7.2 合并 | | 听窗过长体验差 | ratio 1.2–1.5;以后按水平调,不靠判题缩短 | | 用户以为设备在判对错 | 不要 TTS 分数 | --- ## 16. 完成后 ```text Review → Architecture V5(跟读:时钟 + 后评估) → 4b:倒背/接龙/口算 = inline 评估 + 及时 speak;施工单 phase-4b-plan.md ``` 禁止本阶段「顺便」口算出题、倒背及时反馈、Scheduler、租户、SD 搬家、`type=task`。