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

View File

@@ -4,6 +4,7 @@ import {
createFeedbackRecord, createFeedbackRecord,
createFeedbackSummaryRun, createFeedbackSummaryRun,
createVoiceLesson, createVoiceLesson,
discardEmptyVoiceLesson,
discardVoiceSegment, discardVoiceSegment,
ensureFeedbackSession, ensureFeedbackSession,
finishVoiceLesson, finishVoiceLesson,
@@ -27,9 +28,11 @@ import type {
VoiceLessonFinished VoiceLessonFinished
} from '../../utils/types' } from '../../utils/types'
import { runWithSessionRefresh } from './voice-actions' import { runWithSessionRefresh } from './voice-actions'
import type { VoiceLessonRecoveryAction } from './voice-state'
import { import {
getFailedVoiceSegments, getFailedVoiceSegments,
getVoiceFailureHint, getVoiceFailureHint,
getVoiceLessonRecoveryAction,
getVoiceLessonStatusText, getVoiceLessonStatusText,
getVoicePrimaryAction, getVoicePrimaryAction,
getVoicePrimaryActionText getVoicePrimaryActionText
@@ -60,6 +63,9 @@ interface VoiceLessonView {
summaryEligible: boolean summaryEligible: boolean
summarySelected: boolean summarySelected: boolean
failedSegmentCount: number failedSegmentCount: number
failureRecoveryAction: VoiceLessonRecoveryAction
failureRecoveryText: string
failureDeleteText: string
addActionText: string addActionText: string
emptyText: string emptyText: string
} }
@@ -127,7 +133,7 @@ function formatRecordingTime(milliseconds: number): string {
function formatVoiceDuration(milliseconds: number): string { function formatVoiceDuration(milliseconds: number): string {
const totalMinutes = Math.floor(milliseconds / 60000) 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}分钟` return `${totalMinutes}分钟`
} }
@@ -147,6 +153,7 @@ function buildVoiceLessonViews(
const isInDraft = Boolean(transcript) && content.includes(transcript) const isInDraft = Boolean(transcript) && content.includes(transcript)
const summaryEligible = lesson.status === 'ready' && Boolean(transcript) const summaryEligible = lesson.status === 'ready' && Boolean(transcript)
const failedSegmentCount = lesson.segments.filter((segment) => segment.status === 'failed').length const failedSegmentCount = lesson.segments.filter((segment) => segment.status === 'failed').length
const failureRecoveryAction = getVoiceLessonRecoveryAction(lesson)
let emptyText = '转录完成后可查看内容' let emptyText = '转录完成后可查看内容'
if (lesson.status === 'failed') { if (lesson.status === 'failed') {
emptyText = lesson.segments.find((segment) => segment.status === 'failed')?.errorMessage emptyText = lesson.segments.find((segment) => segment.status === 'failed')?.errorMessage
@@ -166,6 +173,9 @@ function buildVoiceLessonViews(
summaryEligible, summaryEligible,
summarySelected: summaryEligible && !excluded.has(lesson.id), summarySelected: summaryEligible && !excluded.has(lesson.id),
failedSegmentCount, failedSegmentCount,
failureRecoveryAction,
failureRecoveryText: failureRecoveryAction === 'retry' ? '重试转录' : '重新录制',
failureDeleteText: failureRecoveryAction === 'retry' ? '删除录音' : '删除记录',
addActionText: isInDraft addActionText: isInDraft
? '已在输入框' ? '已在输入框'
: lesson.appliedDirectly : lesson.appliedDirectly
@@ -387,8 +397,12 @@ Page({
} }
const prefix = `语音记录 · ${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}` const prefix = `语音记录 · ${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}`
const emptyFailedLessonCount = session.lessons.filter((lesson) =>
lesson.status === 'failed' && lesson.segments.length === 0
).length
let suffix = '已就绪' let suffix = '已就绪'
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败` if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
else if (emptyFailedLessonCount > 0) suffix = `${emptyFailedLessonCount}节未保留录音`
else if (session.processingSegmentCount > 0) suffix = '正在处理' else if (session.processingSegmentCount > 0) suffix = '正在处理'
else if (session.needsGeneration) suffix = '可生成总结' else if (session.needsGeneration) suffix = '可生成总结'
const primaryAction = getVoicePrimaryAction(session) const primaryAction = getVoicePrimaryAction(session)
@@ -683,8 +697,13 @@ Page({
return return
} }
await this.startVoiceRecording()
},
async startVoiceRecording(permissionGranted = false) {
this.setData({ voiceBusy: true, voiceStatus: '正在开启麦克风' }) this.setData({ voiceBusy: true, voiceStatus: '正在开启麦克风' })
const authorized = await this.ensureRecordPermission() let authorized = permissionGranted
if (!authorized) authorized = await this.ensureRecordPermission()
if (!authorized) { if (!authorized) {
this.setData({ voiceBusy: false }) this.setData({ voiceBusy: false })
this.syncVoicePresentation(this.data.session) this.syncVoicePresentation(this.data.session)
@@ -1063,13 +1082,57 @@ Page({
await this.retryVoiceSegments(failedSegments) await this.retryVoiceSegments(failedSegments)
}, },
async retryVoiceLesson(event: LessonActionEvent) { async recoverVoiceLesson(event: LessonActionEvent) {
const session = this.data.session const session = this.data.session
const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId) const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId)
if (!lesson) return if (!lesson) return
const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed') const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed')
if (failedSegments.length === 0) return if (failedSegments.length > 0) {
await this.retryVoiceSegments(failedSegments, lesson.sequence) 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( 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() { async discardFailedSegments() {
if (this.data.recording || this.data.voiceBusy) return if (this.data.recording || this.data.voiceBusy) return
const session = this.data.session const session = this.data.session
@@ -1159,13 +1235,50 @@ Page({
const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId) const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId)
if (!session || !lesson) return if (!session || !lesson) return
const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed') const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed')
if (failedSegments.length === 0) {
if ( if (
failedSegments.length === 0 lesson.status === 'failed'
|| !(await this.confirmDiscardVoiceSegments(failedSegments.length, lesson.sequence)) && await this.confirmDiscardEmptyVoiceLesson(lesson.sequence)
) await this.discardEmptyFailedVoiceLesson(lesson)
return
}
if (
!(await this.confirmDiscardVoiceSegments(failedSegments.length, lesson.sequence))
) return ) return
await this.discardVoiceSegments(failedSegments, lesson.sequence) 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( async discardVoiceSegments(
failedSegments: VoiceAudioSegment[], failedSegments: VoiceAudioSegment[],
lessonSequence?: number lessonSequence?: number

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ pub fn router() -> OpenApiRouter<SharedState> {
.routes(routes!(finish_lesson_session)) .routes(routes!(finish_lesson_session))
.routes(routes!(mark_lesson_applied)) .routes(routes!(mark_lesson_applied))
.routes(routes!(retry_audio_segment)) .routes(routes!(retry_audio_segment))
.routes(routes!(discard_empty_failed_lesson))
.routes(routes!(discard_audio_segment)) .routes(routes!(discard_audio_segment))
.routes(routes!(create_feedback_summary_run)) .routes(routes!(create_feedback_summary_run))
.routes(routes!(get_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( #[utoipa::path(
delete, delete,
path = "/api/v1/audio-segments/{segment_id}", path = "/api/v1/audio-segments/{segment_id}",

View File

@@ -924,7 +924,8 @@ mod tests {
.filter(|operation| operation.get("responses").is_some()) .filter(|operation| operation.get("responses").is_some())
.collect::<Vec<_>>(); .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-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}"));
assert!(paths.contains_key("/api/v1/feedback-summary-runs/{run_id}/apply")); assert!(paths.contains_key("/api/v1/feedback-summary-runs/{run_id}/apply"));

View File

@@ -3,6 +3,7 @@ const test = require('node:test')
const { const {
getVoiceFailureHint, getVoiceFailureHint,
getVoiceLessonRecoveryAction,
getVoiceLessonStatusText, getVoiceLessonStatusText,
getVoicePrimaryAction, getVoicePrimaryAction,
getVoicePrimaryActionText getVoicePrimaryActionText
@@ -83,6 +84,23 @@ test('ready lessons explain whether their transcript can be added again', () =>
assert.equal(getVoiceLessonStatusText({ ...lesson, includedInGeneration: true }), '已生成反馈') 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 () => { test('session refresh runs after a failed API action', async () => {
const expected = new Error('generation rejected') const expected = new Error('generation rejected')
let refreshCount = 0 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( export async function generateFeedbackFromVoice(
sessionId: string, sessionId: string,
existingContent: string existingContent: string