diff --git a/docs/recording-library-diarization-development-plan.md b/docs/recording-library-diarization-development-plan.md new file mode 100644 index 0000000..dd4f6db --- /dev/null +++ b/docs/recording-library-diarization-development-plan.md @@ -0,0 +1,1460 @@ +# 录音资料库、说话人分离与反馈素材编排开发计划 + +- 状态:待评审 +- 编制日期:2026-07-23 +- 适用仓库:`teaching-feedback-assistant` +- 目标部署:Ryzen 7 5700U、CPU FunASR、远程 PostgreSQL、Docker Compose +- 计划性质:产品、交互、数据、API、转录、迁移、测试和发布的统一实施基线 + +## 1. 执行摘要 + +本次改造不应继续把录音能力作为“反馈生成”页面中的一个折叠区域扩展。目标能力已经包含录音和文件上传、长期管理、音频播放、异步转录、说话人分离、角色映射、逐段选择、跨录音选择、AI 总结、总结预览和写入反馈输入框,实际边界是一个独立的“录音资料库”子系统。 + +最终产品职责划分如下: + +```text +现场录音 / 上传音频 + | + v + 录音资料库 + | + v + 转录 + 说话人分离 + | + v + 对话校对、角色映射、片段选择 + / \ + v v +直接加入反馈输入框 多录音或片段交给 AI + | + v + 总结预览 + | + v + 写入反馈输入框 +``` + +本计划采用以下总体策略: + +1. 录音从 `feedback_session` 的附属数据升级为用户拥有的独立资产。 +2. 反馈页面只负责编辑反馈、选择素材、预览总结和保存结果。 +3. 录音列表、录音详情和反馈素材选择使用独立页面,不以大弹窗承载主流程。 +4. 说话人分离只产生匿名说话人簇,教师、学生、家长等业务角色由用户确认。 +5. AI 总结使用选中对话的不可变快照,避免转录重跑或角色修改改变历史结果。 +6. 数据库按 expand、backfill、switch、contract 渐进迁移,旧链路在切换完成前始终可回退。 +7. FunASR 说话人能力先通过 5700U CPU 技术预研门槛,再决定 CAM++ 或 ERes2NetV2,不直接在生产中试错。 +8. 音频默认保留到用户主动删除;自动保留期限作为后续可配置能力,不在首版擅自清理历史录音。 + +## 2. 当前状态与约束 + +### 2.1 当前业务链路 + +当前链路为: + +```text +反馈草稿 + -> 创建 feedback_session + -> 创建 lesson_session + -> 小程序每 8 分钟录制一个音频片段 + -> 上传 audio_segment + -> Rust worker 调用 FunASR + -> audio_segment 写入纯文本 transcript + -> 用户按 lesson_session 整节选择 + -> summary worker 生成总结 + -> 用户确认后替换反馈输入框 + -> 保存反馈并将 feedback_session 标记 finalized +``` + +现有能力中可以复用的部分: + +- 微信小程序录音权限、8 分钟自动切片和失败上传本地保留。 +- Rust API 的用户认证、owner 隔离和请求 ID。 +- 音频持久化目录和 Docker 命名卷。 +- PostgreSQL 异步转录队列、租约、重试和失败状态。 +- FunASR Paraformer、FSMN-VAD 和标点模型服务。 +- 总结任务的幂等键、后台 worker、轮询、预览和应用流程。 +- Grafana、Loki 和结构化日志规范。 + +### 2.2 当前结构性限制 + +1. `lesson_sessions.feedback_session_id` 非空,录音不能脱离反馈草稿独立存在。 +2. 录音查询、重试和删除接口只允许访问 `active` 反馈批次。 +3. 保存反馈后批次变为 `finalized`,当前小程序没有独立入口重新管理其录音。 +4. `audio_segments.transcript` 只有纯文本,没有开始时间、结束时间、说话人或置信信息。 +5. 当前总结来源表只关联整节 `lesson_session`,不支持逐段选择。 +6. Rust API 成功转录后保留原始音频,但没有认证播放接口和统一保留策略。 +7. 数据库级联删除和重复序号上传可能留下磁盘孤儿文件。 +8. 当前本地 ASR 响应只有 `text`、`duration_ms` 和 `request_id`。 +9. 腾讯备用实现明确关闭 `speaker_diarization`,不能承担当前目标。 +10. `recording_routes.rs` 已承载过多职责,继续叠加录音库接口会提高回归风险。 + +### 2.3 固定部署约束 + +- API 和 FunASR 使用 `compose.deploy.yml` 部署。 +- FunASR 不暴露宿主机端口,只允许 API 通过 Compose 网络访问。 +- PostgreSQL 使用现有远程数据库,不新增数据库容器。 +- 首发继续使用 CPU 推理,不假设 CUDA、ROCm 或独立 GPU。 +- 默认单 FunASR 进程和单转录 worker,模型对象不在多线程中并发调用。 +- 模型缓存和音频必须继续使用持久化卷。 +- 不得把音频、模型、数据库凭据、微信密钥或 `.env` 提交到 Git。 +- 日志不得包含转录正文、总结正文、学生姓名、原始文件名或真实磁盘路径。 + +### 2.4 已知但尚未获得的生产基线 + +编码前应从部署服务器补齐以下数据;缺少数据不阻止建表和接口开发,但阻止容量结论和自动清理上线: + +- PostgreSQL 版本、现有表行数、数据库总大小和每日增长量。 +- `teaching-feedback-audio` 卷当前文件数、容量和每日增长量。 +- 录音平均时长、P95 时长、平均文件大小和最大文件大小。 +- 单日录音数量、并发教师数量和转录队列 P95 等待时间。 +- 5700U 上当前模型的 P50/P95 RTF、峰值内存和失败率。 +- 用户期望的录音保留期限和可接受的删除恢复窗口。 + +## 3. 产品目标与非目标 + +### 3.1 首个完整版本目标 + +用户能够: + +1. 在录音资料库中查看现场录音和上传录音。 +2. 按学生、日期、来源和处理状态筛选录音。 +3. 查看录音时长、文件状态、转录状态、说话人数和失败原因。 +4. 播放本人录音,并从对话时间点跳转播放。 +5. 上传支持格式的音频文件并在后台完成转录。 +6. 查看按时间排序、按说话人分组标识的对话文本。 +7. 将匿名说话人映射为教师、学生、家长或自定义显示名。 +8. 选择整份录音、某位说话人的全部对话或若干对话片段。 +9. 将选择内容直接追加到反馈输入框,受 2000 字限制保护。 +10. 选择一份或多份录音的部分或全部对话生成 AI 总结。 +11. 预览总结,确认后替换反馈输入框。 +12. 重试失败转录、重新转录、修改录音标题和删除录音。 +13. 在退出页面、应用切后台或请求重试后恢复可恢复的任务状态。 + +### 3.2 首版明确不做 + +- 不做实时字幕和实时说话人分离。 +- 不自动断言“Speaker 0 就是教师”。 +- 不做人脸、声纹实名识别或跨用户声纹库。 +- 不在不同录音之间自动确认同一个自然人。 +- 不做多人协作编辑、公开分享或外链下载。 +- 不做音频裁剪、拼接、降噪参数编辑等专业音频编辑器。 +- 不做波形级标注工作台;首版使用时间轴和播放进度定位。 +- 不删除现有 `lesson_sessions`、`audio_segments` 或旧总结来源表。 +- 不在没有代表性样本验证的情况下升级 FunASR 或切换识别模型。 +- 不在首版启用自动到期删除,除非用户另行确认保留规则。 + +### 3.3 后续可选能力 + +- 用户手工修订转录文本并保留修订历史。 +- 针对固定教师的声纹登记和跨录音角色建议。 +- 自动提取课堂主题、作业、错误点和后续建议。 +- 录音标签、收藏、批量归档和导出。 +- 对象存储替代本地卷,以及分层存储和归档。 +- Web 管理端或桌面端长文本校对工作台。 + +## 4. 信息架构与页面计划 + +### 4.1 页面边界 + +计划新增以下页面: + +| 页面 | 路径建议 | 职责 | +|---|---|---| +| 录音资料列表 | `pages/recordings/index` | 管理、筛选、批量选择、录音和上传入口 | +| 录音详情 | `pages/recordings/detail` | 播放、说话人映射、对话浏览、选择、重试和删除 | +| 反馈素材选择 | `pages/recordings/select` | 从反馈页进入,选择录音或对话片段并返回反馈草稿 | + +弹窗只用于: + +- 删除录音确认。 +- 重新转录确认。 +- 总结覆盖输入框确认。 +- 简短标题修改。 +- 说话人显示名的单字段输入。 + +不得使用弹窗承载完整录音列表、长对话浏览或批量选择。 + +### 4.2 底部导航决策 + +产品目标上,“录音资料”是一级能力。实现前进行一次 5 项底部导航真机评审: + +```text +反馈生成 | 录音资料 | 学生档案 | 反馈记录 | 我的 +``` + +通过条件: + +- 320px 宽度真机上文字和图标不截断。 +- 各入口点击区域满足现有小程序交互尺寸。 +- 当前 tab 状态不会因从选择页返回而错乱。 + +若 5 项导航不通过,首版不强行增加 tab,改为在反馈页和“我的”页面提供录音资料入口;页面和后端边界保持不变,后续再调整导航。 + +### 4.3 录音资料列表页 + +页面结构: + +```text +标题与状态统计 +筛选栏:学生 / 日期 / 来源 / 状态 +录音列表 + - 标题和日期 + - 学生或未关联 + - 现场录音或上传文件 + - 时长、说话人数、字数 + - 转录状态和失败操作 +底部主操作:录音或上传 +``` + +交互要求: + +- 内容列表是主区域,工具栏保持紧凑,不使用大面积装饰卡片。 +- 使用稳定高度的列表行,状态文字变化不能引起明显布局跳动。 +- 支持下拉刷新和游标分页,不一次加载全部历史录音。 +- 处理中项目按更新时间刷新;就绪项目不进入高频轮询。 +- 批量模式下显示已选数量和一个明确的主操作。 +- 失败项目提供“重试”和“删除”,不使用含糊的通用“处理”按钮。 +- 删除入口必须显示是否会影响已生成反馈;历史总结只保留快照,不依赖原录音继续存在。 + +### 4.4 录音详情工作页 + +页面结构: + +```text +顶部导航:返回 / 标题 / 更多 +紧凑播放器:播放、暂停、进度、当前时间、总时长 +说话人映射栏:Speaker 0 -> 教师,Speaker 1 -> 学生 +对话工具栏:全选、按说话人筛选、仅看已选 +对话主区域: + [复选框] [00:12-00:18] [教师] 对话文本 + [复选框] [00:19-00:27] [学生] 对话文本 +固定底栏:已选 N 段 / 加入反馈或生成总结 +``` + +交互要求: + +- 对话内容占据主要视觉面积,播放器和筛选工具保持安静、紧凑。 +- 点击时间范围后从对应位置播放。 +- 播放进度变化只更新当前行状态,不重新渲染完整对话列表。 +- 选择状态由 `turn_id` 管理,不能依赖数组位置。 +- 支持按说话人全选和取消,结果仍展开为具体 `turn_id` 提交。 +- 角色映射修改不改写历史总结快照。 +- 转录失败时不渲染空工作台,直接显示失败原因、重试和删除。 +- 说话人数量异常时允许保留“未知”角色,不强制映射为教师或学生。 + +### 4.5 反馈素材选择页 + +从反馈页打开,必须携带目标 `feedback_session_id`。该页复用录音列表和对话选择组件,但使用独立路由,以避免 tab 页面承担临时返回状态。 + +支持两种选择方式: + +1. 整份录音:服务端解析为该录音当前就绪转录版本的全部对话。 +2. 部分对话:提交明确的 `turn_id` 集合。 + +返回反馈页前显示: + +- 录音数量。 +- 对话片段数量。 +- 字符数估算。 +- 选择范围中是否包含“未知说话人”。 + +选择草稿以 `feedback_session_id` 为键暂存在小程序本地存储,页面返回或小程序短时退出后可恢复;提交总结时服务端重新校验所有权、状态和转录版本。 + +### 4.6 反馈生成页调整 + +现有展开式“语音记录”区域改为紧凑的素材区: + +- `开始录音`。 +- `从录音资料选择`。 +- `已选 2 个录音 / 18 段 / 1260 字`。 +- `查看选择`。 +- `生成总结`。 +- 总结状态和预览。 + +现有按整节加入和总结能力在兼容期保留;新能力开关启用后,页面优先走录音资料选择链路。确认稳定后再删除旧前端入口,后端旧接口继续保留一个发布周期。 + +### 4.7 页面状态矩阵 + +| 状态 | 列表页 | 详情页 | 可用操作 | +|---|---|---|---| +| 上传中 | 进度或等待 | 不进入对话区 | 取消 | +| 排队中 | 排队状态 | 音频可播放 | 删除 | +| 转录中 | 处理中 | 音频可播放 | 删除 | +| 待确认 | 显示说话人数 | 对话可查看和映射 | 映射、选择、完成确认 | +| 已就绪 | 正常状态 | 全部能力 | 选择、总结、重转录、删除 | +| 失败 | 错误分类 | 错误详情 | 重试、删除 | +| 删除中 | 从正常列表隐藏 | 禁止进入 | 后台重试清理 | + +## 5. 目标领域模型 + +### 5.1 设计原则 + +- 录音资产由用户拥有,反馈会话只引用素材,不拥有素材。 +- 原始音频、转录运行、说话人和对话片段职责分离。 +- 每次重新转录创建新版本,不原地覆盖已用于总结的版本。 +- 总结来源在任务创建时展开并快照,worker 不读取会变化的最新文本。 +- 用户删除录音后,历史总结仍可解释其来源数量和快照,但不能再播放音频。 +- 所有查询都显式包含 `owner_id` 边界,不依赖前端传入的用户标识。 +- 删除必须同时覆盖数据库引用和物理文件,不再依赖数据库级联完成文件清理。 + +### 5.2 关系图 + +```text +users + | + +-- recordings + | + +-- recording_audio_parts + | + +-- transcription_runs + | + +-- recording_speakers + | + +-- transcript_turns + +feedback_sessions + | + +-- feedback_summary_runs + | + +-- feedback_summary_run_sources + -> nullable transcript_turn reference + -> immutable text/speaker/time snapshot +``` + +### 5.3 `recordings` + +职责:一份逻辑录音资产,不等同于某个 8 分钟物理片段。 + +建议字段: + +| 字段 | 约束与用途 | +|---|---| +| `id UUID` | 主键 | +| `owner_id UUID` | 非空,引用 `users ON DELETE RESTRICT`;账号删除前必须完成受控录音清理 | +| `profile_id UUID` | 可空,引用学生档案;学生删除时建议 `SET NULL`,不连带删除录音 | +| `title VARCHAR(120)` | 非空,服务端生成默认标题,允许用户修改 | +| `source VARCHAR(16)` | `live`、`upload`、`legacy` | +| `recorded_at TIMESTAMPTZ` | 课堂或文件代表的录制时间 | +| `ingest_status VARCHAR(16)` | `uploading`、`complete`、`failed` | +| `duration_ms BIGINT` | 非负,所有 part 汇总 | +| `byte_size BIGINT` | 非负,所有 part 汇总 | +| `audio_format VARCHAR(8)` | 逻辑主格式,混合 part 时使用规范化输出格式 | +| `current_transcription_run_id UUID` | 可空,指向当前展示版本,扩展迁移后补 FK | +| `legacy_lesson_id UUID` | 可空且唯一,仅用于迁移追踪,后续可保留 | +| `delete_requested_at TIMESTAMPTZ` | 可空,存在时从正常列表隐藏 | +| `created_at/updated_at` | 非空 | + +关键约束与索引: + +- `CHECK (char_length(title) BETWEEN 1 AND 120)`。 +- `CHECK (source IN ('live', 'upload', 'legacy'))`。 +- `CHECK (ingest_status IN ('uploading', 'complete', 'failed'))`。 +- `CHECK (duration_ms >= 0 AND byte_size >= 0)`。 +- 列表索引:`(owner_id, created_at DESC, id DESC) WHERE delete_requested_at IS NULL`。 +- 学生筛选索引:`(owner_id, profile_id, created_at DESC, id DESC) WHERE delete_requested_at IS NULL`。 +- `legacy_lesson_id` 唯一索引仅覆盖非空值。 +- `current_transcription_run_id` 不能只使用单列 FK;应以 + `(id, current_transcription_run_id)` 复合引用 + `transcription_runs(recording_id, id)`,保证当前 run 一定属于当前 recording。 + +### 5.4 `recording_audio_parts` + +职责:保存现场录音切片或上传文件经过规范化后的物理音频单元。 + +建议字段: + +| 字段 | 约束与用途 | +|---|---| +| `id UUID` | 主键 | +| `recording_id UUID` | 非空,引用 `recordings ON DELETE RESTRICT` | +| `sequence INTEGER` | 从 1 开始,录音内唯一 | +| `duration_ms INTEGER` | 大于 0 | +| `byte_size BIGINT` | 大于 0 | +| `audio_format VARCHAR(8)` | `mp3`、`aac`、`wav`、`m4a`、`amr` | +| `storage_key TEXT` | 非空,只存内部相对键,不向客户端返回 | +| `sha256 CHAR(64)` | 非空,用于上传幂等和文件核验 | +| `created_at` | 非空 | + +关键约束与索引: + +- `UNIQUE (recording_id, sequence)`。 +- `UNIQUE (storage_key)`。 +- `CHECK (sequence > 0 AND duration_ms > 0 AND byte_size > 0)`。 +- 为 `recording_id` 建普通索引,支持删除和顺序读取。 + +### 5.5 `transcription_runs` + +职责:一次可审计、可重试、不可原地覆盖的转录版本。 + +建议字段: + +| 字段 | 约束与用途 | +|---|---| +| `id UUID` | 主键 | +| `recording_id UUID` | 非空,引用 `recordings` | +| `revision INTEGER` | 从 1 开始,录音内唯一 | +| `status VARCHAR(16)` | `queued`、`processing`、`ready`、`failed`、`superseded` | +| `provider VARCHAR(32)` | 例如 `local-funasr` | +| `asr_model TEXT` | 实际模型标识 | +| `speaker_model TEXT` | 可空,实际说话人模型标识 | +| `config_version VARCHAR(32)` | 转录配置版本 | +| `source_hash CHAR(64)` | 输入 part 顺序、hash 和配置的摘要 | +| `attempts INTEGER` | 非负 | +| `processing_started_at` | 租约和恢复 | +| `completed_at` | 就绪或失败完成时间 | +| `error_class VARCHAR(64)` | 稳定错误分类 | +| `error_message TEXT` | 面向用户的脱敏错误信息 | +| `created_at/updated_at` | 非空 | + +关键约束与索引: + +- `UNIQUE (recording_id, revision)`。 +- `UNIQUE (recording_id, id)`,供 recording 当前 run 的复合 FK 使用。 +- `UNIQUE (recording_id, source_hash)`,相同输入和配置重复请求返回既有 run。 +- 队列部分索引:`(created_at, id) WHERE status IN ('queued', 'processing')`。 +- 状态结果约束保证 `ready` 时 `completed_at` 非空,`failed` 时 `error_class` 非空。 + +### 5.6 `recording_speakers` + +职责:保存某次转录运行产生的匿名说话人簇及用户业务映射。 + +建议字段: + +| 字段 | 约束与用途 | +|---|---| +| `id UUID` | 主键 | +| `transcription_run_id UUID` | 非空,引用 `transcription_runs` | +| `provider_label VARCHAR(32)` | 例如 `SPK0`,run 内唯一 | +| `role VARCHAR(16)` | `teacher`、`student`、`guardian`、`unknown`、`other` | +| `display_name VARCHAR(60)` | 可空,例如“学生甲” | +| `created_at/updated_at` | 非空 | + +约束: + +- `UNIQUE (transcription_run_id, provider_label)`。 +- `UNIQUE (transcription_run_id, id)`,供 turn 的复合 FK 使用。 +- 映射只改变业务显示,不修改模型原始 `provider_label`。 +- 首次生成时全部为 `unknown`,不得自动写入 `teacher`。 + +### 5.7 `transcript_turns` + +职责:一条连续说话人对话,是播放定位和选择的最小单位。 + +建议字段: + +| 字段 | 约束与用途 | +|---|---| +| `id UUID` | 主键 | +| `transcription_run_id UUID` | 非空,引用 `transcription_runs` | +| `speaker_id UUID` | 非空,引用 `recording_speakers` | +| `sequence INTEGER` | 从 1 开始,run 内唯一 | +| `start_ms BIGINT` | 非负 | +| `end_ms BIGINT` | 大于 `start_ms` | +| `text TEXT` | 非空且去除首尾空白后有内容 | +| `created_at` | 非空 | + +约束与索引: + +- `UNIQUE (transcription_run_id, sequence)`。 +- `(transcription_run_id, speaker_id)` 复合引用 + `recording_speakers(transcription_run_id, id)`,禁止 turn 引用其他 run 的 speaker。 +- `CHECK (start_ms >= 0 AND end_ms > start_ms)`。 +- `CHECK (char_length(btrim(text)) > 0)`。 +- `(transcription_run_id, start_ms, id)` 支持时间排序。 +- `(speaker_id, start_ms, id)` 支持按说话人筛选。 + +首版不允许直接编辑 `text`。若后续增加校对,新增修订表或明确的 `edited_text` 和审计字段,不在本次建表中预留含糊字段。 + +### 5.8 `feedback_summary_run_sources` + +职责:冻结 AI 实际接收的来源,保证重跑、删除和转录升级后仍可审计。 + +建议字段: + +| 字段 | 约束与用途 | +|---|---| +| `summary_run_id UUID` | 引用 `feedback_summary_runs` | +| `source_order INTEGER` | 从 1 开始,run 内唯一 | +| `recording_id UUID` | 可空,删除录音后 `SET NULL` | +| `transcript_turn_id UUID` | 可空,删除转录后 `SET NULL` | +| `recording_title_snapshot TEXT` | 非空 | +| `speaker_label_snapshot TEXT` | 非空 | +| `start_ms_snapshot BIGINT` | 非负 | +| `end_ms_snapshot BIGINT` | 大于开始时间 | +| `text_snapshot TEXT` | 非空,是 worker 唯一读取的正文 | +| `created_at` | 非空 | + +主键建议为 `(summary_run_id, source_order)`。创建总结任务时在同一事务中: + +1. 校验所有录音属于当前 owner。 +2. 校验所有 turn 属于对应录音的当前就绪 run。 +3. 将整份录音选择展开为 turn。 +4. 按录音时间和 turn 时间排序。 +5. 写入快照。 +6. 计算 source hash。 +7. 创建或复用幂等总结任务。 + +worker 只读取 `text_snapshot`,不得再次 join 最新 `transcript_turns.text`。 + +## 6. 录音和转录状态机 + +### 6.1 录音采集状态 + +```text +uploading + | complete upload/finalize + v +complete + +uploading --validation/storage error--> failed +failed --retry upload--> uploading +``` + +规则: + +- `complete` 后 part 集合默认不可变;补传必须在 finalize 前完成。 +- finalize 是幂等操作,重复调用返回同一结果。 +- live 录音停止后只有全部 part 成功上传才允许 finalize。 +- 上传文件校验失败不得创建可见的半成品录音;失败记录只用于恢复时才保留。 + +### 6.2 转录运行状态 + +```text +queued -> processing -> ready + | | + | +-> failed + +------------> failed + +ready --create new revision--> new queued run +old ready --new run promoted--> superseded +``` + +规则: + +- worker 使用 `FOR UPDATE SKIP LOCKED` 和租约领取任务。 +- 进程异常后超过租约的 `processing` 可重新领取。 +- 只有完整写入 speakers 和 turns 后才能把 run 标为 `ready` 并切换 `current_transcription_run_id`。 +- speakers、turns、run ready 和 current pointer 在一个数据库事务中提交。 +- 新 run 失败时旧 ready run 仍保持当前展示版本。 +- 重试同一个 failed run 可增加 attempts;用户主动“重新转录”创建新 revision。 + +### 6.3 删除状态 + +删除采用“先隐藏、后物理清理”: + +1. DELETE 接口校验 owner 并写入 `delete_requested_at`。 +2. 列表和详情立即隐藏该录音。 +3. cleanup worker 删除所有物理文件。 +4. 删除 speakers、turns、runs、parts 和 recording 行。 +5. summary source 的快照保留,外键设空。 +6. 文件删除失败时保留待清理记录并指数退避重试。 + +不得先删数据库行再尝试删除文件,否则会再次制造不可追踪孤儿文件。 + +## 7. 音频存储与播放 + +### 7.1 存储键 + +新文件使用: + +```text +/data/audio/{owner_uuid}/{recording_uuid}/{part_uuid}.{format} +``` + +要求: + +- 路径不包含学生姓名、上传原始文件名或业务标题。 +- 数据库只保存相对 `storage_key`;运行时在受控根目录下解析。 +- 写入临时文件后执行原子 rename,数据库只引用完成文件。 +- 校验解析后路径仍位于 `AUDIO_STORAGE_DIR`,防止目录穿越。 +- 上传原始文件名只在确有 UI 需求时保存为脱敏元数据,默认不保存。 + +### 7.2 上传与格式规范化 + +现有 15 MB 内存读取方式不适合长文件上传。新接口必须流式写磁盘: + +- 请求体按块写临时文件,不能一次读入 `Vec`。 +- 同步计算 SHA-256 和字节数。 +- 使用文件头和解码结果验证格式,不能只信扩展名和 MIME。 +- 通过 ffmpeg/ffprobe 获取真实时长、采样率和声道数。 +- 超限立即停止写入并清理临时文件。 +- 首版支持 `mp3`、`aac`、`wav`、`m4a` 和 `amr`,以实际 ffmpeg 镜像能力为准。 + +初始限制建议: + +- 单文件最大 200 MiB。 +- 单份录音最大 4 小时。 +- 单用户同时上传 1 个文件。 +- 限制值通过环境变量配置,并在 5700U 压测后确认。 + +如果微信小程序文件选择或上传通道的真机限制低于服务端限制,前端显示实际可选范围;不得用健康检查代替真机上传验证。 + +### 7.3 认证播放 + +新增 `GET /api/v1/recordings/{recording_id}/audio`: + +- 必须认证并校验 owner。 +- 支持 HTTP Range、`206 Partial Content`、`Content-Range` 和正确 MIME。 +- 不返回磁盘路径和永久公开 URL。 +- 不在日志中记录 Range 内容、标题、文件名或路径。 +- 对已请求删除或文件校验失败的录音返回稳定错误。 +- 播放请求不触发转录或改变录音状态。 + +### 7.4 保留、容量和孤儿治理 + +首版默认保留到用户主动删除。上线前完成: + +- 音频卷每日容量统计。 +- 每用户录音数、总时长和总字节统计。 +- 数据库 part 与磁盘文件双向巡检。 +- 孤儿文件仅报告,不在首轮自动删除。 +- 确认备份是否同时覆盖 PostgreSQL 和音频卷,以及二者恢复时间点如何对应。 + +自动保留策略后续通过配置和用户确认实现,不能仅靠 cron 删除文件。 + +## 8. FunASR 说话人分离技术计划 + +### 8.1 官方能力与本项目风险 + +FunASR 官方仓库当前说明 Paraformer 可组合 `spk_model`,模型列表包含 CAM++;官方教程还给出了 ERes2NetV2 说话人模型和带 speaker 的 ASR 示例。官方当前接口可返回句子级时间和说话人信息。 + +本项目不能直接据此上线,原因是: + +- 仓库固定 `funasr==1.3.14`,官方 `main` 文档可能对应更高版本。 +- 当前服务只读取结果中的 `text`,没有验证 `sentence_info` 契约。 +- 当前 8 分钟片段分别转录,匿名 speaker 编号可能跨片段互换。 +- 教室噪声、重叠说话、远场麦克风和多学生会显著影响 diarization。 +- 增加 speaker 模型会改变 CPU、内存、模型缓存和推理耗时。 + +### 8.2 技术预研候选 + +候选 A:当前 Paraformer + FSMN-VAD + CT-PUNC + CAM++。 + +- 优点:官方快速示例直接支持,CAM++ 参数量较小,适合作为 CPU 首选候选。 +- 风险:短句、重叠语音和跨片段聚类质量必须实测。 + +候选 B:当前 Paraformer + FSMN-VAD + CT-PUNC + ERes2NetV2。 + +- 优点:官方教程将其作为改进的短时说话人特征候选。 +- 风险:CPU 成本、FunASR 固定版本兼容性和模型缓存增加必须实测。 + +候选 C:独立 diarization pipeline。 + +- 只有 A、B 未达到质量门槛时评估。 +- 不在首轮同时引入第二套大型框架,避免镜像、许可和运维复杂度失控。 + +### 8.3 预研实施步骤 + +1. 建立不提交真实敏感音频的本地测试清单和标注格式。 +2. 在独立分支为 `asr-service` 增加实验开关,不修改生产默认值。 +3. 先测试固定 `funasr==1.3.14` 是否支持所需 `spk_model` 和输出字段。 +4. 若固定版本不支持,再验证最小兼容升级,并记录依赖 diff 和模型版本。 +5. 对相同音频分别运行无 speaker、CAM++、ERes2NetV2。 +6. 保存脱敏指标,不保存或提交真实转录正文。 +7. 在 5700U 单进程 CPU 环境测量 RTF、峰值内存、缓存大小和稳定性。 +8. 选择通过门槛的候选;均不通过时停止正式开发并重新评估方案。 + +### 8.4 测试语料矩阵 + +至少包含经授权的以下场景: + +| 场景 | 最低样本 | +|---|---:| +| 两人、安静、轮流说话 | 3 | +| 两人、真实教室噪声 | 3 | +| 三人及以上 | 2 | +| 明显重叠说话 | 2 | +| 跨 8 分钟切片同一说话人 | 2 | +| 上传的 mp3/m4a/wav | 每种至少 1 | +| 30 分钟以上长录音 | 2 | + +需要人工标注说话人区间和参考文本,才能计算说话人错误率和识别退化。 + +### 8.5 预研通过门槛 + +以下门槛为首轮工程门槛,样本不足时不得宣称达标: + +- 所有支持格式无崩溃、无内存持续增长、无空结构化结果。 +- 两人安静样本的中位 DER 不高于 15%。 +- 两人真实教室样本的中位 DER 不高于 25%。 +- 跨 8 分钟边界的同人 cluster 一致率不低于 90%。 +- 加入 speaker 模型后,中文转录错误率相对当前基线恶化不超过 5 个百分点。 +- 5700U 上 P95 RTF 不高于 0.5,即 1 小时音频在 30 分钟内处理完成。 +- 单 FunASR 进程峰值内存不超过 12 GiB,且连续 20 次任务后不持续增长。 +- 失败必须返回稳定错误分类,不能导致 API 或 worker 重启循环。 + +如质量达标但性能未达标,应保持异步处理并评估独立 speaker worker;不得简单提高同一模型实例并发。 + +### 8.6 跨切片说话人一致性 + +首选方案是把一份逻辑录音的 part 按顺序规范化并作为一次完整 diarization 输入,使 speaker cluster 在录音级生成。不得把每个 8 分钟 part 的 `SPK0` 直接视为同一人。 + +若长音频一次处理超出内存或模型限制,再实现: + +1. 每个 part 提取 speaker embedding。 +2. 在 recording 级对相邻 part 的 cluster 做相似度匹配。 +3. 设置信心阈值,低信心时生成新的匿名 speaker。 +4. 保存原始 provider label 和合并后的 recording speaker,不覆盖原始输出。 + +该备选方案必须单独评审,不能在首版临时拼接字符串完成。 + +### 8.7 ASR 服务响应契约 + +新响应建议: + +```json +{ + "text": "完整转录文本", + "duration_ms": 480000, + "request_id": "uuid", + "model": "paraformer-zh", + "speaker_model": "cam++", + "speakers": [ + { "key": "SPK0" }, + { "key": "SPK1" } + ], + "turns": [ + { + "sequence": 1, + "start_ms": 1200, + "end_ms": 6800, + "speaker_key": "SPK0", + "text": "今天先检查上节课的内容。" + } + ] +} +``` + +契约要求: + +- `turns` 按时间排序。 +- 时间统一使用整数毫秒。 +- `speaker_key` 必须能在 `speakers` 中找到。 +- 相邻且同 speaker、间隔很短的句子可按明确规则合并,但必须保持时间有序。 +- 无法分离时返回一个 `SPK0`,而不是伪造多说话人。 +- `text` 是 turns 文本的兼容聚合,不是新的独立事实来源。 +- Rust 必须校验时间范围、顺序、空文本和 speaker 引用,再写数据库。 + +## 9. API 设计 + +### 9.1 录音列表 + +`GET /api/v1/recordings` + +查询参数: + +- `cursor`:基于 `(created_at, id)` 的不透明游标。 +- `limit`:默认 20,最大 100。 +- `profile_id`:可选。 +- `source`:`live` 或 `upload`。 +- `status`:API 派生状态。 +- `date_from/date_to`:可选。 + +响应只包含列表需要的字段,不返回 turns 和 storage key。 + +### 9.2 创建和上传 + +`POST /api/v1/recordings` + +- 创建逻辑录音,参数包含 `source`、可选 `profile_id`、标题和 `recorded_at`。 +- 支持 `Idempotency-Key`,防止页面重试创建重复录音。 + +`POST /api/v1/recordings/{id}/parts` + +- multipart 流式上传。 +- 参数包含 `sequence`、`duration_ms`、`audio_format` 和 `audio`。 +- `(recording_id, sequence)` 唯一;相同 hash 重试返回原 part,不同 hash 冲突返回 409。 + +`POST /api/v1/recordings/{id}/complete` + +- 校验 part 连续、文件存在、时长和大小一致。 +- 原子地把 ingest 标为 complete 并创建首个 transcription run。 +- 重复调用幂等。 + +上传单文件在客户端表现为一个 recording;服务端可按解码和 ASR 限制生成内部 part,但不把内部切分暴露为多份用户录音。 + +### 9.3 详情和播放 + +- `GET /api/v1/recordings/{id}`:录音元数据、当前 run、speakers 和分页/分块 turns。 +- `GET /api/v1/recordings/{id}/turns?cursor=&speaker_id=`:长对话分页。 +- `GET /api/v1/recordings/{id}/audio`:认证 Range 播放。 +- `PATCH /api/v1/recordings/{id}`:修改标题、学生关联和录制日期。 +- `DELETE /api/v1/recordings/{id}`:进入删除队列。 + +### 9.4 说话人映射和重转录 + +- `PATCH /api/v1/recordings/{recording_id}/speakers/{speaker_id}`:更新 role 和 display name。 +- `POST /api/v1/recordings/{id}/transcription-runs`:创建新 revision。 +- `POST /api/v1/transcription-runs/{id}/retry`:重试 failed run。 + +角色更新使用乐观并发字段或 `updated_at` 前置条件,避免两个页面互相覆盖。 + +### 9.5 直接加入反馈 + +`POST /api/v1/feedback-sessions/{session_id}/recording-sources/apply` + +建议请求: + +```json +{ + "mode": "append", + "selections": [ + { "recording_id": "uuid", "selection": "all" }, + { + "recording_id": "uuid", + "selection": "turns", + "turn_ids": ["uuid", "uuid"] + } + ] +} +``` + +服务端负责: + +- 校验 session active、owner、run ready 和 turn 归属。 +- 按时间顺序格式化带角色的文本。 +- 检查合并后不超过 2000 字。 +- 在一个事务中更新反馈草稿和记录来源应用状态。 +- 返回更新后的 `FeedbackSession`。 + +### 9.6 创建总结任务 + +扩展现有 summary run 请求,使用同一 `selections` 结构。服务端在创建 run 的事务中展开并写入 `feedback_summary_run_sources`。 + +兼容策略: + +- 新请求写 `feedback_summary_run_sources`。 +- 旧 `lesson_ids` 请求继续写 `feedback_summary_run_lessons`。 +- summary worker 优先读取新 sources;没有新 sources 时走旧 lesson 查询。 +- 新旧请求都保留现有幂等键和 2000 字结果约束。 + +新总结的来源和模型预算规则: + +- 单次最多选择 20 份录音。 +- 单次最多展开 2000 个 turn。 +- source snapshot 总字符数初始上限为 200,000,通过环境变量配置。 +- 超限时返回明确的 `summary_source_too_large`,不能静默截断或丢弃尾部 turn。 +- worker 按录音和时间顺序格式化来源,例如 + `[00:12-00:18][教师] 对话文本`,匿名角色使用 `Speaker 0` 等稳定标签。 +- 每份录音先按不拆分 turn 的原则组成有限字符块,再生成录音级摘要,最后结合 + `existing_content` 生成不超过 2000 字的总反馈。 +- `prompt_version` 升级为新的明确版本;旧 run 继续保留原版本。 +- 转录文本按不可信输入处理,使用清晰分隔符包裹,并在系统提示中明确禁止执行转录内容中的指令。 +- 记录 source turn 数、source 字符数、模型请求次数和输出字符数,但不记录正文。 + +### 9.7 错误语义 + +至少定义稳定错误分类: + +- `recording_not_ready` +- `recording_upload_incomplete` +- `recording_audio_missing` +- `recording_format_unsupported` +- `recording_too_large` +- `recording_duration_exceeded` +- `transcription_unavailable` +- `transcription_failed` +- `diarization_failed` +- `speaker_mapping_conflict` +- `transcript_version_changed` +- `summary_source_unavailable` +- `audio_storage_delete_failed` + +HTTP 状态和用户提示在 API 文档中逐项映射,前端不能依赖错误字符串包含关系判断业务状态。 + +## 10. 后端模块拆分 + +避免继续扩大单个 `recording_routes.rs`,建议按职责拆分: + +```text +server/src/recordings/ + mod.rs + routes.rs # 列表、详情、元数据、删除 + upload.rs # 流式上传、格式校验、finalize + playback.rs # owner 校验和 Range 响应 + selection.rs # 选择展开、格式化和快照 + +server/src/transcription_worker.rs +server/src/recording_cleanup_worker.rs +server/src/summary_worker.rs +server/src/speech.rs +``` + +拆分原则: + +- 不为每条 SQL 创建无意义 repository trait。 +- 跨路由复用的 owner 校验、选择展开和存储键解析提取为明确函数。 +- 网络调用不放在数据库事务中。 +- 上传文件写入和数据库提交之间保留可恢复状态。 +- worker 状态转换使用条件 UPDATE,防止重复 worker 覆盖完成结果。 + +### 10.1 预计文件变更矩阵 + +| 文件或目录 | 计划变更 | +|---|---| +| `server/migrations/0006_recording_library.sql` | 新录音、run、speaker、turn 和 summary source 表 | +| `server/src/recordings/` | 列表、上传、播放、选择和删除路由 | +| `server/src/routes.rs` | 合并新 router,健康能力字段和 OpenAPI 注册 | +| `server/src/main.rs` | cleanup worker、配置和共享状态接线 | +| `server/src/config.rs` | 功能开关、上传限制、speaker 模型和 cleanup 配置 | +| `server/src/transcription_worker.rs` | 新 run 领取、结构化结果事务写入和版本切换 | +| `server/src/recording_cleanup_worker.rs` | 物理文件清理、重试和孤儿巡检 | +| `server/src/summary_worker.rs` | 新 source snapshot 读取和分层总结,保留旧来源兼容 | +| `server/src/speech.rs` | 解析 speakers/turns,兼容纯文本响应 | +| `server/src/bin/migrate-recording-library.rs` | legacy backfill 和一致性报告 | +| `server/API.md`、`server/API_GUIDE.md` | 新接口、错误语义和示例 | +| `asr-service/app.py` | speaker 模型、结构化响应、长音频处理和校验 | +| `asr-service/requirements.txt` | 只在阶段 0 证明必要时锁定兼容升级 | +| `asr-service/README.md` | 模型、环境变量、响应和性能说明 | +| `asr-service/Dockerfile` | speaker 模型所需依赖和 ffmpeg 能力核验 | +| `pages/recordings/` | 列表、详情和选择页面 | +| `components/recording-*` | 经确认的列表行、播放器和 turn 复用组件 | +| `pages/feedback/index.*` | 精简语音区域并接入录音素材选择 | +| `utils/types.ts`、`utils/api.ts` | 新类型、API 和上传认证恢复 | +| `app.json`、`custom-tab-bar/` | 页面注册;tab 仅在真机决策通过后修改 | +| `tests/` | 选择、状态和前端恢复测试 | +| `DEBUG_GUIDE.md`、`DEPLOY_5700U.md` | 调试、部署、模型缓存和验收更新 | +| `docs/observability-runbook.md` | 新事件、查询和故障定位 | + +## 11. 小程序实现计划 + +### 11.1 类型和 API + +在 `utils/types.ts` 增加: + +- `RecordingSummary` +- `RecordingDetail` +- `RecordingSpeaker` +- `TranscriptTurn` +- `TranscriptionRun` +- `RecordingSelection` +- `RecordingListPage` + +在 `utils/api.ts` 增加对应请求和 snake_case 映射。上传复用认证刷新逻辑,必须覆盖 `wx.uploadFile` token 失效重试和幂等键。 + +### 11.2 可复用组件 + +只为真实复用点增加组件: + +- `components/recording-list-item`:列表页和选择页复用。 +- `components/transcript-turn`:详情页和选择预览复用。 +- `components/recording-player`:详情页复用,封装播放和时间跳转。 + +筛选栏和固定底栏先保留为页面局部结构,避免过早抽象。 + +### 11.3 性能要求 + +- turns 使用分页或分块加载,不一次 `setData` 数百条完整对象。 +- 选择状态使用 ID 集合并只更新受影响行。 +- 播放时间更新节流,不以 100ms 频率调用整页 `setData`。 +- 页面隐藏时停止播放进度更新和高频轮询。 +- 只轮询 queued/processing run;ready/failed 停止轮询。 +- 列表空态、失败态和加载骨架使用稳定尺寸,避免布局跳动。 + +### 11.4 可访问性和视觉约束 + +- 对话内容是详情页主要区域,工具栏和状态栏保持紧凑。 +- 一个区域内只保留一个蓝色主操作。 +- 使用白、石墨和浅灰中性色,不新增装饰渐变和重阴影。 +- 控件圆角遵循当前小程序设计,不用夸张胶囊按钮。 +- 播放、删除、重试等按钮提供明确 `aria-label`。 +- 说话人不能只靠颜色区分,同时显示角色文字。 +- 长标题、长角色名和 2000 字边界在最小真机宽度验证。 + +## 12. 数据迁移与兼容发布 + +### 12.1 Expand + +新增迁移建议从 `0006_recording_library.sql` 开始: + +1. 创建 `recordings`、`recording_audio_parts`、`transcription_runs`、`recording_speakers`、`transcript_turns`。 +2. 创建 `feedback_summary_run_sources`。 +3. 创建所有 CHECK、UNIQUE、FK 和必要索引。 +4. `recordings.current_transcription_run_id` 在 run 表创建后再添加 FK。 +5. 不修改现有表的非空约束,不删除旧表或旧索引。 + +迁移必须在真实规模副本上测量锁时间。大表索引若不能在事务迁移中安全创建,应拆分为运维步骤并使用 PostgreSQL `CREATE INDEX CONCURRENTLY`。 + +### 12.2 Backfill + +新增可重复运行的 `migrate-recording-library` 二进制或受控迁移任务: + +1. 每个 `lesson_session` 创建一个 `source='legacy'` recording。 +2. `legacy_lesson_id` 唯一保证重复执行不重复创建。 +3. 每个 `audio_segment` 创建一个 recording part;先将现有绝对 `audio_path` 规范化并验证位于 + `AUDIO_STORAGE_DIR` 内,再转换为相对 storage key。目录外路径只报告,不自动导入。 +4. ready segment 的纯文本转为一个 `unknown` speaker 下的 turn。 +5. turn 的时间范围按 segment 顺序和 duration 累计估算,并标记为 legacy 导入,不伪装成模型级精确时间。 +6. processing/failed segment 保留原状态映射,是否重转录由用户或后续任务决定。 +7. 每批固定数量提交,输出游标、成功数、跳过数和失败数。 +8. 回填不移动物理文件,避免大批量 I/O 和回滚困难。 + +一致性检查: + +- legacy recording 数等于 lesson 数。 +- part 数等于 audio segment 数。 +- 每个 ready legacy segment 至少有一个非空 turn。 +- duration 和 byte size 汇总无负值。 +- 所有 storage key 可解析且文件存在,缺失文件单独报告。 + +### 12.3 双读和切换 + +1. API 上线新表和新接口,功能开关默认关闭。 +2. 完成 backfill 并运行一致性报告。 +3. 录音资料列表先只对测试账号开放。 +4. 新现场录音写新表;兼容期如仍需旧页面显示,显式双写映射关系,不复制音频文件。 +5. summary worker 支持新 sources 和旧 lessons 两条读取路径。 +6. 小程序启用新页面和选择流程。 +7. 观察一个完整发布周期后,停止旧前端创建 lesson 链路。 +8. 旧 API 和表至少再保留一个发布周期。 + +### 12.4 Contract + +只有满足以下条件才进入 contract: + +- 没有旧版本小程序仍在产生新 lesson 数据。 +- 新录音、重试、删除、总结和回滚均通过生产验证。 +- legacy 一致性报告无未解释差异。 +- 已完成数据库和音频卷可恢复备份。 + +contract 阶段另建计划,不在本次直接安排删除 `lesson_sessions` 或 `audio_segments`。 + +### 12.5 回滚 + +- 关闭 `RECORDING_LIBRARY_ENABLED`,反馈页恢复旧语音入口。 +- 关闭 `ASR_DIARIZATION_ENABLED`,新转录回退纯文本模式。 +- 关闭 `RECORDING_UPLOAD_ENABLED`,保留已上传数据但隐藏新上传入口。 +- API 旧路由、旧 worker 查询和旧表保持可用。 +- 新表保留,不在应用回滚时执行 down migration。 +- 新录音数据不得自动反向写成不完整 lesson;需要时提供显式兼容导出任务。 +- 回滚过程中不删除任何音频文件。 + +## 13. 功能开关和配置 + +建议增加: + +```env +RECORDING_LIBRARY_ENABLED=false +RECORDING_UPLOAD_ENABLED=false +ASR_DIARIZATION_ENABLED=false +ASR_SPEAKER_MODEL=cam++ +MAX_RECORDING_UPLOAD_BYTES=209715200 +MAX_RECORDING_DURATION_SECONDS=14400 +RECORDING_CLEANUP_ENABLED=false +``` + +要求: + +- 开关在健康接口中只返回启用状态,不返回敏感配置。 +- 小程序根据 health capability 决定展示入口。 +- speaker 模型变化必须更新 `config_version`,不能静默改变同一 run 的结果。 +- cleanup 首次上线保持关闭,只运行 dry-run 巡检。 + +## 14. 可观测性计划 + +### 14.1 新事件 + +- `recording_created` +- `recording_part_uploaded` +- `recording_completed` +- `recording_upload_failed` +- `recording_playback_failed` +- `transcription_run_queued` +- `transcription_run_started` +- `transcription_run_finished` +- `transcription_run_failed` +- `speaker_mapping_updated` +- `recording_delete_requested` +- `recording_cleanup_finished` +- `recording_cleanup_failed` +- `recording_selection_applied` +- `summary_sources_snapshotted` + +### 14.2 允许记录的字段 + +- `recording_id` +- `transcription_run_id` +- `feedback_session_id` +- `summary_run_id` +- `owner` 不记录真实 ID 之外的身份信息;需要统计时使用不可逆聚合方式。 +- `status`、`error_class`、`attempt`。 +- `audio_size_bytes`、`audio_duration_ms`、`turn_count`、`speaker_count`。 +- `queue_wait_ms`、`inference_latency_ms`、`rtf`、`cleanup_latency_ms`。 +- `provider`、`asr_model`、`speaker_model`、`config_version`。 + +禁止记录: + +- 对话正文、总结正文和文件内容。 +- 学生姓名、录音标题、说话人显示名。 +- 原始文件名、绝对路径和 storage key。 +- 上传请求体和第三方原始错误响应。 + +### 14.3 生产观察指标 + +- queued/processing run 数量和最老任务等待时间。 +- 转录成功率、diarization 失败率和按 error_class 分布。 +- P50/P95 RTF、内存峰值和音频时长分布。 +- 每日上传字节、音频卷增长和剩余空间。 +- cleanup 待处理数量、最老任务年龄和失败次数。 +- 总结来源 turn 数、字符数和总结失败率。 +- API 列表、详情、播放首字节和上传完成延迟。 + +## 15. 安全与隐私 + +录音和对话属于敏感教学数据,必须满足: + +1. 每个录音、speaker、turn、播放和总结来源查询都通过 recording owner 反查授权。 +2. 不接受前端传入 `owner_id` 作为授权依据。 +3. 上传文件执行大小、时长、格式和解码验证。 +4. ffmpeg 处理设置超时、资源上限和受控临时目录。 +5. 临时文件在成功、失败、超时和进程恢复路径都能清理。 +6. 播放接口不生成永久免认证 URL。 +7. 删除操作提供清晰确认并记录稳定审计事件,不记录正文。 +8. 数据库备份和音频卷备份使用相同的访问权限和恢复演练要求。 +9. 产品界面明确录音用途和删除后果,遵循微信麦克风权限说明。 +10. 不以说话人 embedding 建立跨录音身份库,除非后续完成单独隐私评审。 + +## 16. 分阶段实施与提交计划 + +每个阶段独立提交、可测试、可回滚。不得把数据库、ASR、全部页面和切换一次性合并为一个提交。 + +### 阶段 0:基线和技术预研 + +交付: + +- 代表性音频测试清单和本地标注格式。 +- 固定 FunASR 版本的 CAM++/ERes2NetV2 兼容性结论。 +- 5700U CPU 质量、RTF、内存和缓存报告。 +- 最终 speaker 模型和输出契约决定。 + +建议提交: + +- `test(asr): add diarization evaluation fixtures` +- `docs(asr): record speaker diarization benchmark` + +退出条件:第 8.5 节门槛通过,或形成明确否决结论。 + +### 阶段 1:数据和存储基础 + +交付: + +- 新表、约束和索引。 +- 流式 part 上传和 finalize。 +- 新存储键、hash、时长和大小校验。 +- 认证 Range 播放。 +- cleanup worker 和 dry-run 孤儿报告。 + +建议提交: + +- `feat(recordings): add recording library schema` +- `feat(recordings): stream audio parts to storage` +- `feat(recordings): add authenticated audio playback` +- `feat(recordings): add deferred audio cleanup` + +退出条件:上传、播放、删除、失败恢复和 owner 隔离集成测试通过。 + +### 阶段 2:结构化转录和说话人分离 + +交付: + +- ASR 新响应契约。 +- speaker 模型配置和健康信息。 +- transcription run 版本、队列和原子写入 speakers/turns。 +- 重试和重转录。 +- 模型错误分类和指标。 + +建议提交: + +- `feat(asr): return timestamped speaker turns` +- `feat(voice): persist versioned transcript turns` +- `fix(voice): recover expired diarization jobs` + +退出条件:技术预研样本和端到端 DB 写入均通过。 + +### 阶段 3:录音资料页面 + +交付: + +- 列表 API 和游标分页。 +- 详情、turn 分页和 speaker 映射 API。 +- 录音列表页、详情页和播放器。 +- 处理状态、失败重试、删除和角色映射。 + +建议提交: + +- `feat(recordings): add recording library APIs` +- `feat(miniprogram): add recording library page` +- `feat(miniprogram): add transcript review workspace` + +退出条件:真机可浏览、播放、映射、重试和删除录音。 + +### 阶段 4:素材选择和总结 + +交付: + +- 反馈素材选择页。 +- 选择本地恢复。 +- 直接加入反馈接口。 +- summary source 快照。 +- summary worker 新旧来源兼容。 +- 反馈页精简和总结预览接入。 + +建议提交: + +- `feat(feedback): select recording transcript turns` +- `feat(summary): snapshot selected transcript sources` +- `feat(feedback): compose feedback from recording library` + +退出条件:未选中的 turn 不进入输入框或 AI 请求,历史总结不受重转录影响。 + +### 阶段 5:历史回填和灰度切换 + +交付: + +- 可重复运行的 legacy backfill。 +- 一致性报告。 +- 测试账号功能开关。 +- 生产灰度、监控和回滚演练。 +- 小程序导航最终决策。 + +建议提交: + +- `feat(recordings): backfill legacy voice lessons` +- `chore(recordings): enable recording library rollout` +- `docs(recordings): add operations runbook` + +退出条件:一个完整发布周期无高严重度数据或隐私问题。 + +## 17. 测试计划 + +### 17.1 Rust 单元测试 + +- 存储键只解析到允许目录。 +- Range 请求边界、无效范围和完整下载。 +- 选择展开排序、去重和所有权校验。 +- 整份录音选择正确展开全部当前 turns。 +- 2000 字限制和角色格式化。 +- source hash 对顺序、文本和版本变化敏感。 +- 状态机拒绝非法转换。 +- 同一幂等键返回同一录音或总结任务。 + +### 17.2 PostgreSQL 集成测试 + +- 所有 CHECK、UNIQUE 和 FK 生效。 +- 不同 owner 无法查询、播放、修改、选择或删除彼此录音。 +- worker 并发领取不重复处理。 +- ready run 原子切换 current pointer。 +- 新 run 失败不覆盖旧 ready run。 +- summary source 在录音删除后保留快照。 +- cleanup 失败不会丢失待处理记录。 +- backfill 重跑不产生重复 recording、part 或 turn。 +- 游标分页无重复和遗漏。 + +### 17.3 Python/ASR 测试 + +- 无 speaker 模式保持当前响应兼容。 +- speaker 模式返回合法 speakers 和 turns。 +- 空语音、单人、多人、重叠语音和损坏音频。 +- 所有支持格式和长音频。 +- 临时文件在每条失败路径清理。 +- 连续 20 次推理无内存持续增长。 +- request ID 从 API 传播到 ASR 日志。 + +### 17.4 小程序测试 + +- 列表筛选、分页、刷新和空态。 +- 上传成功、网络中断、认证刷新和重试。 +- 播放、暂停、拖动、跳到 turn 时间。 +- speaker 映射和按 speaker 全选。 +- 跨页面返回后选择恢复。 +- 后台转录完成后的状态刷新。 +- 失败重试和删除确认。 +- 直接加入超过 2000 字时不修改草稿。 +- 总结 ready 后预览和替换输入框。 +- 320px 宽度、常见安卓机和 iPhone 真机布局。 + +### 17.5 端到端验收场景 + +1. 现场录制 20 分钟两人对话,跨越至少两个 part。 +2. 后台转录并保持同一说话人跨 part 一致。 +3. 用户映射教师和学生角色。 +4. 只选择学生的若干 turn 直接加入反馈。 +5. 再选择两份录音生成总结。 +6. 验证 AI 输入不包含未选 turn。 +7. 预览后替换输入框并保存反馈。 +8. 在录音库仍可查看录音;删除录音后历史反馈和总结仍存在。 +9. 重启 API 和 FunASR 后任务恢复,模型不重复下载,音频仍存在。 + +### 17.6 常规校验命令 + +开发提交前至少执行: + +```bash +npm test +cd server && cargo fmt --check +cd server && cargo test +cd server && cargo clippy --all-targets --all-features -- -D warnings +python3 -m py_compile asr-service/app.py asr-service/logging_config.py +docker compose -f compose.deploy.yml config +``` + +涉及 FunASR 依赖变更时还要在镜像内执行 `pip check` 和真实音频推理,不能只做 Python 语法检查。 + +## 18. 部署与发布计划 + +### 18.1 发布前 + +1. 备份远程 PostgreSQL,并记录可恢复时间点。 +2. 备份或快照 `teaching-feedback-audio` 卷。 +3. 记录当前镜像 tag、Git SHA、模型缓存大小和健康状态。 +4. 在生产规模副本验证迁移锁时间和 backfill 批次。 +5. 确认新 speaker 模型缓存所需磁盘和首次下载时间。 +6. 功能开关全部保持关闭。 + +### 18.2 发布顺序 + +1. 拉取已经测试并推送的提交。 +2. 构建 API 和 FunASR 新镜像,不停止现有容器。 +3. 运行 expand migration。 +4. 启动新 API 和 FunASR,保持新功能关闭。 +5. 验证健康检查、旧录音链路和旧总结链路。 +6. 运行 legacy backfill dry-run,再执行正式 backfill。 +7. 对测试账号启用录音资料和 diarization。 +8. 完成真实录音、上传、播放、选择、总结和删除验收。 +9. 扩大灰度范围。 +10. 最后发布使用新入口的小程序版本。 + +### 18.3 回滚触发条件 + +出现以下任一情况立即关闭对应功能: + +- 数据跨 owner 暴露或未授权播放。 +- 新转录覆盖或丢失既有 ready 转录。 +- 删除流程造成不可恢复的非目标音频删除。 +- worker 重复处理导致队列持续增长。 +- FunASR 容器内存持续增长或反复重启。 +- speaker 输出结构错误导致批量任务失败。 +- 新总结包含未选中的对话。 +- 数据库迁移或查询导致生产明显锁等待和超时。 + +## 19. 验收标准 + +### 19.1 产品验收 + +- 用户可以独立进入录音资料,不依赖未保存反馈草稿。 +- 现场录音和上传文件出现在统一列表。 +- 用户可以播放、筛选、重试、重转录和删除本人录音。 +- 详情页按时间和匿名说话人展示对话。 +- 用户可以映射角色并按 turn 选择。 +- 用户可以选择整份录音或多份录音生成总结。 +- 总结可预览,确认后才写入反馈输入框。 +- 录音保存、反馈保存和录音删除互不错误级联。 + +### 19.2 数据完整性验收 + +- recording、part、run、speaker、turn 数量关系满足约束。 +- 所有 ready recording 都有 current ready run 和至少一个非空 turn。 +- 所有 summary run 的新来源均有完整快照。 +- legacy backfill 可重复执行且结果一致。 +- 删除后无数据库悬挂 part,失败删除仍有可重试记录。 +- 孤儿巡检结果为零或每一项都有已记录处置。 + +### 19.3 安全验收 + +- 两个测试用户之间录音列表、详情、播放和选择完全隔离。 +- 伪造 recording、speaker、turn ID 均返回 404 或等价资源不可见响应。 +- 日志检索不到标题、学生姓名、转录文本、原始文件名和路径。 +- Range 播放必须携带有效认证。 +- 上传恶意扩展名、损坏文件和超限文件被拒绝且临时文件清理。 + +### 19.4 性能验收 + +- 录音列表 20 条 P95 API 延迟目标低于 300ms,不含公网网络时间。 +- 详情元数据 P95 低于 300ms;turn 分页 P95 低于 500ms。 +- 音频 Range 首字节在局域网 P95 低于 500ms。 +- 数据库查询计划使用 owner + 时间游标索引,无全量 offset 扫描。 +- diarization 达到第 8.5 节质量和资源门槛。 +- summary worker 在 100 份录音或大量 turns 输入时受明确字符/token 预算限制。 + +### 19.5 运维验收 + +- API、FunASR、migration 和 cleanup 状态可从结构化日志定位。 +- 容器重启后 queued/processing 任务可恢复。 +- 模型缓存和音频卷重启后保持。 +- PostgreSQL 与音频卷完成一次关联恢复演练。 +- 回滚开关和旧链路在生产实际演练成功。 + +## 20. 风险清单 + +| 风险 | 严重度 | 应对 | +|---|---|---| +| 说话人分离质量不足 | 高 | 技术预研门槛、匿名标签、用户映射、允许 unknown | +| 跨 8 分钟 speaker 编号互换 | 高 | 录音级 diarization,禁止直接拼接 part 标签 | +| 数据库记录与文件不一致 | 高 | 受控删除队列、hash、巡检和恢复状态 | +| 跨用户音频泄露 | 阻断 | 每条接口 owner 反查、Range 集成测试 | +| 总结包含未选内容 | 高 | 事务内展开 turn 并快照,worker 只读快照 | +| 大文件占满内存或磁盘 | 高 | 流式上传、限额、临时目录和磁盘告警 | +| FunASR 升级引入回归 | 高 | 固定版本优先、对照基线、独立镜像 tag 和回滚 | +| 新旧表迁移丢失历史 | 高 | expand/backfill/一致性报告,不提前 contract | +| 长对话导致小程序卡顿 | 中 | turn 分页、局部 setData、节流播放进度 | +| 五项 tab 拥挤 | 中 | 真机决策门槛,不通过则普通页面入口 | +| 自动角色识别误导用户 | 中 | 不自动断言角色,必须用户确认 | +| 存储长期增长 | 中 | 容量监控、配额、后续保留策略 | + +## 21. 开工前需要确认的产品决定 + +以下决定有推荐默认值,不应在实现中隐式猜测: + +| 决定 | 推荐默认值 | +|---|---| +| 录音保留期限 | 保留到用户主动删除 | +| 学生档案删除时录音处理 | 录音保留,`profile_id` 置空 | +| 说话人角色 | 教师、学生、家长、其他、未知,允许自定义显示名 | +| 是否自动认定教师 | 否 | +| 首版是否允许修改转录文本 | 否,只允许角色映射 | +| 上传限制 | 200 MiB、4 小时,完成真机和服务端验证后确认 | +| 录音删除后历史总结 | 保留来源快照,不再提供播放 | +| 底部导航 | 真机通过后增加“录音资料”,否则保留普通页面入口 | +| diarization 模型 | 预研后在 CAM++ 和 ERes2NetV2 中选择 | + +## 22. Definition of Done + +只有同时满足以下条件,本功能才算完成: + +1. 本文档中首版目标均有对应实现或明确批准的范围变更。 +2. 数据库迁移、backfill、API、worker、小程序和部署文档全部合并。 +3. 自动测试、代表性音频评测和真机端到端测试通过。 +4. owner 隔离、日志脱敏和删除恢复通过安全验收。 +5. 5700U CPU 上 diarization 达到质量和资源门槛。 +6. 生产完成灰度、监控、回滚和一次容器重启恢复验证。 +7. API 文档、调试指南、部署指南和观测 runbook 已同步更新。 +8. 所有提交使用仓库现有 Conventional Commit 风格并已推送。 +9. 服务器可通过 `git pull --ff-only` 获取确定的 Git SHA 重新部署。 +10. 未完成项、残余风险和生产开关状态在发布报告中明确列出。 + +## 23. 参考资料 + +项目内依据: + +- `README.md` +- `DEBUG_GUIDE.md` +- `DEPLOY_5700U.md` +- `server/API.md` +- `server/migrations/0002_voice_feedback_sessions.sql` +- `server/migrations/0003_async_transcription_jobs.sql` +- `server/migrations/0005_feedback_summary_runs.sql` +- `server/src/recording_routes.rs` +- `server/src/transcription_worker.rs` +- `server/src/summary_worker.rs` +- `server/src/speech.rs` +- `asr-service/app.py` +- `pages/feedback/index.ts` +- `pages/feedback/index.wxml` + +官方技术依据,均应在实际实现时锁定到使用版本对应的文档或提交: + +- [FunASR 官方仓库与 Paraformer speaker model 示例](https://github.com/modelscope/FunASR) +- [FunASR 官方教程中的 Speaker Verification / Diarization](https://github.com/modelscope/FunASR/blob/main/docs/tutorial/README.md) +- [FunASR 官方 Paraformer 实现说明](https://github.com/modelscope/FunASR/blob/main/funasr/models/paraformer/model.py) + +官方 `main` 分支能力不等于本项目固定 `funasr==1.3.14` 已验证能力;阶段 0 的固定版本兼容测试是正式开发的阻断门槛。