feat(voice): add reusable transcript records

This commit is contained in:
zhangheng
2026-07-22 17:26:55 +08:00
parent f755ed2b08
commit 108e280b3f
12 changed files with 353 additions and 57 deletions

View File

@@ -100,7 +100,7 @@ Authorization: Bearer <access-token>
| `POST` | `/api/v1/feedback-sessions/{session_id}/lessons` | 开始下一节课堂录音。 |
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/segments` | 上传一个自动切分的片段并加入后台转录队列。 |
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/finish` | 结束课节并汇总片段状态。 |
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/mark-applied` | 标记短录音文字已直接加入反馈。 |
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/mark-applied` | 标记该节转写已手动加入反馈。 |
| `POST` | `/api/v1/audio-segments/{segment_id}/retry` | 重试失败片段的转录。 |
| `DELETE` | `/api/v1/audio-segments/{segment_id}` | 放弃失败片段;空课堂会一并删除。 |
| `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 汇总多节转录并生成反馈正文。 |

View File

@@ -124,10 +124,12 @@ Bearer 会话不存在、过期或已撤销时返回 `401`,小程序会重新
}
```
录音上传接口在音频入库后立即返回 `processing`。Rust 后台工作线程会调用 FunASR并将片段更新为 `ready``failed`;小程序会自动刷新状态,短录音转写完成后仍会直接加入反馈正文
录音上传接口在音频入库后立即返回 `processing`。Rust 后台工作线程会调用 FunASR并将片段更新为 `ready``failed`;小程序会自动刷新状态,转写完成后可在语音记录中逐节查看和使用
失败片段可以通过 `/retry` 重新进入队列,也可以通过 `DELETE /api/v1/audio-segments/{segment_id}` 放弃。两个接口都只接受当前用户活动反馈批次中的 `failed` 片段,并通过行锁避免重试与删除并发执行。
活动反馈批次中的每节语音记录会返回按片段顺序合并的 `transcript`。小程序将语音记录作为可复用素材保存,教师可以逐节查看并按需要加入反馈输入框;录音转写完成后不会自动改写教师正在编辑的正文。
## 6. OpenAPI 维护方式
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和 Bearer 身份参数。

View File

@@ -93,6 +93,7 @@ struct SegmentRow {
sequence: i32,
duration_ms: i32,
status: String,
transcript: Option<String>,
error_message: Option<String>,
}
@@ -141,6 +142,7 @@ struct LessonSummaryResponse {
duration_ms: i32,
applied_directly: bool,
included_in_generation: bool,
transcript: String,
segments: Vec<AudioSegmentResponse>,
started_at: DateTime<Utc>,
ended_at: Option<DateTime<Utc>>,
@@ -614,8 +616,8 @@ async fn finish_lesson_session(
post,
path = "/api/v1/feedback-lessons/{lesson_id}/mark-applied",
tag = "语音反馈",
summary = "标记录音已加入反馈",
description = "短录音文字直接加入反馈内容后,标记该节课无需再次汇总。",
summary = "标记录音已加入反馈",
description = "教师将该节转写手动加入反馈内容后,标记该节课无需再次汇总。",
params(
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
@@ -967,7 +969,8 @@ async fn load_session_response(
.fetch_all(pool)
.await?;
let segments = sqlx::query_as::<_, SegmentRow>(
"SELECT s.id, s.lesson_session_id, s.sequence, s.duration_ms, s.status, s.error_message \
"SELECT s.id, s.lesson_session_id, s.sequence, s.duration_ms, s.status, \
s.transcript, s.error_message \
FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \
WHERE l.feedback_session_id = $1 ORDER BY l.sequence, s.sequence",
)
@@ -994,7 +997,16 @@ async fn load_session_response(
lesson.status == "ready" && !lesson.applied_directly && !lesson.included_in_generation
});
let mut segments_by_lesson: HashMap<Uuid, Vec<AudioSegmentResponse>> = HashMap::new();
let mut transcripts_by_lesson: HashMap<Uuid, Vec<String>> = HashMap::new();
for segment in segments {
if segment.status == "ready" {
if let Some(transcript) = segment.transcript.as_deref() {
transcripts_by_lesson
.entry(segment.lesson_session_id)
.or_default()
.push(transcript.to_owned());
}
}
segments_by_lesson
.entry(segment.lesson_session_id)
.or_default()
@@ -1016,6 +1028,10 @@ async fn load_session_response(
duration_ms: lesson.duration_ms,
applied_directly: lesson.applied_directly,
included_in_generation: lesson.included_in_generation,
transcript: transcripts_by_lesson
.remove(&lesson.id)
.unwrap_or_default()
.join("\n"),
segments: segments_by_lesson.remove(&lesson.id).unwrap_or_default(),
started_at: lesson.started_at,
ended_at: lesson.ended_at,

View File

@@ -968,6 +968,10 @@ mod tests {
assert!(document["info"].get("license").is_none());
assert!(document["components"]["schemas"]["ProfileInput"]["example"].is_object());
assert!(document["components"]["schemas"]["ProfileDefaultsInput"]["example"].is_object());
assert!(
document["components"]["schemas"]["LessonSummaryResponse"]["properties"]["transcript"]
.is_object()
);
assert!(
document["components"]["schemas"]["FeedbackRecordInput"]["example"]["profile_id"]
.is_null()