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

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 共用)

已知:每个范读 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. 复读结束仍只招呼一次。

后评估:

  1. 有 txt 原文且本句有 ASR 时:在「一定时间内」(本阶段允许会话结束后数秒内后台完成即可)GET 到该 step 的 expected_text + metrics(至少一项 text_similarity_v0 的 0–1 数值)。
  2. 无 ASR / 无 txt / 评估抛错:metrics 空,**不影响**已发生的播放。
  3. 任何 API/页面 不得把单一阈值渲染成必须的对错结论(本阶段无 UI;字段里不要 passed/grade)。

纪律:

  1. 固件零提交。无新 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. 架构

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):

.../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 示例:

"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 纯函数:

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。

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. 实施顺序

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

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

跟读句(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. 完成后

Review → Architecture V5(跟读:时钟 + 后评估)
      → 4b:倒背/接龙/口算 = inline 评估 + 及时 speak;施工单 phase-4b-plan.md

禁止本阶段「顺便」口算出题、倒背及时反馈、Scheduler、租户、SD 搬家、type=task。