fix(voice): restore failed transcription recovery

This commit is contained in:
zhangheng
2026-07-22 15:35:48 +08:00
parent c54f4214df
commit 146e78e335
15 changed files with 603 additions and 64 deletions

View File

@@ -2,6 +2,7 @@ import {
copyApiRequestId,
createFeedbackRecord,
createVoiceLesson,
discardVoiceSegment,
ensureFeedbackSession,
finishVoiceLesson,
generateFeedbackFromVoice,
@@ -20,6 +21,13 @@ import type {
StudentProfile,
VoiceLessonFinished
} from '../../utils/types'
import { runWithSessionRefresh } from './voice-actions'
import {
getFailedVoiceSegments,
getVoiceFailureHint,
getVoicePrimaryAction,
getVoicePrimaryActionText
} from './voice-state'
type PickerEvent = { detail: { value: string | number } }
type InputEvent = { detail: { value: string } }
@@ -98,6 +106,10 @@ function wait(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function logVoiceOperation(event: string, fields: Record<string, unknown>): void {
console.info('voice_operation', { event, ...fields })
}
Page({
data: {
profiles: [] as StudentProfile[],
@@ -114,6 +126,7 @@ Page({
recordingPaused: false,
recordingTime: '00:00',
voiceStatus: '语音录入',
voiceFailureHint: '',
voiceActionable: false,
primaryActionText: '保存反馈',
errorMessage: '',
@@ -241,23 +254,35 @@ Page({
return
}
if (!session || session.lessonCount === 0) {
this.setData({ voiceStatus: '语音录入', voiceActionable: false, primaryActionText: '保存反馈' })
this.setData({
voiceStatus: '语音录入',
voiceFailureHint: '',
voiceActionable: false,
primaryActionText: '保存反馈'
})
return
}
const prefix = `${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}`
let suffix = '已就绪'
if (session.failedSegmentCount > 0) suffix = '转录失败,点击重试'
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
else if (session.processingSegmentCount > 0) suffix = '正在处理'
let primaryActionText = session.needsGeneration ? '生成反馈' : '保存反馈'
if (session.processingSegmentCount > 0) primaryActionText = '转录中'
const primaryAction = getVoicePrimaryAction(session)
this.setData({
voiceStatus: `${prefix} · ${suffix}`,
voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '',
voiceActionable: true,
primaryActionText
primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount)
})
},
showApiError(error: unknown, showToast = true) {
const errorMessage = getApiErrorMessage(error)
const errorRequestId = getApiRequestId(error)
this.setData({ errorMessage, errorRequestId })
if (showToast) wx.showToast({ title: errorMessage, icon: 'none' })
},
scheduleVoicePolling(session: FeedbackSession | null) {
this.clearVoicePollTimer()
if (!pageVisible || !session || session.processingSegmentCount === 0) return
@@ -302,6 +327,7 @@ Page({
if (readyShortLessons.length > 0) await this.loadActiveSession(profileId)
} catch (error) {
console.warn('Failed to refresh voice transcription status', error)
this.showApiError(error, false)
} finally {
voicePollBusy = false
this.scheduleVoicePolling(this.data.session)
@@ -374,6 +400,7 @@ Page({
this.syncVoicePresentation(updated)
} catch (error) {
console.warn('Failed to auto-save feedback session', error)
this.showApiError(error, false)
}
},
@@ -443,7 +470,7 @@ Page({
} catch (error) {
this.setData({ voiceBusy: false })
this.syncVoicePresentation(this.data.session)
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
this.showApiError(error)
}
},
@@ -496,6 +523,7 @@ Page({
await this.uploadWithRetry(segment)
} catch (error) {
console.warn('Voice segment upload failed', error)
this.showApiError(error, false)
const saved = await this.persistRecordingFile(segment)
failedLocalUploads.push(saved)
wx.setStorageSync(PENDING_UPLOADS_KEY, failedLocalUploads)
@@ -536,11 +564,13 @@ Page({
if (failedLocalUploads.length === 0) return
this.setData({ voiceBusy: true, voiceStatus: '正在重试上传' })
const remaining: PendingVoiceUpload[] = []
let lastError: unknown
for (const segment of failedLocalUploads) {
try {
await this.uploadWithRetry(segment)
this.removeSavedFile(segment.filePath)
} catch {
} catch (error) {
lastError = error
remaining.push(segment)
}
}
@@ -548,7 +578,7 @@ Page({
wx.setStorageSync(PENDING_UPLOADS_KEY, failedLocalUploads)
if (remaining.length > 0) {
this.setData({ voiceBusy: false, voiceStatus: '上传失败,点击重试', voiceActionable: true })
wx.showToast({ title: '录音上传失败,请检查网络', icon: 'none' })
this.showApiError(lastError || new Error('录音上传失败,请检查网络'))
return
}
await this.finalizeCurrentLesson()
@@ -584,7 +614,7 @@ Page({
finishRequested = false
await this.loadActiveSession()
} catch (error) {
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
this.showApiError(error)
} finally {
this.clearRecordingTimer()
this.setData({ recording: false, recordingPaused: false, voiceBusy: false, recordingTime: '00:00' })
@@ -647,7 +677,7 @@ Page({
content: lines.join('\n'),
showCancel: hasFailures,
cancelText: '关闭',
confirmText: hasFailures ? '重试失败项' : '知道了',
confirmText: hasFailures ? '重试转录' : '知道了',
success: (result) => {
if (hasFailures && result.confirm) void this.retryFailedSegments()
}
@@ -657,32 +687,100 @@ Page({
async retryFailedSegments() {
const session = this.data.session
if (!session) return
const failedSegments = session.lessons.flatMap((lesson) =>
lesson.segments.filter((segment) => segment.status === 'failed')
)
const failedSegments = getFailedVoiceSegments(session)
if (failedSegments.length === 0) return
this.setData({ voiceBusy: true, voiceStatus: '正在重试转录' })
logVoiceOperation('transcription_retry_clicked', {
feedbackSessionId: session.id,
failedSegmentCount: failedSegments.length
})
this.setData({
voiceBusy: true,
voiceStatus: `正在重试${failedSegments.length}段转录`,
primaryActionText: '正在重试转录',
errorMessage: '',
errorRequestId: ''
})
try {
for (const segment of failedSegments) await retryVoiceSegment(segment.id)
const failedLessonIds = session.lessons
.filter((lesson) => lesson.segments.some((segment) => segment.status === 'failed'))
.map((lesson) => lesson.id)
for (const lessonId of failedLessonIds) {
const finished = await finishVoiceLesson(lessonId)
await this.applyFinishedLesson(finished)
}
await this.loadActiveSession()
await runWithSessionRefresh(
async () => {
for (const segment of failedSegments) await retryVoiceSegment(segment.id)
},
async () => this.loadActiveSession()
)
logVoiceOperation('transcription_retry_submitted', {
feedbackSessionId: session.id,
segmentIds: failedSegments.map((segment) => segment.id)
})
wx.showToast({ title: '已重新提交,正在转录', icon: 'none' })
} catch (error) {
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
logVoiceOperation('transcription_retry_failed', {
feedbackSessionId: session.id,
requestId: getApiRequestId(error)
})
this.showApiError(error)
} finally {
this.setData({ voiceBusy: false })
this.syncVoicePresentation(this.data.session)
this.scheduleVoicePolling(this.data.session)
}
},
confirmDiscardFailedSegments(): Promise<boolean> {
return new Promise((resolve) => {
wx.showModal({
title: '放弃失败录音',
content: '失败片段将被删除,并立即开始新的录音。此操作无法撤销。',
confirmText: '放弃重录',
confirmColor: '#d13f3a',
success: (result) => resolve(result.confirm),
fail: () => resolve(false)
})
})
},
async discardFailedAndRecord() {
if (this.data.recording || this.data.voiceBusy) return
const session = this.data.session
if (!session) return
const failedSegments = getFailedVoiceSegments(session)
if (failedSegments.length === 0 || !(await this.confirmDiscardFailedSegments())) return
let discarded = false
this.setData({
voiceBusy: true,
voiceStatus: `正在放弃${failedSegments.length}段失败录音`,
primaryActionText: '正在处理',
errorMessage: '',
errorRequestId: ''
})
try {
await runWithSessionRefresh(
async () => {
for (const segment of failedSegments) await discardVoiceSegment(segment.id)
},
async () => this.loadActiveSession()
)
discarded = true
logVoiceOperation('failed_transcription_discarded', {
feedbackSessionId: session.id,
segmentIds: failedSegments.map((segment) => segment.id)
})
} catch (error) {
this.showApiError(error)
} finally {
this.setData({ voiceBusy: false })
this.syncVoicePresentation(this.data.session)
}
if (discarded) await this.toggleRecording()
},
async saveFeedback() {
this.clearDraftSaveTimer()
const session = this.data.session
if (session && session.failedSegmentCount > 0) {
await this.retryFailedSegments()
return
}
if (session && session.processingSegmentCount > 0) {
wx.showToast({ title: '录音仍在转写,请稍候', icon: 'none' })
this.scheduleVoicePolling(session)
@@ -718,7 +816,7 @@ Page({
wx.showToast({ title: '反馈已保存', icon: 'success' })
setTimeout(() => wx.switchTab({ url: '/pages/records/index' }), 450)
} catch (error) {
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
this.showApiError(error)
} finally {
this.setData({ saving: false })
}
@@ -727,17 +825,25 @@ Page({
async generateFeedback() {
const session = this.data.session
if (!session) return
this.setData({ generating: true, voiceBusy: true, voiceStatus: '正在生成反馈' })
this.setData({
generating: true,
voiceBusy: true,
voiceStatus: '正在生成反馈',
errorMessage: '',
errorRequestId: ''
})
try {
const result = await generateFeedbackFromVoice(session.id, this.data.draft.content)
const result = await runWithSessionRefresh(
async () => generateFeedbackFromVoice(session.id, this.data.draft.content),
async () => this.loadActiveSession()
)
this.setData({
'draft.content': result.content,
contentLength: result.content.length
})
await this.loadActiveSession()
wx.showToast({ title: '反馈已生成,可继续修改', icon: 'none' })
} catch (error) {
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
this.showApiError(error)
} finally {
this.setData({ generating: false, voiceBusy: false })
this.syncVoicePresentation(this.data.session)