fix(voice): recover empty failed lessons

This commit is contained in:
zhangheng
2026-07-22 21:19:22 +08:00
parent 55084a4784
commit 80e670d72b
8 changed files with 226 additions and 13 deletions

View File

@@ -103,6 +103,7 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,
- 重试开始后立即锁定操作并显示处理中状态;请求结束后无论成功或失败都刷新活动会话并恢复轮询。
- 生成请求结束后始终刷新活动会话API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。
- 页面增加失败原因说明和“放弃失败录音”;放弃操作不会自动开始录音,后端删除失败片段时会重算课堂状态,空课堂会一并删除。
- 失败课节在列表内直接提供恢复操作:保留失败音频时显示“重试转录 / 删除录音”;未保留任何音频的空失败课节显示“重新录制 / 删除记录”,避免提供无法执行的转录重试。
- 语音记录区分“已加入输入框”“待生成反馈”和“已生成反馈”,避免把待汇总的长录音笼统显示为“已就绪”。
- 语音记录进一步调整为可复用素材列表:每节可查看完整转写、展开或收起、按需加入或重新加入输入框;短录音转写完成后不再自动修改正文。
- 反馈输入框支持确认后清空,语音记录仍保留;单节内容加入后若超过 2000 字会整体拒绝并提示,不再截断后写入。
@@ -119,6 +120,7 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,
- 点击“重试转录”后API 日志中能查询到对应 `/retry` 请求和业务事件。
- 页面在重试期间显示明确的处理中状态,且不会重复提交生成请求。
- 重试成功后,该节录音自动进入可选总结集合;再次失败时显示可理解的失败原因。
- 未保留音频的失败课节显示“重新录制”和“删除记录”,不会显示无法生效的转录重试。
- 所有已就绪录音默认被选中,可逐节取消或一键全选;总结完成后必须先显示预览,不能自动覆盖输入框。
- 两万字级原始转录不写入输入框;配置 AI 后由分层总结压缩为不超过 2000 字的一份正文。
- 所有 API 错误都能在页面上获得可复制的请求编号。

View File

@@ -4,6 +4,7 @@ import {
createFeedbackRecord,
createFeedbackSummaryRun,
createVoiceLesson,
discardEmptyVoiceLesson,
discardVoiceSegment,
ensureFeedbackSession,
finishVoiceLesson,
@@ -27,9 +28,11 @@ import type {
VoiceLessonFinished
} from '../../utils/types'
import { runWithSessionRefresh } from './voice-actions'
import type { VoiceLessonRecoveryAction } from './voice-state'
import {
getFailedVoiceSegments,
getVoiceFailureHint,
getVoiceLessonRecoveryAction,
getVoiceLessonStatusText,
getVoicePrimaryAction,
getVoicePrimaryActionText
@@ -60,6 +63,9 @@ interface VoiceLessonView {
summaryEligible: boolean
summarySelected: boolean
failedSegmentCount: number
failureRecoveryAction: VoiceLessonRecoveryAction
failureRecoveryText: string
failureDeleteText: string
addActionText: string
emptyText: string
}
@@ -127,7 +133,7 @@ function formatRecordingTime(milliseconds: number): string {
function formatVoiceDuration(milliseconds: number): string {
const totalMinutes = Math.floor(milliseconds / 60000)
if (totalMinutes < 1) return `${Math.max(1, Math.round(milliseconds / 1000))}`
if (totalMinutes < 1) return `${Math.max(0, Math.round(milliseconds / 1000))}`
return `${totalMinutes}分钟`
}
@@ -147,6 +153,7 @@ function buildVoiceLessonViews(
const isInDraft = Boolean(transcript) && content.includes(transcript)
const summaryEligible = lesson.status === 'ready' && Boolean(transcript)
const failedSegmentCount = lesson.segments.filter((segment) => segment.status === 'failed').length
const failureRecoveryAction = getVoiceLessonRecoveryAction(lesson)
let emptyText = '转录完成后可查看内容'
if (lesson.status === 'failed') {
emptyText = lesson.segments.find((segment) => segment.status === 'failed')?.errorMessage
@@ -166,6 +173,9 @@ function buildVoiceLessonViews(
summaryEligible,
summarySelected: summaryEligible && !excluded.has(lesson.id),
failedSegmentCount,
failureRecoveryAction,
failureRecoveryText: failureRecoveryAction === 'retry' ? '重试转录' : '重新录制',
failureDeleteText: failureRecoveryAction === 'retry' ? '删除录音' : '删除记录',
addActionText: isInDraft
? '已在输入框'
: lesson.appliedDirectly
@@ -387,8 +397,12 @@ Page({
}
const prefix = `语音记录 · ${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}`
const emptyFailedLessonCount = session.lessons.filter((lesson) =>
lesson.status === 'failed' && lesson.segments.length === 0
).length
let suffix = '已就绪'
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
else if (emptyFailedLessonCount > 0) suffix = `${emptyFailedLessonCount}节未保留录音`
else if (session.processingSegmentCount > 0) suffix = '正在处理'
else if (session.needsGeneration) suffix = '可生成总结'
const primaryAction = getVoicePrimaryAction(session)
@@ -683,8 +697,13 @@ Page({
return
}
await this.startVoiceRecording()
},
async startVoiceRecording(permissionGranted = false) {
this.setData({ voiceBusy: true, voiceStatus: '正在开启麦克风' })
const authorized = await this.ensureRecordPermission()
let authorized = permissionGranted
if (!authorized) authorized = await this.ensureRecordPermission()
if (!authorized) {
this.setData({ voiceBusy: false })
this.syncVoicePresentation(this.data.session)
@@ -1063,13 +1082,57 @@ Page({
await this.retryVoiceSegments(failedSegments)
},
async retryVoiceLesson(event: LessonActionEvent) {
async recoverVoiceLesson(event: LessonActionEvent) {
const session = this.data.session
const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId)
if (!lesson) return
const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed')
if (failedSegments.length === 0) return
await this.retryVoiceSegments(failedSegments, lesson.sequence)
if (failedSegments.length > 0) {
await this.retryVoiceSegments(failedSegments, lesson.sequence)
return
}
if (lesson.status === 'failed') await this.rerecordVoiceLesson(lesson)
},
confirmRerecordVoiceLesson(lessonSequence: number): Promise<boolean> {
return new Promise((resolve) => {
wx.showModal({
title: `重新录制第${lessonSequence}`,
content: '该节没有保留可重试的音频。重新录制将删除这条失败记录并开启麦克风。',
confirmText: '重新录制',
success: (result) => resolve(result.confirm),
fail: () => resolve(false)
})
})
},
async rerecordVoiceLesson(lesson: VoiceLesson) {
if (
this.data.recording
|| this.data.voiceBusy
|| !(await this.confirmRerecordVoiceLesson(lesson.sequence))
) return
this.setData({ voiceBusy: true, voiceStatus: `正在准备重新录制第${lesson.sequence}` })
const authorized = await this.ensureRecordPermission()
if (!authorized) {
this.setData({ voiceBusy: false })
this.syncVoicePresentation(this.data.session)
return
}
try {
await discardEmptyVoiceLesson(lesson.id)
logVoiceOperation('empty_failed_lesson_rerecord_started', {
feedbackSessionId: this.data.session?.id || null,
lessonId: lesson.id,
lessonSequence: lesson.sequence
})
await this.startVoiceRecording(true)
} catch (error) {
this.setData({ voiceBusy: false })
await this.loadActiveSession()
this.syncVoicePresentation(this.data.session)
this.showApiError(error)
}
},
async retryVoiceSegments(
@@ -1141,6 +1204,19 @@ Page({
})
},
confirmDiscardEmptyVoiceLesson(lessonSequence: number): Promise<boolean> {
return new Promise((resolve) => {
wx.showModal({
title: `删除第${lessonSequence}节记录`,
content: '该节没有保留可用录音,删除后不会影响其他语音记录。此操作无法撤销。',
confirmText: '确认删除',
confirmColor: '#d13f3a',
success: (result) => resolve(result.confirm),
fail: () => resolve(false)
})
})
},
async discardFailedSegments() {
if (this.data.recording || this.data.voiceBusy) return
const session = this.data.session
@@ -1159,13 +1235,50 @@ Page({
const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId)
if (!session || !lesson) return
const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed')
if (failedSegments.length === 0) {
if (
lesson.status === 'failed'
&& await this.confirmDiscardEmptyVoiceLesson(lesson.sequence)
) await this.discardEmptyFailedVoiceLesson(lesson)
return
}
if (
failedSegments.length === 0
|| !(await this.confirmDiscardVoiceSegments(failedSegments.length, lesson.sequence))
!(await this.confirmDiscardVoiceSegments(failedSegments.length, lesson.sequence))
) return
await this.discardVoiceSegments(failedSegments, lesson.sequence)
},
async discardEmptyFailedVoiceLesson(lesson: VoiceLesson) {
const session = this.data.session
if (!session) return
this.setData({
voiceBusy: true,
voiceStatus: `正在删除第${lesson.sequence}节失败记录`,
primaryActionText: '正在处理',
errorMessage: '',
errorRequestId: ''
})
try {
await runWithSessionRefresh(
async () => discardEmptyVoiceLesson(lesson.id),
async () => {
await this.loadActiveSession()
}
)
logVoiceOperation('empty_failed_lesson_discarded', {
feedbackSessionId: session.id,
lessonId: lesson.id,
lessonSequence: lesson.sequence
})
wx.showToast({ title: `${lesson.sequence}节失败记录已删除`, icon: 'none' })
} catch (error) {
this.showApiError(error)
} finally {
this.setData({ voiceBusy: false })
this.syncVoicePresentation(this.data.session)
}
},
async discardVoiceSegments(
failedSegments: VoiceAudioSegment[],
lessonSequence?: number

View File

@@ -101,7 +101,7 @@
<text class="voice-record-duration">{{lesson.durationText}} · {{lesson.transcriptLength}}字</text>
</view>
</view>
<text class="voice-record-status {{lesson.failedSegmentCount > 0 ? 'voice-record-status-failed' : ''}}">{{lesson.statusText}}</text>
<text class="voice-record-status {{lesson.failureRecoveryAction ? 'voice-record-status-failed' : ''}}">{{lesson.statusText}}</text>
</view>
<text
wx:if="{{lesson.transcript}}"
@@ -123,19 +123,19 @@
bindtap="addVoiceLessonToDraft"
>{{lesson.addActionText}}</button>
</view>
<view wx:if="{{lesson.failedSegmentCount > 0}}" class="voice-record-failure-actions">
<view wx:if="{{lesson.failureRecoveryAction}}" class="voice-record-failure-actions">
<button
class="voice-record-retry-button"
data-lesson-id="{{lesson.id}}"
disabled="{{recording || voiceBusy}}"
catchtap="retryVoiceLesson"
>重试转录</button>
catchtap="recoverVoiceLesson"
>{{lesson.failureRecoveryText}}</button>
<button
class="voice-record-delete-button"
data-lesson-id="{{lesson.id}}"
disabled="{{recording || voiceBusy}}"
catchtap="discardVoiceLesson"
>删除录音</button>
>{{lesson.failureDeleteText}}</button>
</view>
</view>
</checkbox-group>

View File

@@ -1,6 +1,7 @@
import type { FeedbackSession, VoiceAudioSegment, VoiceLesson } from '../../utils/types'
export type VoicePrimaryAction = 'save' | 'retry' | 'processing'
export type VoiceLessonRecoveryAction = 'retry' | 'rerecord' | null
export function getVoicePrimaryAction(session: FeedbackSession | null): VoicePrimaryAction {
if (!session || session.lessonCount === 0) return 'save'
@@ -24,6 +25,15 @@ export function getFailedVoiceSegments(session: FeedbackSession): VoiceAudioSegm
)
}
export function getVoiceLessonRecoveryAction(
lesson: VoiceLesson
): VoiceLessonRecoveryAction {
if (lesson.status !== 'failed') return null
return lesson.segments.some((segment) => segment.status === 'failed')
? 'retry'
: 'rerecord'
}
export function getVoiceLessonStatusText(lesson: VoiceLesson, isInDraft = false): string {
if (lesson.status === 'failed') return '转录失败'
if (lesson.status === 'recording') return '录音中'

View File

@@ -38,6 +38,7 @@ pub fn router() -> OpenApiRouter<SharedState> {
.routes(routes!(finish_lesson_session))
.routes(routes!(mark_lesson_applied))
.routes(routes!(retry_audio_segment))
.routes(routes!(discard_empty_failed_lesson))
.routes(routes!(discard_audio_segment))
.routes(routes!(create_feedback_summary_run))
.routes(routes!(get_feedback_summary_run))
@@ -796,6 +797,67 @@ async fn retry_audio_segment(
}))
}
#[utoipa::path(
delete,
path = "/api/v1/feedback-lessons/{lesson_id}",
tag = "语音反馈",
summary = "删除未保留音频的失败课堂",
description = "删除没有任何录音片段、因而无法重试转录的失败课堂记录。",
params(
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
("lesson_id" = Uuid, Path, description = "失败课堂录音 ID")
),
responses(
(status = 204, description = "空失败课堂已删除"),
(status = 404, description = "课堂不存在、不属于当前用户或仍包含录音片段", body = ErrorBody),
CommonApiErrors
)
)]
async fn discard_empty_failed_lesson(
State(state): State<SharedState>,
headers: HeaderMap,
Path(lesson_id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let owner_id = state
.auth
.authenticate(state.pool.as_ref(), &headers)
.await?
.user_id;
let mut transaction = pool(&state)?.begin().await?;
let session_id = sqlx::query_scalar::<_, Uuid>(
"SELECT f.id FROM lesson_sessions l \
JOIN feedback_sessions f ON f.id = l.feedback_session_id \
WHERE l.id = $1 AND f.owner_id = $2 AND f.status = 'active' \
AND l.status = 'failed' \
AND NOT EXISTS (SELECT 1 FROM audio_segments s WHERE s.lesson_session_id = l.id) \
FOR UPDATE OF l",
)
.bind(lesson_id)
.bind(owner_id)
.fetch_optional(&mut *transaction)
.await?
.ok_or(ApiError::NotFound)?;
sqlx::query("DELETE FROM lesson_sessions WHERE id = $1")
.bind(lesson_id)
.execute(&mut *transaction)
.await?;
sqlx::query("UPDATE feedback_sessions SET updated_at = now() WHERE id = $1")
.bind(session_id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
tracing::info!(
event = "empty_failed_lesson_discarded",
feedback_session_id = %session_id,
lesson_id = %lesson_id,
status = "discarded",
"empty failed lesson discarded"
);
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
delete,
path = "/api/v1/audio-segments/{segment_id}",

View File

@@ -924,7 +924,8 @@ mod tests {
.filter(|operation| operation.get("responses").is_some())
.collect::<Vec<_>>();
assert_eq!(operations.len(), 27);
assert_eq!(operations.len(), 28);
assert!(paths.contains_key("/api/v1/feedback-lessons/{lesson_id}"));
assert!(paths.contains_key("/api/v1/feedback-sessions/{session_id}/summary-runs"));
assert!(paths.contains_key("/api/v1/feedback-summary-runs/{run_id}"));
assert!(paths.contains_key("/api/v1/feedback-summary-runs/{run_id}/apply"));

View File

@@ -3,6 +3,7 @@ const test = require('node:test')
const {
getVoiceFailureHint,
getVoiceLessonRecoveryAction,
getVoiceLessonStatusText,
getVoicePrimaryAction,
getVoicePrimaryActionText
@@ -83,6 +84,23 @@ test('ready lessons explain whether their transcript can be added again', () =>
assert.equal(getVoiceLessonStatusText({ ...lesson, includedInGeneration: true }), '已生成反馈')
})
test('failed lessons distinguish retryable audio from empty failed records', () => {
const failedLesson = {
status: 'failed',
segments: []
}
assert.equal(getVoiceLessonRecoveryAction(failedLesson), 'rerecord')
assert.equal(getVoiceLessonRecoveryAction({
...failedLesson,
segments: [{ status: 'failed' }]
}), 'retry')
assert.equal(getVoiceLessonRecoveryAction({
...failedLesson,
status: 'ready'
}), null)
})
test('session refresh runs after a failed API action', async () => {
const expected = new Error('generation rejected')
let refreshCount = 0

View File

@@ -641,6 +641,13 @@ export async function discardVoiceSegment(segmentId: string): Promise<void> {
)
}
export async function discardEmptyVoiceLesson(lessonId: string): Promise<void> {
await request<unknown>(
`/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}`,
'DELETE'
)
}
export async function generateFeedbackFromVoice(
sessionId: string,
existingContent: string