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

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ miniprogram_npm/
dist/ dist/
coverage/ coverage/
target/ target/
.test-dist/
*.tsbuildinfo *.tsbuildinfo
# Local configuration and secrets # Local configuration and secrets

View File

@@ -3,7 +3,7 @@
## 状态 ## 状态
- 记录日期2026-07-22 - 记录日期2026-07-22
- 当前状态:待处理 - 当前状态:已修复,待部署验证
- 影响范围:小程序反馈生成页的语音转录重试流程 - 影响范围:小程序反馈生成页的语音转录重试流程
- 相关会话:`7dc4f0dd-bd32-4d4b-b90a-c36ae83c2ca7` - 相关会话:`7dc4f0dd-bd32-4d4b-b90a-c36ae83c2ca7`
@@ -73,6 +73,10 @@ POST /api/v1/feedback-lessons/{lesson_id}/finish
页面没有独立、明确的“重试转录”主操作,也没有显示将要重试的失败片段数量。 页面没有独立、明确的“重试转录”主操作,也没有显示将要重试的失败片段数量。
### 原生弹窗确认文案超限
语音记录弹窗把 `confirmText` 设置为“重试失败项”,共 5 个字符。项目使用的微信小程序 API 类型定义明确限制 `showModal.confirmText` 最多 4 个字符,因此该弹窗可能直接调用失败;原实现也没有设置 `fail` 回调,界面上不会留下可观察错误。
### 生成失败后没有刷新会话 ### 生成失败后没有刷新会话
`generateFeedback` 捕获 API 错误后只显示短暂 Toast没有重新调用 `loadActiveSession`,也没有把错误信息和 `X-Request-Id` 保存到页面的持久错误区域。 `generateFeedback` 捕获 API 错误后只显示短暂 Toast没有重新调用 `loadActiveSession`,也没有把错误信息和 `X-Request-Id` 保存到页面的持久错误区域。
@@ -83,7 +87,7 @@ POST /api/v1/feedback-lessons/{lesson_id}/finish
API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,没有前端操作日志;请求到达服务端后,也缺少 `transcription_retry_requested` 等业务事件日志。 API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,没有前端操作日志;请求到达服务端后,也缺少 `transcription_retry_requested` 等业务事件日志。
## 实现方案 ## 实现方案
1.`failedSegmentCount > 0` 时,主操作优先显示“重试转录”,并直接调用失败片段重试流程。 1.`failedSegmentCount > 0` 时,主操作优先显示“重试转录”,并直接调用失败片段重试流程。
2. 禁止在存在失败片段时调用生成接口。 2. 禁止在存在失败片段时调用生成接口。
@@ -92,6 +96,16 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,
5. API 为重试受理、生成拒绝和转录失败增加包含会话 ID、课堂 ID、片段 ID、尝试次数和错误分类的结构化业务日志。 5. API 为重试受理、生成拒绝和转录失败增加包含会话 ID、课堂 ID、片段 ID、尝试次数和错误分类的结构化业务日志。
6. 对“未识别到清晰语音”提供更准确的用户提示,说明可能是录音过短、静音或环境噪声,并允许放弃该失败片段或重新录制。 6. 对“未识别到清晰语音”提供更准确的用户提示,说明可能是录音过短、静音或环境噪声,并允许放弃该失败片段或重新录制。
## 实施结果
- 主操作按 `failed -> processing -> generate -> save` 排序;存在失败片段时显示“重试转录(数量)”,不会调用生成接口。
- 原生弹窗确认文案缩短为“重试转录”,符合最多 4 个字符的约束。
- 重试开始后立即锁定操作并显示处理中状态;请求结束后无论成功或失败都刷新活动会话并恢复轮询。
- 生成请求结束后始终刷新活动会话API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。
- 页面增加失败原因说明和“放弃并重新录制”;后端删除失败片段时会重算课堂状态,空课堂会一并删除。
- API 增加 `transcription_retry_requested``feedback_generation_rejected``failed_transcription_discarded` 业务日志;转录 worker 将无清晰语音分类为 `no_clear_speech`
- 前端自动化测试覆盖失败优先级、重试状态流转、无清晰语音提示和 API 失败后的会话刷新;后端测试覆盖错误分类和 OpenAPI 路由完整性。
## 验收标准 ## 验收标准
- 存在失败片段时,主按钮不再显示“生成反馈”。 - 存在失败片段时,主按钮不再显示“生成反馈”。

View File

@@ -3,7 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "tsc --noEmit false --outDir .test-dist && node --test tests/voice-state.test.js"
}, },
"devDependencies": { "devDependencies": {
"miniprogram-api-typings": "^4.0.0", "miniprogram-api-typings": "^4.0.0",

View File

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

View File

@@ -56,10 +56,18 @@
<text wx:if="{{recording}}" class="recording-time">{{recordingTime}}</text> <text wx:if="{{recording}}" class="recording-time">{{recordingTime}}</text>
<text wx:elif="{{voiceActionable}}" class="voice-state-arrow"></text> <text wx:elif="{{voiceActionable}}" class="voice-state-arrow"></text>
</view> </view>
<view wx:if="{{session && session.failedSegmentCount > 0}}" class="voice-failure-recovery">
<text class="voice-failure-hint">{{voiceFailureHint}}</text>
<button
class="voice-discard-button"
disabled="{{saving || generating || recording || voiceBusy}}"
bindtap="discardFailedAndRecord"
>放弃并重新录制</button>
</view>
</view> </view>
</view> </view>
<button class="save-feedback primary-button" loading="{{saving || generating}}" disabled="{{saving || generating || recording || voiceBusy || (session && session.processingSegmentCount > 0)}}" bindtap="saveFeedback">{{primaryActionText}}</button> <button class="save-feedback primary-button" loading="{{saving || generating}}" disabled="{{saving || generating || recording || voiceBusy || (session && session.processingSegmentCount > 0 && session.failedSegmentCount === 0)}}" bindtap="saveFeedback">{{primaryActionText}}</button>
</view> </view>
<view wx:if="{{!loading && profiles.length === 0}}" class="profiles-hint"> <view wx:if="{{!loading && profiles.length === 0}}" class="profiles-hint">

View File

@@ -165,6 +165,45 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.voice-failure-recovery {
width: 100%;
padding: 16rpx;
border-left: 6rpx solid #d13f3a;
border-radius: 8rpx;
background: #fff7f6;
}
.voice-failure-hint {
display: block;
color: #6e3f3c;
font-size: 24rpx;
line-height: 1.55;
}
.voice-discard-button {
width: auto;
min-width: 0;
height: 56rpx;
margin: 12rpx 0 0;
padding: 0;
border: 0;
background: transparent;
color: #b8322d;
font-size: 24rpx;
font-weight: 600;
line-height: 56rpx;
text-align: left;
}
.voice-discard-button::after {
border: 0;
}
.voice-discard-button[disabled] {
color: #aeaeb2;
opacity: 1;
}
.save-feedback { .save-feedback {
width: calc(100% - 48rpx); width: calc(100% - 48rpx);
min-width: calc(100% - 48rpx); min-width: calc(100% - 48rpx);

View File

@@ -0,0 +1,18 @@
export async function runWithSessionRefresh<T>(
action: () => Promise<T>,
refreshSession: () => Promise<void>
): Promise<T> {
let result: T
try {
result = await action()
} catch (error) {
try {
await refreshSession()
} catch {
// Preserve the actionable API error and its request ID.
}
throw error
}
await refreshSession()
return result
}

View File

@@ -0,0 +1,39 @@
import type { FeedbackSession, VoiceAudioSegment } from '../../utils/types'
export type VoicePrimaryAction = 'save' | 'retry' | 'processing' | 'generate'
export function getVoicePrimaryAction(session: FeedbackSession | null): VoicePrimaryAction {
if (!session || session.lessonCount === 0) return 'save'
if (session.failedSegmentCount > 0) return 'retry'
if (session.processingSegmentCount > 0) return 'processing'
return session.needsGeneration ? 'generate' : 'save'
}
export function getVoicePrimaryActionText(
action: VoicePrimaryAction,
failedSegmentCount = 0
): string {
if (action === 'retry') return `重试转录(${failedSegmentCount}`
if (action === 'processing') return '转录中'
if (action === 'generate') return '生成反馈'
return '保存反馈'
}
export function getFailedVoiceSegments(session: FeedbackSession): VoiceAudioSegment[] {
return session.lessons.flatMap((lesson) =>
lesson.segments.filter((segment) => segment.status === 'failed')
)
}
export function getVoiceFailureHint(session: FeedbackSession): string {
const failedSegments = getFailedVoiceSegments(session)
const hasNoClearSpeech = failedSegments.some((segment) => {
const message = segment.errorMessage?.toLowerCase() || ''
return message.includes('no clear speech') || message.includes('未识别到清晰语音')
})
const count = session.failedSegmentCount
if (hasNoClearSpeech) {
return `${count}段录音未识别到清晰语音,可能是录音过短、静音或环境噪声较大。可重试,或放弃后重新录制。`
}
return `${count}段录音转录失败,可重试,或放弃后重新录制。`
}

View File

@@ -102,6 +102,7 @@ Authorization: Bearer <access-token>
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/finish` | 结束课节并汇总片段状态。 | | `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` | 重试失败片段的转录。 | | `POST` | `/api/v1/audio-segments/{segment_id}/retry` | 重试失败片段的转录。 |
| `DELETE` | `/api/v1/audio-segments/{segment_id}` | 放弃失败片段;空课堂会一并删除。 |
| `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 汇总多节转录并生成反馈正文。 | | `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 汇总多节转录并生成反馈正文。 |
### 创建学生档案 ### 创建学生档案

View File

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

View File

@@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc};
use axum::{ use axum::{
Json, Json,
extract::{Multipart, Path, Query, State}, extract::{Multipart, Path, Query, State},
http::HeaderMap, http::{HeaderMap, StatusCode},
}; };
use chrono::{DateTime, NaiveDate, Utc}; use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -15,6 +15,7 @@ use uuid::Uuid;
use crate::{ use crate::{
AppState, AppState,
error::{ApiError, CommonApiErrors, ErrorBody}, error::{ApiError, CommonApiErrors, ErrorBody},
transcription_worker::transcription_error_class,
}; };
type SharedState = Arc<AppState>; type SharedState = Arc<AppState>;
@@ -34,6 +35,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_audio_segment))
.routes(routes!(generate_feedback_content)) .routes(routes!(generate_feedback_content))
} }
@@ -94,6 +96,26 @@ struct SegmentRow {
error_message: Option<String>, error_message: Option<String>,
} }
#[derive(Debug, FromRow)]
struct RetryableSegmentRow {
sequence: i32,
duration_ms: i32,
audio_path: String,
lesson_id: Uuid,
feedback_session_id: Uuid,
transcription_attempts: i32,
error_message: Option<String>,
}
#[derive(Debug, FromRow)]
struct GenerationBlockingSegmentRow {
id: Uuid,
lesson_id: Uuid,
status: String,
transcription_attempts: i32,
error_message: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)] #[derive(Debug, Serialize, ToSchema)]
struct FeedbackSessionResponse { struct FeedbackSessionResponse {
id: Uuid, id: Uuid,
@@ -665,19 +687,23 @@ async fn retry_audio_segment(
.authenticate(state.pool.as_ref(), &headers) .authenticate(state.pool.as_ref(), &headers)
.await? .await?
.user_id; .user_id;
let segment = sqlx::query_as::<_, (i32, i32, String, Uuid)>( let mut transaction = pool(&state)?.begin().await?;
"SELECT s.sequence, s.duration_ms, s.audio_path, l.id \ let segment = sqlx::query_as::<_, RetryableSegmentRow>(
"SELECT s.sequence, s.duration_ms, s.audio_path, l.id AS lesson_id, \
f.id AS feedback_session_id, s.transcription_attempts, s.error_message \
FROM audio_segments s \ FROM audio_segments s \
JOIN lesson_sessions l ON l.id = s.lesson_session_id \ JOIN lesson_sessions l ON l.id = s.lesson_session_id \
JOIN feedback_sessions f ON f.id = l.feedback_session_id \ JOIN feedback_sessions f ON f.id = l.feedback_session_id \
WHERE s.id = $1 AND f.owner_id = $2 AND f.status = 'active'", WHERE s.id = $1 AND f.owner_id = $2 AND f.status = 'active' \
AND s.status = 'failed' \
FOR UPDATE OF s",
) )
.bind(segment_id) .bind(segment_id)
.bind(owner_id) .bind(owner_id)
.fetch_optional(pool(&state)?) .fetch_optional(&mut *transaction)
.await? .await?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
tokio::fs::metadata(&segment.2) tokio::fs::metadata(&segment.audio_path)
.await .await
.map_err(internal_io_error)?; .map_err(internal_io_error)?;
sqlx::query( sqlx::query(
@@ -685,30 +711,144 @@ async fn retry_audio_segment(
processing_started_at = NULL, updated_at = now() WHERE id = $1", processing_started_at = NULL, updated_at = now() WHERE id = $1",
) )
.bind(segment_id) .bind(segment_id)
.execute(pool(&state)?) .execute(&mut *transaction)
.await?; .await?;
sqlx::query( sqlx::query(
"UPDATE lesson_sessions SET status = 'processing', updated_at = now() WHERE id = $1", "UPDATE lesson_sessions SET status = 'processing', updated_at = now() WHERE id = $1",
) )
.bind(segment.3) .bind(segment.lesson_id)
.execute(pool(&state)?) .execute(&mut *transaction)
.await?; .await?;
let session_id = sqlx::query_scalar::<_, Uuid>( sqlx::query("UPDATE feedback_sessions SET updated_at = now() WHERE id = $1")
"SELECT feedback_session_id FROM lesson_sessions WHERE id = $1", .bind(segment.feedback_session_id)
) .execute(&mut *transaction)
.bind(segment.3) .await?;
.fetch_one(pool(&state)?) transaction.commit().await?;
.await?; tracing::info!(
touch_session(pool(&state)?, session_id).await?; event = "transcription_retry_requested",
feedback_session_id = %segment.feedback_session_id,
lesson_id = %segment.lesson_id,
segment_id = %segment_id,
attempt = segment.transcription_attempts + 1,
previous_attempt = segment.transcription_attempts,
error_class = transcription_error_class(segment.error_message.as_deref().unwrap_or("")),
status = "processing",
"failed audio segment returned to transcription queue"
);
Ok(Json(AudioSegmentResponse { Ok(Json(AudioSegmentResponse {
id: segment_id, id: segment_id,
sequence: segment.0, sequence: segment.sequence,
duration_ms: segment.1, duration_ms: segment.duration_ms,
status: "processing".to_owned(), status: "processing".to_owned(),
error_message: None, error_message: None,
})) }))
} }
#[utoipa::path(
delete,
path = "/api/v1/audio-segments/{segment_id}",
tag = "语音反馈",
summary = "放弃失败的录音片段",
description = "删除无法使用的失败录音。若课堂不再包含片段,则同时删除空课堂。",
params(
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
("segment_id" = Uuid, Path, description = "失败录音片段 ID")
),
responses(
(status = 204, description = "失败片段已放弃"),
(status = 404, description = "失败片段不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn discard_audio_segment(
State(state): State<SharedState>,
headers: HeaderMap,
Path(segment_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 segment = sqlx::query_as::<_, RetryableSegmentRow>(
"SELECT s.sequence, s.duration_ms, s.audio_path, l.id AS lesson_id, \
f.id AS feedback_session_id, s.transcription_attempts, s.error_message \
FROM audio_segments s \
JOIN lesson_sessions l ON l.id = s.lesson_session_id \
JOIN feedback_sessions f ON f.id = l.feedback_session_id \
WHERE s.id = $1 AND f.owner_id = $2 AND f.status = 'active' \
AND s.status = 'failed' \
FOR UPDATE OF s",
)
.bind(segment_id)
.bind(owner_id)
.fetch_optional(&mut *transaction)
.await?
.ok_or(ApiError::NotFound)?;
sqlx::query("DELETE FROM audio_segments WHERE id = $1 AND status = 'failed'")
.bind(segment_id)
.execute(&mut *transaction)
.await?;
let remaining_segment_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM audio_segments WHERE lesson_session_id = $1",
)
.bind(segment.lesson_id)
.fetch_one(&mut *transaction)
.await?;
if remaining_segment_count == 0 {
sqlx::query("DELETE FROM lesson_sessions WHERE id = $1")
.bind(segment.lesson_id)
.execute(&mut *transaction)
.await?;
} else {
sqlx::query(
"UPDATE lesson_sessions SET \
status = CASE \
WHEN EXISTS (SELECT 1 FROM audio_segments WHERE lesson_session_id = $1 AND status = 'failed') THEN 'failed' \
WHEN EXISTS (SELECT 1 FROM audio_segments WHERE lesson_session_id = $1 AND status = 'processing') THEN 'processing' \
ELSE 'ready' \
END, \
duration_ms = (SELECT COALESCE(SUM(duration_ms), 0) FROM audio_segments WHERE lesson_session_id = $1), \
updated_at = now() \
WHERE id = $1",
)
.bind(segment.lesson_id)
.execute(&mut *transaction)
.await?;
}
sqlx::query("UPDATE feedback_sessions SET updated_at = now() WHERE id = $1")
.bind(segment.feedback_session_id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
if let Err(error) = tokio::fs::remove_file(&segment.audio_path).await
&& error.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
event = "audio_storage_delete_failed",
feedback_session_id = %segment.feedback_session_id,
lesson_id = %segment.lesson_id,
segment_id = %segment_id,
error_class = "audio_storage_delete_failed",
"discarded audio file could not be removed from storage"
);
}
tracing::info!(
event = "failed_transcription_discarded",
feedback_session_id = %segment.feedback_session_id,
lesson_id = %segment.lesson_id,
segment_id = %segment_id,
attempt = segment.transcription_attempts,
error_class = transcription_error_class(segment.error_message.as_deref().unwrap_or("")),
status = "discarded",
"failed audio segment discarded"
);
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/v1/feedback-sessions/{session_id}/generate", path = "/api/v1/feedback-sessions/{session_id}/generate",
@@ -739,22 +879,30 @@ async fn generate_feedback_content(
.user_id; .user_id;
validate_draft_content(&input.existing_content)?; validate_draft_content(&input.existing_content)?;
assert_session_owner(pool(&state)?, owner_id, session_id).await?; assert_session_owner(pool(&state)?, owner_id, session_id).await?;
let pending_status = sqlx::query_as::<_, (bool, bool)>( let blocking_segments = sqlx::query_as::<_, GenerationBlockingSegmentRow>(
"SELECT \ "SELECT s.id, l.id AS lesson_id, s.status, s.transcription_attempts, s.error_message \
EXISTS(SELECT 1 FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \ FROM audio_segments s \
WHERE l.feedback_session_id = $1 AND s.status = 'processing'), \ JOIN lesson_sessions l ON l.id = s.lesson_session_id \
EXISTS(SELECT 1 FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \ WHERE l.feedback_session_id = $1 AND s.status IN ('processing', 'failed') \
WHERE l.feedback_session_id = $1 AND s.status = 'failed')", ORDER BY l.sequence, s.sequence",
) )
.bind(session_id) .bind(session_id)
.fetch_one(pool(&state)?) .fetch_all(pool(&state)?)
.await?; .await?;
if pending_status.0 { if blocking_segments
.iter()
.any(|segment| segment.status == "processing")
{
log_generation_rejection(session_id, &blocking_segments, "processing");
return Err(ApiError::BadRequest( return Err(ApiError::BadRequest(
"录音仍在后台转录中,请稍后再生成反馈".to_owned(), "录音仍在后台转录中,请稍后再生成反馈".to_owned(),
)); ));
} }
if pending_status.1 { if blocking_segments
.iter()
.any(|segment| segment.status == "failed")
{
log_generation_rejection(session_id, &blocking_segments, "failed");
return Err(ApiError::BadRequest( return Err(ApiError::BadRequest(
"部分录音转录失败,请重试后再生成反馈".to_owned(), "部分录音转录失败,请重试后再生成反馈".to_owned(),
)); ));
@@ -979,6 +1127,34 @@ fn internal_io_error(_: std::io::Error) -> ApiError {
ApiError::Internal ApiError::Internal
} }
fn log_generation_rejection(
session_id: Uuid,
blocking_segments: &[GenerationBlockingSegmentRow],
blocking_status: &str,
) {
for segment in blocking_segments
.iter()
.filter(|segment| segment.status == blocking_status)
{
let error_class = if segment.status == "processing" {
"transcription_processing"
} else {
transcription_error_class(segment.error_message.as_deref().unwrap_or(""))
};
tracing::warn!(
event = "feedback_generation_rejected",
feedback_session_id = %session_id,
lesson_id = %segment.lesson_id,
segment_id = %segment.id,
attempt = segment.transcription_attempts,
status = %segment.status,
error_class,
rejection_reason = %blocking_status,
"feedback generation blocked by audio transcription state"
);
}
}
fn validate_draft_content(content: &str) -> Result<(), ApiError> { fn validate_draft_content(content: &str) -> Result<(), ApiError> {
if content.chars().count() > 2000 { if content.chars().count() > 2000 {
return Err(ApiError::BadRequest( return Err(ApiError::BadRequest(

View File

@@ -924,7 +924,7 @@ mod tests {
.filter(|operation| operation.get("responses").is_some()) .filter(|operation| operation.get("responses").is_some())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(operations.len(), 23); assert_eq!(operations.len(), 24);
for operation in &operations { for operation in &operations {
assert_non_empty(operation, "summary"); assert_non_empty(operation, "summary");
assert_non_empty(operation, "description"); assert_non_empty(operation, "description");

View File

@@ -335,10 +335,13 @@ async fn fail_job(
Ok(()) Ok(())
} }
fn transcription_error_class(message: &str) -> &'static str { pub(crate) fn transcription_error_class(message: &str) -> &'static str {
if message.contains("连接失败") || message.contains("unavailable") { let normalized = message.to_lowercase();
if normalized.contains("no clear speech") || message.contains("未识别到清晰语音") {
"no_clear_speech"
} else if message.contains("连接失败") || normalized.contains("unavailable") {
"asr_unavailable" "asr_unavailable"
} else if message.contains("HTTP") { } else if normalized.contains("http") {
"asr_http_error" "asr_http_error"
} else if message.contains("读取录音") { } else if message.contains("读取录音") {
"audio_read_failed" "audio_read_failed"
@@ -379,3 +382,24 @@ async fn refresh_lesson(pool: &PgPool, lesson_id: Uuid) -> Result<(), sqlx::Erro
.await?; .await?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::transcription_error_class;
#[test]
fn no_clear_speech_is_classified_before_http_errors() {
assert_eq!(
transcription_error_class("FunASR 返回 HTTP 422: no clear speech was recognized"),
"no_clear_speech"
);
}
#[test]
fn generic_http_error_keeps_provider_error_class() {
assert_eq!(
transcription_error_class("FunASR 返回 HTTP 500"),
"asr_http_error"
);
}
}

103
tests/voice-state.test.js Normal file
View File

@@ -0,0 +1,103 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const {
getVoiceFailureHint,
getVoicePrimaryAction,
getVoicePrimaryActionText
} = require('../.test-dist/pages/feedback/voice-state.js')
const { runWithSessionRefresh } = require('../.test-dist/pages/feedback/voice-actions.js')
function session(overrides = {}) {
return {
id: 'session-1',
profileId: null,
feedbackDate: '2026-07-22',
content: '',
status: 'active',
lessonCount: 1,
totalDurationMs: 2800,
processingSegmentCount: 0,
failedSegmentCount: 0,
needsGeneration: false,
lessons: [],
createdAt: 0,
updatedAt: 0,
...overrides
}
}
test('failed segments take priority over processing and generation', () => {
const value = session({
failedSegmentCount: 2,
processingSegmentCount: 1,
needsGeneration: true
})
const action = getVoicePrimaryAction(value)
assert.equal(action, 'retry')
assert.equal(getVoicePrimaryActionText(action, value.failedSegmentCount), '重试转录2')
})
test('voice primary action follows retry, processing, then generation states', () => {
assert.equal(getVoicePrimaryAction(session({ failedSegmentCount: 1 })), 'retry')
assert.equal(getVoicePrimaryAction(session({ processingSegmentCount: 1 })), 'processing')
assert.equal(getVoicePrimaryAction(session({ needsGeneration: true })), 'generate')
})
test('no clear speech failures provide an actionable explanation', () => {
const value = session({
failedSegmentCount: 1,
lessons: [
{
segments: [
{
id: 'segment-1',
sequence: 1,
durationMs: 2800,
status: 'failed',
errorMessage: 'FunASR HTTP 422: no clear speech was recognized'
}
]
}
]
})
const hint = getVoiceFailureHint(value)
assert.match(hint, /录音过短、静音或环境噪声/)
assert.match(hint, /放弃后重新录制/)
})
test('session refresh runs after a failed API action', async () => {
const expected = new Error('generation rejected')
let refreshCount = 0
await assert.rejects(
runWithSessionRefresh(
async () => {
throw expected
},
async () => {
refreshCount += 1
}
),
expected
)
assert.equal(refreshCount, 1)
})
test('refresh failure does not replace the original API error', async () => {
const expected = new Error('generation rejected with request id')
await assert.rejects(
runWithSessionRefresh(
async () => {
throw expected
},
async () => {
throw new Error('refresh failed')
}
),
expected
)
})

View File

@@ -586,6 +586,13 @@ export async function retryVoiceSegment(segmentId: string): Promise<VoiceAudioSe
return mapVoiceSegment(result) return mapVoiceSegment(result)
} }
export async function discardVoiceSegment(segmentId: string): Promise<void> {
await request<unknown>(
`/api/v1/audio-segments/${encodeURIComponent(segmentId)}`,
'DELETE'
)
}
export async function generateFeedbackFromVoice( export async function generateFeedbackFromVoice(
sessionId: string, sessionId: string,
existingContent: string existingContent: string