fix(voice): restore failed transcription recovery
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@ miniprogram_npm/
|
||||
dist/
|
||||
coverage/
|
||||
target/
|
||||
.test-dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local configuration and secrets
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## 状态
|
||||
|
||||
- 记录日期:2026-07-22
|
||||
- 当前状态:待处理
|
||||
- 当前状态:已修复,待部署验证
|
||||
- 影响范围:小程序反馈生成页的语音转录重试流程
|
||||
- 相关会话:`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` 保存到页面的持久错误区域。
|
||||
@@ -83,7 +87,7 @@ POST /api/v1/feedback-lessons/{lesson_id}/finish
|
||||
|
||||
API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,没有前端操作日志;请求到达服务端后,也缺少 `transcription_retry_requested` 等业务事件日志。
|
||||
|
||||
## 待实现方案
|
||||
## 实现方案
|
||||
|
||||
1. 当 `failedSegmentCount > 0` 时,主操作优先显示“重试转录”,并直接调用失败片段重试流程。
|
||||
2. 禁止在存在失败片段时调用生成接口。
|
||||
@@ -92,6 +96,16 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,
|
||||
5. API 为重试受理、生成拒绝和转录失败增加包含会话 ID、课堂 ID、片段 ID、尝试次数和错误分类的结构化业务日志。
|
||||
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 路由完整性。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 存在失败片段时,主按钮不再显示“生成反馈”。
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc --noEmit false --outDir .test-dist && node --test tests/voice-state.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"miniprogram-api-typings": "^4.0.0",
|
||||
|
||||
@@ -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 {
|
||||
await runWithSessionRefresh(
|
||||
async () => {
|
||||
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()
|
||||
},
|
||||
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)
|
||||
|
||||
@@ -56,10 +56,18 @@
|
||||
<text wx:if="{{recording}}" class="recording-time">{{recordingTime}}</text>
|
||||
<text wx:elif="{{voiceActionable}}" class="voice-state-arrow">›</text>
|
||||
</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>
|
||||
|
||||
<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 wx:if="{{!loading && profiles.length === 0}}" class="profiles-hint">
|
||||
|
||||
@@ -165,6 +165,45 @@
|
||||
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 {
|
||||
width: calc(100% - 48rpx);
|
||||
min-width: calc(100% - 48rpx);
|
||||
|
||||
18
pages/feedback/voice-actions.ts
Normal file
18
pages/feedback/voice-actions.ts
Normal 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
|
||||
}
|
||||
39
pages/feedback/voice-state.ts
Normal file
39
pages/feedback/voice-state.ts
Normal 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}段录音转录失败,可重试,或放弃后重新录制。`
|
||||
}
|
||||
@@ -102,6 +102,7 @@ Authorization: Bearer <access-token>
|
||||
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/finish` | 结束课节并汇总片段状态。 |
|
||||
| `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` | 汇总多节转录并生成反馈正文。 |
|
||||
|
||||
### 创建学生档案
|
||||
|
||||
@@ -126,6 +126,8 @@ Bearer 会话不存在、过期或已撤销时返回 `401`,小程序会重新
|
||||
|
||||
录音上传接口在音频入库后立即返回 `processing`。Rust 后台工作线程会调用 FunASR,并将片段更新为 `ready` 或 `failed`;小程序会自动刷新状态,短录音转写完成后仍会直接加入反馈正文。
|
||||
|
||||
失败片段可以通过 `/retry` 重新进入队列,也可以通过 `DELETE /api/v1/audio-segments/{segment_id}` 放弃。两个接口都只接受当前用户活动反馈批次中的 `failed` 片段,并通过行锁避免重试与删除并发执行。
|
||||
|
||||
## 6. OpenAPI 维护方式
|
||||
|
||||
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和 Bearer 身份参数。
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::HeaderMap,
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -15,6 +15,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
AppState,
|
||||
error::{ApiError, CommonApiErrors, ErrorBody},
|
||||
transcription_worker::transcription_error_class,
|
||||
};
|
||||
|
||||
type SharedState = Arc<AppState>;
|
||||
@@ -34,6 +35,7 @@ pub fn router() -> OpenApiRouter<SharedState> {
|
||||
.routes(routes!(finish_lesson_session))
|
||||
.routes(routes!(mark_lesson_applied))
|
||||
.routes(routes!(retry_audio_segment))
|
||||
.routes(routes!(discard_audio_segment))
|
||||
.routes(routes!(generate_feedback_content))
|
||||
}
|
||||
|
||||
@@ -94,6 +96,26 @@ struct SegmentRow {
|
||||
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)]
|
||||
struct FeedbackSessionResponse {
|
||||
id: Uuid,
|
||||
@@ -665,19 +687,23 @@ async fn retry_audio_segment(
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let segment = sqlx::query_as::<_, (i32, i32, String, Uuid)>(
|
||||
"SELECT s.sequence, s.duration_ms, s.audio_path, l.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'",
|
||||
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(pool(&state)?)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
tokio::fs::metadata(&segment.2)
|
||||
tokio::fs::metadata(&segment.audio_path)
|
||||
.await
|
||||
.map_err(internal_io_error)?;
|
||||
sqlx::query(
|
||||
@@ -685,30 +711,144 @@ async fn retry_audio_segment(
|
||||
processing_started_at = NULL, updated_at = now() WHERE id = $1",
|
||||
)
|
||||
.bind(segment_id)
|
||||
.execute(pool(&state)?)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE lesson_sessions SET status = 'processing', updated_at = now() WHERE id = $1",
|
||||
)
|
||||
.bind(segment.3)
|
||||
.execute(pool(&state)?)
|
||||
.bind(segment.lesson_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let session_id = sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT feedback_session_id FROM lesson_sessions WHERE id = $1",
|
||||
)
|
||||
.bind(segment.3)
|
||||
.fetch_one(pool(&state)?)
|
||||
sqlx::query("UPDATE feedback_sessions SET updated_at = now() WHERE id = $1")
|
||||
.bind(segment.feedback_session_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
touch_session(pool(&state)?, session_id).await?;
|
||||
transaction.commit().await?;
|
||||
tracing::info!(
|
||||
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 {
|
||||
id: segment_id,
|
||||
sequence: segment.0,
|
||||
duration_ms: segment.1,
|
||||
sequence: segment.sequence,
|
||||
duration_ms: segment.duration_ms,
|
||||
status: "processing".to_owned(),
|
||||
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(
|
||||
post,
|
||||
path = "/api/v1/feedback-sessions/{session_id}/generate",
|
||||
@@ -739,22 +879,30 @@ async fn generate_feedback_content(
|
||||
.user_id;
|
||||
validate_draft_content(&input.existing_content)?;
|
||||
assert_session_owner(pool(&state)?, owner_id, session_id).await?;
|
||||
let pending_status = sqlx::query_as::<_, (bool, bool)>(
|
||||
"SELECT \
|
||||
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 = 'processing'), \
|
||||
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 = 'failed')",
|
||||
let blocking_segments = sqlx::query_as::<_, GenerationBlockingSegmentRow>(
|
||||
"SELECT s.id, l.id AS lesson_id, s.status, s.transcription_attempts, s.error_message \
|
||||
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') \
|
||||
ORDER BY l.sequence, s.sequence",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(pool(&state)?)
|
||||
.fetch_all(pool(&state)?)
|
||||
.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(
|
||||
"录音仍在后台转录中,请稍后再生成反馈".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(
|
||||
"部分录音转录失败,请重试后再生成反馈".to_owned(),
|
||||
));
|
||||
@@ -979,6 +1127,34 @@ fn internal_io_error(_: std::io::Error) -> ApiError {
|
||||
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> {
|
||||
if content.chars().count() > 2000 {
|
||||
return Err(ApiError::BadRequest(
|
||||
|
||||
@@ -924,7 +924,7 @@ mod tests {
|
||||
.filter(|operation| operation.get("responses").is_some())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(operations.len(), 23);
|
||||
assert_eq!(operations.len(), 24);
|
||||
for operation in &operations {
|
||||
assert_non_empty(operation, "summary");
|
||||
assert_non_empty(operation, "description");
|
||||
|
||||
@@ -335,10 +335,13 @@ async fn fail_job(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transcription_error_class(message: &str) -> &'static str {
|
||||
if message.contains("连接失败") || message.contains("unavailable") {
|
||||
pub(crate) fn transcription_error_class(message: &str) -> &'static str {
|
||||
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"
|
||||
} else if message.contains("HTTP") {
|
||||
} else if normalized.contains("http") {
|
||||
"asr_http_error"
|
||||
} else if message.contains("读取录音") {
|
||||
"audio_read_failed"
|
||||
@@ -379,3 +382,24 @@ async fn refresh_lesson(pool: &PgPool, lesson_id: Uuid) -> Result<(), sqlx::Erro
|
||||
.await?;
|
||||
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
103
tests/voice-state.test.js
Normal 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
|
||||
)
|
||||
})
|
||||
@@ -586,6 +586,13 @@ export async function retryVoiceSegment(segmentId: string): Promise<VoiceAudioSe
|
||||
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(
|
||||
sessionId: string,
|
||||
existingContent: string
|
||||
|
||||
Reference in New Issue
Block a user