fix(voice): recover empty failed lessons
This commit is contained in:
@@ -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 错误都能在页面上获得可复制的请求编号。
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
lesson.status === 'failed'
|
||||||
|
&& await this.confirmDiscardEmptyVoiceLesson(lesson.sequence)
|
||||||
|
) await this.discardEmptyFailedVoiceLesson(lesson)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
failedSegments.length === 0
|
!(await this.confirmDiscardVoiceSegments(failedSegments.length, lesson.sequence))
|
||||||
|| !(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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 '录音中'
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user