# 录音资料库、说话人分离与反馈素材编排开发计划 - 状态:待评审 - 编制日期: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 的固定版本兼容测试是正式开发的阻断门槛。