feat(voice): add reusable transcript records
This commit is contained in:
@@ -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` | 汇总多节转录并生成反馈正文。 |
|
||||
|
||||
@@ -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 身份参数。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user