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**)。
已知:每个范读 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 上,不指挥喇叭。
| 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 当产品结论 |
listen_ratio × duration_ms(下限保持现网最少 800ms,除非配置显式改)。expected_text 来自与 basename 对应的 txt 契约(见 §4),供后评估使用;**不**拿 SD 路径当原文。metrics[](方法名 + 数值),不写「好/坏/及格」。评估可在本句时钟已经进入下一句之后才完成。listen_ratio 可配置;预留按用户水平取值的函数口(v0 用 task/JSON 常数即可)。Phase 3 已有 Instance 可挂结果。若不把「分析」从热路径拆开,后面每个 App 都会把喇叭卡在云端推理上。跟读是最便宜的钉钉。
热路径:
后评估:
expected_text + metrics(至少一项 text_similarity_v0 的 0–1 数值)。metrics 空,**不影响**已发生的播放。passed/grade)。纪律:
resolve_listen_ratio 口)01.txtDevice(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。
资源包约定(SD 与云端清单同一套 stem,Phase 6 下载两者;本阶段不新做 DOWNLOAD):
.../lessons/unit01/01.mp3
.../lessons/unit01/01.txt # UTF-8,范读原文
Server 解析顺序(热路径不读设备 SD):
task.transcripts["01.mp3"] 或 ["01"](manifest 已带正文,等于 txt 已同步到云)。.txt 则读文件(仅 Server 本地/已下载包,**禁止**为此加 MCP)。sentences[i] 与排序后 clips 下标对齐。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。
core/app_runtime/assessment.py 纯函数:
normalize(s): 小写、去标点空白;ASR 先 _plain_user_text
metrics = [
{ "method": "text_similarity_v0", "value": <0-1 SequenceMatcher ratio> }
]
unit 省略为 ratio)。passed / grade / level=good。method=... 新行,仍然只定量。评估抛错或缺少 asr/expected:返回 [],不抛到热路径。
与 is_echo_of_prompt(0.82)完全分开。听窗门闩保持。不要用 echo 阈值当任何产品含义。
| 现网 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:保持。
同一 step_index 一行。窗口内多次 ASR:v0 保留最后一次 文本与音频意图(更新同一 turn)。
| 字段 | 说明 |
|---|---|
prompt_text |
仍为 SD 路径 |
expected_text |
txt 正文,可空 |
asr_text |
可空(没说/没认出) |
metrics |
数组,可 [];元素 {method, value} |
assessed_at |
epoch ms,可空表示尚未评估 |
不要 passed / retry_count / grade。
可选 event:listen_window_closed(时钟开火)。不是新 WS。
新文件,例如 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) 继续。
不新增路径。PUT/GET 仍 /task/instance/{id},server secret。
后评估完成后再 PUT 一次全文(或只靠那次带 metrics 的 save)即可。不要为 metrics 单独做第二套鉴权。
| ID | 任务 |
|---|---|
| J1 | 新列 + DTO |
| J2 | upsert 合并空 metrics(见 §7.2) |
| J3 | 不要校验「必须有 score」;不要出现 passed 列 |
manager-web 零提交。
| 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/翻译不碰评估 |
见 phase-4-test.md。最低限度:
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。
await 评估/比对/HTTP。speak。passed/good/bad/grade。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
跟读句(Server):
play → (completed) → recording_window --timer--> next play | finish
└── ASR/report/assess 旁路
Instance:running / completed / cancelled(不加 graded)
| 风险 | 缓解 |
|---|---|
| 把草稿「说错重播」做回去 | 验收明确禁止;pytest 说错也 index++ |
| 空 PUT 冲掉晚到的 metrics | §7.2 合并 |
| 听窗过长体验差 | ratio 1.2–1.5;以后按水平调,不靠判题缩短 |
| 用户以为设备在判对错 | 不要 TTS 分数 |
Review → Architecture V5(跟读:时钟 + 后评估)
→ 4b:倒背/接龙/口算 = inline 评估 + 及时 speak;施工单 phase-4b-plan.md
禁止本阶段「顺便」口算出题、倒背及时反馈、Scheduler、租户、SD 搬家、type=task。