fix(voice): clarify recording disposition
This commit is contained in:
@@ -102,7 +102,8 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,
|
|||||||
- 原生弹窗确认文案缩短为“重试转录”,符合最多 4 个字符的约束。
|
- 原生弹窗确认文案缩短为“重试转录”,符合最多 4 个字符的约束。
|
||||||
- 重试开始后立即锁定操作并显示处理中状态;请求结束后无论成功或失败都刷新活动会话并恢复轮询。
|
- 重试开始后立即锁定操作并显示处理中状态;请求结束后无论成功或失败都刷新活动会话并恢复轮询。
|
||||||
- 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。
|
- 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。
|
||||||
- 页面增加失败原因说明和“放弃并重新录制”;后端删除失败片段时会重算课堂状态,空课堂会一并删除。
|
- 页面增加失败原因说明和“放弃失败录音”;放弃操作不会自动开始录音,后端删除失败片段时会重算课堂状态,空课堂会一并删除。
|
||||||
|
- 语音记录区分“已加入输入框”“待生成反馈”和“已生成反馈”,避免把待汇总的长录音笼统显示为“已就绪”。
|
||||||
- API 增加 `transcription_retry_requested`、`feedback_generation_rejected` 和 `failed_transcription_discarded` 业务日志;转录 worker 将无清晰语音分类为 `no_clear_speech`。
|
- API 增加 `transcription_retry_requested`、`feedback_generation_rejected` 和 `failed_transcription_discarded` 业务日志;转录 worker 将无清晰语音分类为 `no_clear_speech`。
|
||||||
- 前端自动化测试覆盖失败优先级、重试状态流转、无清晰语音提示和 API 失败后的会话刷新;后端测试覆盖错误分类和 OpenAPI 路由完整性。
|
- 前端自动化测试覆盖失败优先级、重试状态流转、无清晰语音提示和 API 失败后的会话刷新;后端测试覆盖错误分类和 OpenAPI 路由完整性。
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { runWithSessionRefresh } from './voice-actions'
|
|||||||
import {
|
import {
|
||||||
getFailedVoiceSegments,
|
getFailedVoiceSegments,
|
||||||
getVoiceFailureHint,
|
getVoiceFailureHint,
|
||||||
|
getVoiceLessonStatusText,
|
||||||
getVoicePrimaryAction,
|
getVoicePrimaryAction,
|
||||||
getVoicePrimaryActionText
|
getVoicePrimaryActionText
|
||||||
} from './voice-state'
|
} from './voice-state'
|
||||||
@@ -267,6 +268,7 @@ Page({
|
|||||||
let suffix = '已就绪'
|
let suffix = '已就绪'
|
||||||
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
|
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
|
||||||
else if (session.processingSegmentCount > 0) suffix = '正在处理'
|
else if (session.processingSegmentCount > 0) suffix = '正在处理'
|
||||||
|
else if (session.needsGeneration) suffix = '待生成反馈'
|
||||||
const primaryAction = getVoicePrimaryAction(session)
|
const primaryAction = getVoicePrimaryAction(session)
|
||||||
this.setData({
|
this.setData({
|
||||||
voiceStatus: `${prefix} · ${suffix}`,
|
voiceStatus: `${prefix} · ${suffix}`,
|
||||||
@@ -668,8 +670,7 @@ Page({
|
|||||||
const session = this.data.session
|
const session = this.data.session
|
||||||
if (!session || session.lessonCount === 0) return
|
if (!session || session.lessonCount === 0) return
|
||||||
const lines = session.lessons.map((lesson) => {
|
const lines = session.lessons.map((lesson) => {
|
||||||
const status = lesson.status === 'ready' ? '已就绪' : lesson.status === 'failed' ? '转录失败' : '处理中'
|
return `第${lesson.sequence}节 · ${formatVoiceDuration(lesson.durationMs)} · ${getVoiceLessonStatusText(lesson)}`
|
||||||
return `第${lesson.sequence}节 · ${formatVoiceDuration(lesson.durationMs)} · ${status}`
|
|
||||||
})
|
})
|
||||||
const hasFailures = session.failedSegmentCount > 0
|
const hasFailures = session.failedSegmentCount > 0
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
@@ -726,11 +727,12 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
confirmDiscardFailedSegments(): Promise<boolean> {
|
confirmDiscardFailedSegments(): Promise<boolean> {
|
||||||
|
const failedSegmentCount = this.data.session?.failedSegmentCount || 0
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
title: '放弃失败录音',
|
title: '放弃失败录音',
|
||||||
content: '失败片段将被删除,并立即开始新的录音。此操作无法撤销。',
|
content: `将删除${failedSegmentCount}段失败录音,其余录音和反馈内容不受影响。此操作无法撤销。`,
|
||||||
confirmText: '放弃重录',
|
confirmText: '确认放弃',
|
||||||
confirmColor: '#d13f3a',
|
confirmColor: '#d13f3a',
|
||||||
success: (result) => resolve(result.confirm),
|
success: (result) => resolve(result.confirm),
|
||||||
fail: () => resolve(false)
|
fail: () => resolve(false)
|
||||||
@@ -738,14 +740,13 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async discardFailedAndRecord() {
|
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
|
||||||
if (!session) return
|
if (!session) return
|
||||||
const failedSegments = getFailedVoiceSegments(session)
|
const failedSegments = getFailedVoiceSegments(session)
|
||||||
if (failedSegments.length === 0 || !(await this.confirmDiscardFailedSegments())) return
|
if (failedSegments.length === 0 || !(await this.confirmDiscardFailedSegments())) return
|
||||||
|
|
||||||
let discarded = false
|
|
||||||
this.setData({
|
this.setData({
|
||||||
voiceBusy: true,
|
voiceBusy: true,
|
||||||
voiceStatus: `正在放弃${failedSegments.length}段失败录音`,
|
voiceStatus: `正在放弃${failedSegments.length}段失败录音`,
|
||||||
@@ -760,18 +761,17 @@ Page({
|
|||||||
},
|
},
|
||||||
async () => this.loadActiveSession()
|
async () => this.loadActiveSession()
|
||||||
)
|
)
|
||||||
discarded = true
|
|
||||||
logVoiceOperation('failed_transcription_discarded', {
|
logVoiceOperation('failed_transcription_discarded', {
|
||||||
feedbackSessionId: session.id,
|
feedbackSessionId: session.id,
|
||||||
segmentIds: failedSegments.map((segment) => segment.id)
|
segmentIds: failedSegments.map((segment) => segment.id)
|
||||||
})
|
})
|
||||||
|
wx.showToast({ title: '已放弃失败录音', icon: 'none' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showApiError(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() {
|
||||||
|
|||||||
@@ -61,8 +61,8 @@
|
|||||||
<button
|
<button
|
||||||
class="voice-discard-button"
|
class="voice-discard-button"
|
||||||
disabled="{{saving || generating || recording || voiceBusy}}"
|
disabled="{{saving || generating || recording || voiceBusy}}"
|
||||||
bindtap="discardFailedAndRecord"
|
bindtap="discardFailedSegments"
|
||||||
>放弃并重新录制</button>
|
>放弃失败录音</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FeedbackSession, VoiceAudioSegment } from '../../utils/types'
|
import type { FeedbackSession, VoiceAudioSegment, VoiceLesson } from '../../utils/types'
|
||||||
|
|
||||||
export type VoicePrimaryAction = 'save' | 'retry' | 'processing' | 'generate'
|
export type VoicePrimaryAction = 'save' | 'retry' | 'processing' | 'generate'
|
||||||
|
|
||||||
@@ -25,6 +25,15 @@ export function getFailedVoiceSegments(session: FeedbackSession): VoiceAudioSegm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVoiceLessonStatusText(lesson: VoiceLesson): string {
|
||||||
|
if (lesson.status === 'failed') return '转录失败'
|
||||||
|
if (lesson.status === 'recording') return '录音中'
|
||||||
|
if (lesson.status === 'processing') return '处理中'
|
||||||
|
if (lesson.appliedDirectly) return '已加入输入框'
|
||||||
|
if (lesson.includedInGeneration) return '已生成反馈'
|
||||||
|
return '待生成反馈'
|
||||||
|
}
|
||||||
|
|
||||||
export function getVoiceFailureHint(session: FeedbackSession): string {
|
export function getVoiceFailureHint(session: FeedbackSession): string {
|
||||||
const failedSegments = getFailedVoiceSegments(session)
|
const failedSegments = getFailedVoiceSegments(session)
|
||||||
const hasNoClearSpeech = failedSegments.some((segment) => {
|
const hasNoClearSpeech = failedSegments.some((segment) => {
|
||||||
@@ -33,7 +42,7 @@ export function getVoiceFailureHint(session: FeedbackSession): string {
|
|||||||
})
|
})
|
||||||
const count = session.failedSegmentCount
|
const count = session.failedSegmentCount
|
||||||
if (hasNoClearSpeech) {
|
if (hasNoClearSpeech) {
|
||||||
return `${count}段录音未识别到清晰语音,可能是录音过短、静音或环境噪声较大。可重试,或放弃后重新录制。`
|
return `${count}段录音未识别到清晰语音,可能是录音过短、静音或环境噪声较大。可重试,或放弃该失败录音。`
|
||||||
}
|
}
|
||||||
return `${count}段录音转录失败,可重试,或放弃后重新录制。`
|
return `${count}段录音转录失败,可重试,或放弃该失败录音。`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const test = require('node:test')
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
getVoiceFailureHint,
|
getVoiceFailureHint,
|
||||||
|
getVoiceLessonStatusText,
|
||||||
getVoicePrimaryAction,
|
getVoicePrimaryAction,
|
||||||
getVoicePrimaryActionText
|
getVoicePrimaryActionText
|
||||||
} = require('../.test-dist/pages/feedback/voice-state.js')
|
} = require('../.test-dist/pages/feedback/voice-state.js')
|
||||||
@@ -65,7 +66,19 @@ test('no clear speech failures provide an actionable explanation', () => {
|
|||||||
|
|
||||||
const hint = getVoiceFailureHint(value)
|
const hint = getVoiceFailureHint(value)
|
||||||
assert.match(hint, /录音过短、静音或环境噪声/)
|
assert.match(hint, /录音过短、静音或环境噪声/)
|
||||||
assert.match(hint, /放弃后重新录制/)
|
assert.match(hint, /放弃该失败录音/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ready lessons explain whether their transcript is visible or pending generation', () => {
|
||||||
|
const lesson = {
|
||||||
|
status: 'ready',
|
||||||
|
appliedDirectly: false,
|
||||||
|
includedInGeneration: false
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(getVoiceLessonStatusText(lesson), '待生成反馈')
|
||||||
|
assert.equal(getVoiceLessonStatusText({ ...lesson, appliedDirectly: true }), '已加入输入框')
|
||||||
|
assert.equal(getVoiceLessonStatusText({ ...lesson, includedInGeneration: true }), '已生成反馈')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('session refresh runs after a failed API action', async () => {
|
test('session refresh runs after a failed API action', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user