feat(voice): add async selectable summaries
This commit is contained in:
@@ -20,7 +20,7 @@ cli open --project /Users/zhangheng/project/ai/miniprogram/teaching-feedback-ass
|
||||
|
||||
3. 在微信开发者工具内编译、预览和调试。
|
||||
|
||||
反馈生成页使用小程序原生录音能力。短录音会直接追加到反馈内容,长录音每 8 分钟自动切片,多节录音可以汇总为一次反馈。真机首次使用时需要允许麦克风权限;中文转录默认使用项目自带的本地 FunASR 服务,不要求腾讯云密钥。
|
||||
反馈生成页使用小程序原生录音能力。录音每 8 分钟自动切片,转写完成后作为可独立查看和复用的语音记录保留,不会自动写入 2000 字反馈输入框。用户可以全选或逐节选择已就绪记录,异步生成一份统一总结,预览确认后再替换输入框。真机首次使用时需要允许麦克风权限;中文转录默认使用项目自带的本地 FunASR 服务,不要求腾讯云密钥。
|
||||
|
||||
小程序默认连接生产服务 `https://feedback.shay7sev.site`。旧版本缓存的默认本地地址会自动迁移;如需调试本地 API,可在“我的”页面保存 `http://127.0.0.1:8080` 并检测服务状态。
|
||||
|
||||
@@ -66,7 +66,7 @@ curl http://127.0.0.1:10095/health
|
||||
## 小程序与后端
|
||||
|
||||
- 学生档案、档案预设和反馈记录均通过 `utils/api.ts` 访问 Rust API。
|
||||
- 反馈页只暴露一个语音入口;反馈批次、课节、录音片段和转录状态由后端自动维护。
|
||||
- 反馈页只暴露一个语音入口;反馈批次、课节、录音片段、转录状态和异步总结任务由后端自动维护。
|
||||
- 开发者工具模拟器和真机默认使用 `https://feedback.shay7sev.site`。
|
||||
- 微信公众平台需要将该 HTTPS 域名同时配置为 `request` 和 `uploadFile` 合法域名。
|
||||
- 小程序通过 `wx.login` 换取后端 Bearer 会话,普通请求和录音上传使用同一身份;部署配置和旧数据归属步骤见 [WECHAT_AUTH_PLAN.md](./WECHAT_AUTH_PLAN.md)。
|
||||
|
||||
@@ -98,7 +98,7 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,
|
||||
|
||||
## 实施结果
|
||||
|
||||
- 主操作按 `failed -> processing -> generate -> save` 排序;存在失败片段时显示“重试转录(数量)”,不会调用生成接口。
|
||||
- 主操作按 `failed -> processing -> save` 排序;总结生成改为语音记录区内的独立操作,存在失败片段时仍优先显示“重试转录(数量)”。
|
||||
- 原生弹窗确认文案缩短为“重试转录”,符合最多 4 个字符的约束。
|
||||
- 重试开始后立即锁定操作并显示处理中状态;请求结束后无论成功或失败都刷新活动会话并恢复轮询。
|
||||
- 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。
|
||||
@@ -108,13 +108,19 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,
|
||||
- 反馈输入框支持确认后清空,语音记录仍保留;单节内容加入后若超过 2000 字会整体拒绝并提示,不再截断后写入。
|
||||
- API 增加 `transcription_retry_requested`、`feedback_generation_rejected` 和 `failed_transcription_discarded` 业务日志;转录 worker 将无清晰语音分类为 `no_clear_speech`。
|
||||
- 前端自动化测试覆盖失败优先级、重试状态流转、无清晰语音提示和 API 失败后的会话刷新;后端测试覆盖错误分类和 OpenAPI 路由完整性。
|
||||
- 已就绪录音默认全部参与总结,同时支持全选、取消全选和逐节选择;失败或处理中的录音不会进入可选集合,也不会阻止其他已就绪录音生成总结。
|
||||
- 总结改为持久化异步任务,页面轮询展示 `queued`、`processing`、`ready`、`failed` 和 `applied` 状态;生成完成先预览,确认后才替换输入框。
|
||||
- AI 模式内部按片段、单次录音、最终正文分层压缩,最终输出是一份不按录音次数或固定栏目分栏的连贯反馈;未配置 AI 时使用本地抽取式兜底。
|
||||
- 原始转录可远超 2000 字并持续保留;仅最终总结和反馈输入框限制为 2000 字。折叠箭头已由字体字符改为 CSS 矢量形状。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 存在失败片段时,主按钮不再显示“生成反馈”。
|
||||
- 点击“重试转录”后,API 日志中能查询到对应 `/retry` 请求和业务事件。
|
||||
- 页面在重试期间显示明确的处理中状态,且不会重复提交生成请求。
|
||||
- 重试成功后自动恢复“生成反馈”;再次失败时显示可理解的失败原因。
|
||||
- 重试成功后,该节录音自动进入可选总结集合;再次失败时显示可理解的失败原因。
|
||||
- 所有已就绪录音默认被选中,可逐节取消或一键全选;总结完成后必须先显示预览,不能自动覆盖输入框。
|
||||
- 两万字级原始转录不写入输入框;配置 AI 后由分层总结压缩为不超过 2000 字的一份正文。
|
||||
- 所有 API 错误都能在页面上获得可复制的请求编号。
|
||||
- 自动化测试覆盖失败片段优先级、重试状态流转和生成失败后的会话刷新。
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {
|
||||
applyFeedbackSummaryRun,
|
||||
copyApiRequestId,
|
||||
createFeedbackRecord,
|
||||
createFeedbackSummaryRun,
|
||||
createVoiceLesson,
|
||||
discardVoiceSegment,
|
||||
ensureFeedbackSession,
|
||||
finishVoiceLesson,
|
||||
generateFeedbackFromVoice,
|
||||
getActiveFeedbackSession,
|
||||
getApiErrorMessage,
|
||||
getApiRequestId,
|
||||
getFeedbackSummaryRun,
|
||||
listProfiles,
|
||||
markVoiceLessonApplied,
|
||||
retryVoiceSegment,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
import type {
|
||||
FeedbackDraft,
|
||||
FeedbackSession,
|
||||
FeedbackSummaryRun,
|
||||
StudentProfile,
|
||||
VoiceLesson,
|
||||
VoiceLessonFinished
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
|
||||
type PickerEvent = { detail: { value: string | number } }
|
||||
type InputEvent = { detail: { value: string } }
|
||||
type CheckboxGroupEvent = { detail: { value: string[] } }
|
||||
type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } }
|
||||
|
||||
interface PendingVoiceUpload {
|
||||
@@ -51,6 +55,8 @@ interface VoiceLessonView {
|
||||
transcriptLength: number
|
||||
transcriptExpandable: boolean
|
||||
canAdd: boolean
|
||||
summaryEligible: boolean
|
||||
summarySelected: boolean
|
||||
addActionText: string
|
||||
emptyText: string
|
||||
}
|
||||
@@ -58,13 +64,17 @@ interface VoiceLessonView {
|
||||
const MAX_CONTENT_LENGTH = 2000
|
||||
const RECORDING_SEGMENT_DURATION_MS = 8 * 60 * 1000
|
||||
const VOICE_POLL_INTERVAL_MS = 3000
|
||||
const SUMMARY_POLL_INTERVAL_MS = 2000
|
||||
const PENDING_UPLOADS_KEY = 'teaching-feedback-pending-voice-uploads-v1'
|
||||
|
||||
const recorderManager = wx.getRecorderManager()
|
||||
let recordingTimer: number | null = null
|
||||
let draftSaveTimer: number | null = null
|
||||
let draftSaveQueue: Promise<void> = Promise.resolve()
|
||||
let voicePollTimer: number | null = null
|
||||
let summaryPollTimer: number | null = null
|
||||
let voicePollBusy = false
|
||||
let summaryPollBusy = false
|
||||
let pageVisible = false
|
||||
let lessonStartedAt = 0
|
||||
let segmentStartedAt = 0
|
||||
@@ -121,11 +131,17 @@ function wait(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function buildVoiceLessonViews(session: FeedbackSession | null, content: string): VoiceLessonView[] {
|
||||
function buildVoiceLessonViews(
|
||||
session: FeedbackSession | null,
|
||||
content: string,
|
||||
excludedSummaryLessonIds: string[] = []
|
||||
): VoiceLessonView[] {
|
||||
if (!session) return []
|
||||
const excluded = new Set(excludedSummaryLessonIds)
|
||||
return session.lessons.map((lesson: VoiceLesson) => {
|
||||
const transcript = lesson.transcript.trim()
|
||||
const isInDraft = Boolean(transcript) && content.includes(transcript)
|
||||
const summaryEligible = lesson.status === 'ready' && Boolean(transcript)
|
||||
let emptyText = '转录完成后可查看内容'
|
||||
if (lesson.status === 'failed') {
|
||||
emptyText = lesson.segments.find((segment) => segment.status === 'failed')?.errorMessage
|
||||
@@ -142,6 +158,8 @@ function buildVoiceLessonViews(session: FeedbackSession | null, content: string)
|
||||
transcriptLength: transcript.length,
|
||||
transcriptExpandable: transcript.length > 120,
|
||||
canAdd: lesson.status === 'ready' && Boolean(transcript) && !isInDraft,
|
||||
summaryEligible,
|
||||
summarySelected: summaryEligible && !excluded.has(lesson.id),
|
||||
addActionText: isInDraft
|
||||
? '已在输入框'
|
||||
: lesson.appliedDirectly
|
||||
@@ -156,6 +174,19 @@ function logVoiceOperation(event: string, fields: Record<string, unknown>): void
|
||||
console.info('voice_operation', { event, ...fields })
|
||||
}
|
||||
|
||||
function createSummaryIdempotencyKey(sessionId: string): string {
|
||||
return `${sessionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function getSummaryStatusText(run: FeedbackSummaryRun | null): string {
|
||||
if (!run) return ''
|
||||
if (run.status === 'queued') return '已提交,等待生成'
|
||||
if (run.status === 'processing') return '正在整理所选录音'
|
||||
if (run.status === 'ready') return `总结已生成 · ${run.characterCount || 0}字`
|
||||
if (run.status === 'applied') return `已替换输入框 · ${run.characterCount || 0}字`
|
||||
return run.errorMessage || '总结生成失败,请重新生成'
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
profiles: [] as StudentProfile[],
|
||||
@@ -166,7 +197,14 @@ Page({
|
||||
contentLength: 0,
|
||||
loading: false,
|
||||
saving: false,
|
||||
generating: false,
|
||||
summaryBusy: false,
|
||||
summaryRun: null as FeedbackSummaryRun | null,
|
||||
summaryStatusText: '',
|
||||
summaryActionText: '生成总结',
|
||||
excludedSummaryLessonIds: [] as string[],
|
||||
selectedSummaryLessonCount: 0,
|
||||
eligibleSummaryLessonCount: 0,
|
||||
allSummaryLessonsSelected: false,
|
||||
voiceBusy: false,
|
||||
recording: false,
|
||||
recordingPaused: false,
|
||||
@@ -200,6 +238,7 @@ Page({
|
||||
onHide() {
|
||||
pageVisible = false
|
||||
this.clearVoicePollTimer()
|
||||
this.clearSummaryPollTimer()
|
||||
if (this.data.recording) this.stopRecording()
|
||||
},
|
||||
|
||||
@@ -208,6 +247,7 @@ Page({
|
||||
this.clearRecordingTimer()
|
||||
this.clearDraftSaveTimer()
|
||||
this.clearVoicePollTimer()
|
||||
this.clearSummaryPollTimer()
|
||||
if (this.data.recording) recorderManager.stop()
|
||||
},
|
||||
|
||||
@@ -277,11 +317,16 @@ Page({
|
||||
async loadActiveSession(profileId?: string | null) {
|
||||
try {
|
||||
const targetProfileId = profileId === undefined ? this.data.draft.profileId : profileId
|
||||
const previousSessionId = this.data.session?.id || null
|
||||
const session = await getActiveFeedbackSession(targetProfileId)
|
||||
if (targetProfileId !== this.data.draft.profileId) return
|
||||
const nextData: Record<string, unknown> = {
|
||||
session,
|
||||
'draft.feedbackSessionId': session?.id || null
|
||||
'draft.feedbackSessionId': session?.id || null,
|
||||
summaryRun: session?.latestSummaryRun || null
|
||||
}
|
||||
if ((session?.id || null) !== previousSessionId) {
|
||||
nextData.excludedSummaryLessonIds = []
|
||||
}
|
||||
if (session) {
|
||||
nextData['draft.feedbackDate'] = session.feedbackDate
|
||||
@@ -290,6 +335,7 @@ Page({
|
||||
}
|
||||
this.setData(nextData)
|
||||
this.syncVoicePresentation(session)
|
||||
this.syncSummaryRun(session?.latestSummaryRun || null)
|
||||
this.scheduleVoicePolling(session)
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
|
||||
@@ -310,6 +356,9 @@ Page({
|
||||
voiceDetailsExpanded: false,
|
||||
expandedVoiceLessonId: '',
|
||||
voiceLessonViews: [],
|
||||
selectedSummaryLessonCount: 0,
|
||||
eligibleSummaryLessonCount: 0,
|
||||
allSummaryLessonsSelected: false,
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
return
|
||||
@@ -319,17 +368,39 @@ Page({
|
||||
let suffix = '已就绪'
|
||||
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
|
||||
else if (session.processingSegmentCount > 0) suffix = '正在处理'
|
||||
else if (session.needsGeneration) suffix = '待生成反馈'
|
||||
else if (session.needsGeneration) suffix = '可生成总结'
|
||||
const primaryAction = getVoicePrimaryAction(session)
|
||||
const voiceLessonViews = buildVoiceLessonViews(
|
||||
session,
|
||||
this.data.draft.content,
|
||||
this.data.excludedSummaryLessonIds
|
||||
)
|
||||
const eligibleSummaryLessonCount = voiceLessonViews.filter((lesson) => lesson.summaryEligible).length
|
||||
const selectedSummaryLessonCount = voiceLessonViews.filter((lesson) => lesson.summarySelected).length
|
||||
this.setData({
|
||||
voiceStatus: `${prefix} · ${suffix}`,
|
||||
voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '',
|
||||
voiceActionable: true,
|
||||
voiceLessonViews: buildVoiceLessonViews(session, this.data.draft.content),
|
||||
voiceLessonViews,
|
||||
eligibleSummaryLessonCount,
|
||||
selectedSummaryLessonCount,
|
||||
allSummaryLessonsSelected: eligibleSummaryLessonCount > 0
|
||||
&& selectedSummaryLessonCount === eligibleSummaryLessonCount,
|
||||
primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount)
|
||||
})
|
||||
},
|
||||
|
||||
syncSummaryRun(run: FeedbackSummaryRun | null) {
|
||||
this.setData({
|
||||
summaryRun: run,
|
||||
summaryStatusText: getSummaryStatusText(run),
|
||||
summaryActionText: run && ['ready', 'failed', 'applied'].includes(run.status)
|
||||
? '重新生成总结'
|
||||
: '生成总结'
|
||||
})
|
||||
this.scheduleSummaryPolling(run)
|
||||
},
|
||||
|
||||
showApiError(error: unknown, showToast = true) {
|
||||
const errorMessage = getApiErrorMessage(error)
|
||||
const errorRequestId = getApiRequestId(error)
|
||||
@@ -352,6 +423,42 @@ Page({
|
||||
voicePollTimer = null
|
||||
},
|
||||
|
||||
scheduleSummaryPolling(run: FeedbackSummaryRun | null) {
|
||||
this.clearSummaryPollTimer()
|
||||
if (!pageVisible || !run || (run.status !== 'queued' && run.status !== 'processing')) return
|
||||
summaryPollTimer = setTimeout(() => {
|
||||
summaryPollTimer = null
|
||||
void this.pollFeedbackSummary()
|
||||
}, SUMMARY_POLL_INTERVAL_MS) as unknown as number
|
||||
},
|
||||
|
||||
clearSummaryPollTimer() {
|
||||
if (summaryPollTimer === null) return
|
||||
clearTimeout(summaryPollTimer)
|
||||
summaryPollTimer = null
|
||||
},
|
||||
|
||||
async pollFeedbackSummary() {
|
||||
const run = this.data.summaryRun
|
||||
if (!pageVisible || !run) return
|
||||
if (summaryPollBusy) {
|
||||
this.scheduleSummaryPolling(run)
|
||||
return
|
||||
}
|
||||
summaryPollBusy = true
|
||||
try {
|
||||
const updated = await getFeedbackSummaryRun(run.id)
|
||||
if (!pageVisible || this.data.summaryRun?.id !== run.id) return
|
||||
this.syncSummaryRun(updated)
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh feedback summary status', error)
|
||||
this.showApiError(error, false)
|
||||
this.scheduleSummaryPolling(this.data.summaryRun)
|
||||
} finally {
|
||||
summaryPollBusy = false
|
||||
}
|
||||
},
|
||||
|
||||
async pollVoiceSession() {
|
||||
if (!pageVisible) return
|
||||
if (voicePollBusy || this.data.recording || this.data.voiceBusy) {
|
||||
@@ -363,8 +470,13 @@ Page({
|
||||
try {
|
||||
const session = await getActiveFeedbackSession(profileId)
|
||||
if (!pageVisible || profileId !== this.data.draft.profileId) return
|
||||
this.setData({ session, 'draft.feedbackSessionId': session?.id || null })
|
||||
this.setData({
|
||||
session,
|
||||
'draft.feedbackSessionId': session?.id || null,
|
||||
summaryRun: session?.latestSummaryRun || this.data.summaryRun
|
||||
})
|
||||
this.syncVoicePresentation(session)
|
||||
if (session?.latestSummaryRun) this.syncSummaryRun(session.latestSummaryRun)
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh voice transcription status', error)
|
||||
this.showApiError(error, false)
|
||||
@@ -384,6 +496,7 @@ Page({
|
||||
|
||||
onProfileChange(event: PickerEvent) {
|
||||
this.clearDraftSaveTimer()
|
||||
this.clearSummaryPollTimer()
|
||||
const selectedProfileIndex = Number(event.detail.value)
|
||||
const profile = this.data.profiles[selectedProfileIndex - 1]
|
||||
const profileId = profile?.id || null
|
||||
@@ -398,6 +511,13 @@ Page({
|
||||
voiceDetailsExpanded: false,
|
||||
expandedVoiceLessonId: '',
|
||||
voiceLessonViews: [],
|
||||
excludedSummaryLessonIds: [],
|
||||
selectedSummaryLessonCount: 0,
|
||||
eligibleSummaryLessonCount: 0,
|
||||
allSummaryLessonsSelected: false,
|
||||
summaryRun: null,
|
||||
summaryStatusText: '',
|
||||
summaryActionText: '生成总结',
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
void this.loadActiveSession(profileId)
|
||||
@@ -413,7 +533,11 @@ Page({
|
||||
this.setData({
|
||||
'draft.content': content,
|
||||
contentLength: content.length,
|
||||
voiceLessonViews: buildVoiceLessonViews(this.data.session, content)
|
||||
voiceLessonViews: buildVoiceLessonViews(
|
||||
this.data.session,
|
||||
content,
|
||||
this.data.excludedSummaryLessonIds
|
||||
)
|
||||
})
|
||||
this.scheduleDraftSave()
|
||||
},
|
||||
@@ -437,7 +561,11 @@ Page({
|
||||
this.setData({
|
||||
'draft.content': '',
|
||||
contentLength: 0,
|
||||
voiceLessonViews: buildVoiceLessonViews(this.data.session, '')
|
||||
voiceLessonViews: buildVoiceLessonViews(
|
||||
this.data.session,
|
||||
'',
|
||||
this.data.excludedSummaryLessonIds
|
||||
)
|
||||
})
|
||||
await this.persistSessionDraft()
|
||||
},
|
||||
@@ -456,23 +584,26 @@ Page({
|
||||
draftSaveTimer = null
|
||||
},
|
||||
|
||||
async persistSessionDraft() {
|
||||
persistSessionDraft(): Promise<void> {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
if (!session) return Promise.resolve()
|
||||
const sessionId = session.id
|
||||
try {
|
||||
const updated = await updateFeedbackSession(
|
||||
sessionId,
|
||||
this.data.draft.feedbackDate,
|
||||
this.data.draft.content
|
||||
)
|
||||
if (this.data.session?.id !== sessionId) return
|
||||
this.setData({ session: updated })
|
||||
this.syncVoicePresentation(updated)
|
||||
} catch (error) {
|
||||
console.warn('Failed to auto-save feedback session', error)
|
||||
this.showApiError(error, false)
|
||||
const feedbackDate = this.data.draft.feedbackDate
|
||||
const content = this.data.draft.content
|
||||
const save = async () => {
|
||||
try {
|
||||
const updated = await updateFeedbackSession(sessionId, feedbackDate, content)
|
||||
if (this.data.session?.id !== sessionId) return
|
||||
this.setData({ session: updated, summaryRun: updated.latestSummaryRun || this.data.summaryRun })
|
||||
this.syncVoicePresentation(updated)
|
||||
if (updated.latestSummaryRun) this.syncSummaryRun(updated.latestSummaryRun)
|
||||
} catch (error) {
|
||||
console.warn('Failed to auto-save feedback session', error)
|
||||
this.showApiError(error, false)
|
||||
}
|
||||
}
|
||||
draftSaveQueue = draftSaveQueue.then(save, save)
|
||||
return draftSaveQueue
|
||||
},
|
||||
|
||||
ensureRecordPermission(): Promise<boolean> {
|
||||
@@ -777,6 +908,99 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
onSummarySelectionChange(event: CheckboxGroupEvent) {
|
||||
const selectedLessonIds = new Set(event.detail.value)
|
||||
const excludedSummaryLessonIds = this.data.voiceLessonViews
|
||||
.filter((lesson) => lesson.summaryEligible && !selectedLessonIds.has(lesson.id))
|
||||
.map((lesson) => lesson.id)
|
||||
this.setData({ excludedSummaryLessonIds })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
},
|
||||
|
||||
toggleAllSummaryLessons() {
|
||||
if (this.data.eligibleSummaryLessonCount === 0) return
|
||||
const excludedSummaryLessonIds = this.data.allSummaryLessonsSelected
|
||||
? this.data.voiceLessonViews
|
||||
.filter((lesson) => lesson.summaryEligible)
|
||||
.map((lesson) => lesson.id)
|
||||
: []
|
||||
this.setData({ excludedSummaryLessonIds })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
},
|
||||
|
||||
async generateFeedbackSummary() {
|
||||
const session = this.data.session
|
||||
if (!session || this.data.summaryBusy || this.data.recording || this.data.voiceBusy) return
|
||||
if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') {
|
||||
wx.showToast({ title: '当前总结仍在生成', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const lessonIds = this.data.voiceLessonViews
|
||||
.filter((lesson) => lesson.summarySelected)
|
||||
.map((lesson) => lesson.id)
|
||||
if (lessonIds.length === 0) {
|
||||
wx.showToast({ title: '请至少选择一节已就绪录音', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({ summaryBusy: true, errorMessage: '', errorRequestId: '' })
|
||||
try {
|
||||
const run = await createFeedbackSummaryRun(
|
||||
session.id,
|
||||
lessonIds,
|
||||
this.data.draft.content,
|
||||
createSummaryIdempotencyKey(session.id)
|
||||
)
|
||||
logVoiceOperation('feedback_summary_queued', {
|
||||
feedbackSessionId: session.id,
|
||||
summaryRunId: run.id,
|
||||
lessonIds
|
||||
})
|
||||
this.syncSummaryRun(run)
|
||||
wx.showToast({ title: '已开始生成总结', icon: 'none' })
|
||||
} catch (error) {
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ summaryBusy: false })
|
||||
}
|
||||
},
|
||||
|
||||
confirmApplySummary(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
wx.showModal({
|
||||
title: '替换反馈内容',
|
||||
content: '将使用预览总结替换当前输入框内容,原始语音记录仍会保留。',
|
||||
confirmText: '确认替换',
|
||||
success: (result) => resolve(result.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async applyGeneratedSummary() {
|
||||
const run = this.data.summaryRun
|
||||
if (!run || run.status !== 'ready' || !(await this.confirmApplySummary())) return
|
||||
this.clearDraftSaveTimer()
|
||||
this.setData({ summaryBusy: true, errorMessage: '', errorRequestId: '' })
|
||||
try {
|
||||
await this.persistSessionDraft()
|
||||
const session = await applyFeedbackSummaryRun(run.id)
|
||||
this.setData({
|
||||
session,
|
||||
summaryRun: session.latestSummaryRun,
|
||||
'draft.content': session.content,
|
||||
contentLength: session.content.length
|
||||
})
|
||||
this.syncVoicePresentation(session)
|
||||
this.syncSummaryRun(session.latestSummaryRun)
|
||||
wx.showToast({ title: '总结已替换输入框', icon: 'none' })
|
||||
} catch (error) {
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ summaryBusy: false })
|
||||
}
|
||||
},
|
||||
|
||||
async retryFailedSegments() {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
@@ -878,8 +1102,9 @@ Page({
|
||||
this.scheduleVoicePolling(session)
|
||||
return
|
||||
}
|
||||
if (session?.needsGeneration) {
|
||||
await this.generateFeedback()
|
||||
if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') {
|
||||
wx.showToast({ title: '总结仍在生成,请稍候', icon: 'none' })
|
||||
this.scheduleSummaryPolling(this.data.summaryRun)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -896,6 +1121,7 @@ Page({
|
||||
content,
|
||||
feedbackSessionId: session?.id || null
|
||||
})
|
||||
this.clearSummaryPollTimer()
|
||||
this.setData({
|
||||
draft: buildDraft(),
|
||||
session: null,
|
||||
@@ -903,6 +1129,15 @@ Page({
|
||||
contentLength: 0,
|
||||
voiceStatus: '语音录入',
|
||||
voiceActionable: false,
|
||||
voiceDetailsExpanded: false,
|
||||
voiceLessonViews: [],
|
||||
excludedSummaryLessonIds: [],
|
||||
selectedSummaryLessonCount: 0,
|
||||
eligibleSummaryLessonCount: 0,
|
||||
allSummaryLessonsSelected: false,
|
||||
summaryRun: null,
|
||||
summaryStatusText: '',
|
||||
summaryActionText: '生成总结',
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
wx.showToast({ title: '反馈已保存', icon: 'success' })
|
||||
@@ -914,34 +1149,6 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
async generateFeedback() {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
this.setData({
|
||||
generating: true,
|
||||
voiceBusy: true,
|
||||
voiceStatus: '正在生成反馈',
|
||||
errorMessage: '',
|
||||
errorRequestId: ''
|
||||
})
|
||||
try {
|
||||
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
|
||||
})
|
||||
wx.showToast({ title: '反馈已生成,可继续修改', icon: 'none' })
|
||||
} catch (error) {
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ generating: false, voiceBusy: false })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
}
|
||||
},
|
||||
|
||||
goStudents() {
|
||||
wx.switchTab({ url: '/pages/students/index' })
|
||||
}
|
||||
|
||||
@@ -62,60 +62,103 @@
|
||||
<view class="voice-state {{voiceActionable ? 'voice-state-actionable' : ''}}" bindtap="toggleVoiceDetails">
|
||||
<text class="voice-status">{{voiceStatus}}</text>
|
||||
<text wx:if="{{recording}}" class="recording-time">{{recordingTime}}</text>
|
||||
<text wx:elif="{{voiceActionable}}" class="voice-state-arrow">{{voiceDetailsExpanded ? '⌃' : '⌄'}}</text>
|
||||
<view wx:elif="{{voiceActionable}}" class="voice-state-arrow {{voiceDetailsExpanded ? 'voice-state-arrow-expanded' : ''}}"></view>
|
||||
</view>
|
||||
<view wx:if="{{voiceDetailsExpanded}}" class="voice-record-list">
|
||||
<view class="voice-record-list-heading">
|
||||
<text>语音记录</text>
|
||||
<text>{{voiceLessonViews.length}}节</text>
|
||||
</view>
|
||||
<view
|
||||
wx:for="{{voiceLessonViews}}"
|
||||
wx:key="id"
|
||||
wx:for-item="lesson"
|
||||
class="voice-record-item"
|
||||
>
|
||||
<view class="voice-record-header">
|
||||
<view>
|
||||
<text class="voice-record-title">第{{lesson.sequence}}节</text>
|
||||
<text class="voice-record-duration">{{lesson.durationText}} · {{lesson.transcriptLength}}字</text>
|
||||
</view>
|
||||
<text class="voice-record-status">{{lesson.statusText}}</text>
|
||||
</view>
|
||||
<text
|
||||
wx:if="{{lesson.transcript}}"
|
||||
selectable
|
||||
class="voice-record-transcript {{expandedVoiceLessonId === lesson.id ? 'voice-record-transcript-expanded' : ''}}"
|
||||
>{{lesson.transcript}}</text>
|
||||
<text wx:else class="voice-record-empty">{{lesson.emptyText}}</text>
|
||||
<view wx:if="{{lesson.transcript}}" class="voice-record-actions">
|
||||
<view class="voice-summary-selection-heading">
|
||||
<text>已选 {{selectedSummaryLessonCount}} / {{eligibleSummaryLessonCount}}</text>
|
||||
<button
|
||||
wx:if="{{lesson.transcriptExpandable}}"
|
||||
wx:if="{{eligibleSummaryLessonCount > 0}}"
|
||||
class="voice-record-text-button"
|
||||
data-lesson-id="{{lesson.id}}"
|
||||
bindtap="toggleVoiceLessonTranscript"
|
||||
>{{expandedVoiceLessonId === lesson.id ? '收起' : '展开'}}</button>
|
||||
<button
|
||||
class="voice-record-add-button"
|
||||
data-lesson-id="{{lesson.id}}"
|
||||
disabled="{{!lesson.canAdd || voiceBusy}}"
|
||||
bindtap="addVoiceLessonToDraft"
|
||||
>{{lesson.addActionText}}</button>
|
||||
disabled="{{recording || voiceBusy || summaryBusy || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing'))}}"
|
||||
bindtap="toggleAllSummaryLessons"
|
||||
>{{allSummaryLessonsSelected ? '取消全选' : '全选'}}</button>
|
||||
</view>
|
||||
</view>
|
||||
<checkbox-group bindchange="onSummarySelectionChange">
|
||||
<view
|
||||
wx:for="{{voiceLessonViews}}"
|
||||
wx:key="id"
|
||||
wx:for-item="lesson"
|
||||
class="voice-record-item"
|
||||
>
|
||||
<view class="voice-record-header">
|
||||
<view class="voice-record-title-row">
|
||||
<checkbox
|
||||
wx:if="{{lesson.summaryEligible}}"
|
||||
class="voice-summary-checkbox"
|
||||
value="{{lesson.id}}"
|
||||
checked="{{lesson.summarySelected}}"
|
||||
disabled="{{recording || voiceBusy || summaryBusy || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing'))}}"
|
||||
color="#007aff"
|
||||
/>
|
||||
<view>
|
||||
<text class="voice-record-title">第{{lesson.sequence}}节</text>
|
||||
<text class="voice-record-duration">{{lesson.durationText}} · {{lesson.transcriptLength}}字</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="voice-record-status">{{lesson.statusText}}</text>
|
||||
</view>
|
||||
<text
|
||||
wx:if="{{lesson.transcript}}"
|
||||
selectable
|
||||
class="voice-record-transcript {{expandedVoiceLessonId === lesson.id ? 'voice-record-transcript-expanded' : ''}}"
|
||||
>{{lesson.transcript}}</text>
|
||||
<text wx:else class="voice-record-empty">{{lesson.emptyText}}</text>
|
||||
<view wx:if="{{lesson.transcript}}" class="voice-record-actions">
|
||||
<button
|
||||
wx:if="{{lesson.transcriptExpandable}}"
|
||||
class="voice-record-text-button"
|
||||
data-lesson-id="{{lesson.id}}"
|
||||
bindtap="toggleVoiceLessonTranscript"
|
||||
>{{expandedVoiceLessonId === lesson.id ? '收起' : '展开'}}</button>
|
||||
<button
|
||||
class="voice-record-add-button"
|
||||
data-lesson-id="{{lesson.id}}"
|
||||
disabled="{{!lesson.canAdd || voiceBusy}}"
|
||||
bindtap="addVoiceLessonToDraft"
|
||||
>{{lesson.addActionText}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</checkbox-group>
|
||||
<view wx:if="{{eligibleSummaryLessonCount > 0}}" class="voice-summary-controls">
|
||||
<button
|
||||
class="voice-summary-button"
|
||||
loading="{{summaryBusy}}"
|
||||
disabled="{{recording || voiceBusy || summaryBusy || selectedSummaryLessonCount === 0 || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing'))}}"
|
||||
bindtap="generateFeedbackSummary"
|
||||
>{{summaryActionText}}({{selectedSummaryLessonCount}}节)</button>
|
||||
</view>
|
||||
<view wx:if="{{summaryRun}}" class="voice-summary-preview {{summaryRun.status === 'failed' ? 'voice-summary-preview-failed' : ''}}">
|
||||
<view class="voice-summary-preview-heading">
|
||||
<text>总结预览</text>
|
||||
<text>{{summaryStatusText}}</text>
|
||||
</view>
|
||||
<text wx:if="{{summaryRun.content}}" selectable class="voice-summary-preview-content">{{summaryRun.content}}</text>
|
||||
<text wx:elif="{{summaryRun.status === 'queued' || summaryRun.status === 'processing'}}" class="voice-summary-preview-note">已选择 {{summaryRun.lessonIds.length}} 节录音</text>
|
||||
<button
|
||||
wx:if="{{summaryRun.status === 'ready'}}"
|
||||
class="voice-summary-apply-button"
|
||||
loading="{{summaryBusy}}"
|
||||
disabled="{{summaryBusy}}"
|
||||
bindtap="applyGeneratedSummary"
|
||||
>替换输入框</button>
|
||||
</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}}"
|
||||
disabled="{{saving || summaryBusy || recording || voiceBusy}}"
|
||||
bindtap="discardFailedSegments"
|
||||
>放弃失败录音</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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>
|
||||
<button class="save-feedback primary-button" loading="{{saving}}" disabled="{{saving || summaryBusy || recording || voiceBusy || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing')) || (session && session.processingSegmentCount > 0 && session.failedSegmentCount === 0)}}" bindtap="saveFeedback">{{primaryActionText}}</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!loading && profiles.length === 0}}" class="profiles-hint">
|
||||
|
||||
@@ -182,10 +182,18 @@
|
||||
|
||||
.voice-state-arrow {
|
||||
flex: none;
|
||||
margin-left: 10rpx;
|
||||
color: #8e8e93;
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
margin: -6rpx 6rpx 0 14rpx;
|
||||
border-right: 3rpx solid #8e8e93;
|
||||
border-bottom: 3rpx solid #8e8e93;
|
||||
transform: rotate(45deg);
|
||||
transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.voice-state-arrow-expanded {
|
||||
margin-top: 6rpx;
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
|
||||
.voice-record-list {
|
||||
@@ -210,6 +218,29 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.voice-summary-selection-heading,
|
||||
.voice-record-title-row,
|
||||
.voice-summary-preview-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.voice-summary-selection-heading {
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.voice-record-title-row {
|
||||
min-width: 0;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.voice-summary-checkbox {
|
||||
flex: none;
|
||||
width: 46rpx;
|
||||
transform: scale(0.8);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.voice-record-item {
|
||||
padding: 18rpx 0;
|
||||
border-top: 1rpx solid #e5e5ea;
|
||||
@@ -285,6 +316,89 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.voice-summary-controls {
|
||||
padding: 18rpx 0;
|
||||
border-top: 1rpx solid #e5e5ea;
|
||||
}
|
||||
|
||||
.voice-summary-button,
|
||||
.voice-summary-apply-button {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 8rpx;
|
||||
font-size: 25rpx;
|
||||
font-weight: 600;
|
||||
line-height: 72rpx;
|
||||
}
|
||||
|
||||
.voice-summary-button {
|
||||
background: #eaf3ff;
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.voice-summary-apply-button {
|
||||
margin-top: 18rpx;
|
||||
background: #007aff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.voice-summary-button::after,
|
||||
.voice-summary-apply-button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.voice-summary-button[disabled],
|
||||
.voice-summary-apply-button[disabled] {
|
||||
background: #f1f3f6;
|
||||
color: #aeaeb2;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.voice-summary-preview {
|
||||
padding: 18rpx 0;
|
||||
border-top: 1rpx solid #d8d8dc;
|
||||
}
|
||||
|
||||
.voice-summary-preview-failed {
|
||||
border-top-color: #e8b3b0;
|
||||
}
|
||||
|
||||
.voice-summary-preview-heading {
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
color: #1c1c1e;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.voice-summary-preview-heading text:last-child {
|
||||
color: #6e6e73;
|
||||
font-size: 22rpx;
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.voice-summary-preview-failed .voice-summary-preview-heading text:last-child {
|
||||
color: #b8322d;
|
||||
}
|
||||
|
||||
.voice-summary-preview-content,
|
||||
.voice-summary-preview-note {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
color: #3a3a3c;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.voice-summary-preview-note {
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.recording-time {
|
||||
flex: none;
|
||||
margin-left: 12rpx;
|
||||
@@ -351,7 +465,8 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.voice-button,
|
||||
.voice-state-actionable {
|
||||
.voice-state-actionable,
|
||||
.voice-state-arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { FeedbackSession, VoiceAudioSegment, VoiceLesson } from '../../utils/types'
|
||||
|
||||
export type VoicePrimaryAction = 'save' | 'retry' | 'processing' | 'generate'
|
||||
export type VoicePrimaryAction = 'save' | 'retry' | 'processing'
|
||||
|
||||
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'
|
||||
return 'save'
|
||||
}
|
||||
|
||||
export function getVoicePrimaryActionText(
|
||||
@@ -15,7 +15,6 @@ export function getVoicePrimaryActionText(
|
||||
): string {
|
||||
if (action === 'retry') return `重试转录(${failedSegmentCount})`
|
||||
if (action === 'processing') return '转录中'
|
||||
if (action === 'generate') return '生成反馈'
|
||||
return '保存反馈'
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ TENCENT_CLOUD_ASR_SECRET_KEY=your-secret-key
|
||||
TENCENT_CLOUD_ASR_ENGINE_TYPE=16k_zh
|
||||
```
|
||||
|
||||
反馈汇总支持可选的 OpenAI 兼容 Chat Completions 接口。未配置时使用本地抽取式汇总,录音转录和保存流程仍可工作:
|
||||
反馈汇总支持可选的 OpenAI 兼容 Chat Completions 接口。配置后,后台任务按“片段事实提取 -> 单次录音压缩 -> 全部所选录音统一改写”处理,最终只返回一份不按录音次数分栏的正文。未配置时使用本地抽取式汇总,录音转录和保存流程仍可工作:
|
||||
|
||||
```text
|
||||
FEEDBACK_SUMMARY_API_URL=https://api.example.com/v1/chat/completions
|
||||
@@ -103,7 +103,12 @@ Authorization: Bearer <access-token>
|
||||
| `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` | 汇总多节转录并生成反馈正文。 |
|
||||
| `POST` | `/api/v1/feedback-sessions/{session_id}/summary-runs` | 选择已就绪录音并创建异步总结任务。 |
|
||||
| `GET` | `/api/v1/feedback-summary-runs/{run_id}` | 查询总结状态和预览正文。 |
|
||||
| `POST` | `/api/v1/feedback-summary-runs/{run_id}/apply` | 确认后用预览正文替换反馈草稿。 |
|
||||
| `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 旧版同步汇总接口,仅保留客户端兼容。 |
|
||||
|
||||
总结任务使用请求中的 `idempotency_key` 防止重复点击创建重复任务。`queued` 和 `processing` 状态由 API 内的持久化 worker 自动恢复;生成完成进入 `ready`,此时尚未修改反馈草稿。只有调用 `/apply` 后才进入 `applied` 并替换草稿。原始转录不限制为 2000 字,只有最终反馈正文和输入框受 2000 字限制。
|
||||
|
||||
### 创建学生档案
|
||||
|
||||
|
||||
@@ -128,6 +128,8 @@ Bearer 会话不存在、过期或已撤销时返回 `401`,小程序会重新
|
||||
|
||||
失败片段可以通过 `/retry` 重新进入队列,也可以通过 `DELETE /api/v1/audio-segments/{segment_id}` 放弃。两个接口都只接受当前用户活动反馈批次中的 `failed` 片段,并通过行锁避免重试与删除并发执行。
|
||||
|
||||
已就绪录音的总结流程为:调用 `POST /api/v1/feedback-sessions/{session_id}/summary-runs` 并传入 `lesson_ids`、当前输入框内容和客户端幂等键;轮询 `GET /api/v1/feedback-summary-runs/{run_id}` 直到状态为 `ready`;检查 `content` 后再调用 `POST /api/v1/feedback-summary-runs/{run_id}/apply`。创建和查询不会修改输入框,只有最后的应用操作会替换草稿。
|
||||
|
||||
活动反馈批次中的每节语音记录会返回按片段顺序合并的 `transcript`。小程序将语音记录作为可复用素材保存,教师可以逐节查看并按需要加入反馈输入框;录音转写完成后不会自动改写教师正在编辑的正文。
|
||||
|
||||
## 6. OpenAPI 维护方式
|
||||
|
||||
55
server/migrations/0005_feedback_summary_runs.sql
Normal file
55
server/migrations/0005_feedback_summary_runs.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
CREATE TABLE feedback_summary_runs (
|
||||
id UUID PRIMARY KEY,
|
||||
feedback_session_id UUID NOT NULL REFERENCES feedback_sessions(id) ON DELETE CASCADE,
|
||||
idempotency_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'queued',
|
||||
existing_content TEXT NOT NULL DEFAULT '',
|
||||
source_hash VARCHAR(64),
|
||||
content TEXT,
|
||||
method VARCHAR(24),
|
||||
model TEXT,
|
||||
prompt_version VARCHAR(32) NOT NULL DEFAULT 'summary-v1',
|
||||
character_count INTEGER,
|
||||
error_class VARCHAR(64),
|
||||
error_message TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
processing_started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
applied_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT feedback_summary_runs_status_check
|
||||
CHECK (status IN ('queued', 'processing', 'ready', 'failed', 'applied')),
|
||||
CONSTRAINT feedback_summary_runs_idempotency_key_check
|
||||
CHECK (char_length(idempotency_key) BETWEEN 1 AND 100),
|
||||
CONSTRAINT feedback_summary_runs_existing_content_check
|
||||
CHECK (char_length(existing_content) <= 2000),
|
||||
CONSTRAINT feedback_summary_runs_content_check
|
||||
CHECK (content IS NULL OR char_length(content) <= 2000),
|
||||
CONSTRAINT feedback_summary_runs_character_count_check
|
||||
CHECK (character_count IS NULL OR character_count BETWEEN 0 AND 2000),
|
||||
CONSTRAINT feedback_summary_runs_attempts_check
|
||||
CHECK (attempts >= 0),
|
||||
CONSTRAINT feedback_summary_runs_result_check
|
||||
CHECK (
|
||||
(status IN ('ready', 'applied') AND content IS NOT NULL AND character_count IS NOT NULL AND completed_at IS NOT NULL)
|
||||
OR
|
||||
(status NOT IN ('ready', 'applied') AND content IS NULL AND character_count IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE feedback_summary_run_lessons (
|
||||
summary_run_id UUID NOT NULL REFERENCES feedback_summary_runs(id) ON DELETE CASCADE,
|
||||
lesson_session_id UUID NOT NULL REFERENCES lesson_sessions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (summary_run_id, lesson_session_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_feedback_summary_runs_session_created
|
||||
ON feedback_summary_runs(feedback_session_id, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_feedback_summary_runs_queue
|
||||
ON feedback_summary_runs(created_at, id)
|
||||
WHERE status IN ('queued', 'processing');
|
||||
|
||||
CREATE INDEX idx_feedback_summary_run_lessons_lesson
|
||||
ON feedback_summary_run_lessons(lesson_session_id);
|
||||
@@ -5,6 +5,7 @@ mod recording_routes;
|
||||
mod routes;
|
||||
mod speech;
|
||||
mod summary;
|
||||
mod summary_worker;
|
||||
mod transcription_worker;
|
||||
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
@@ -96,8 +97,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
if let Some(pool) = state.pool.clone() {
|
||||
if speech.configured() {
|
||||
transcription_worker::spawn(pool, speech.clone(), log_context.clone());
|
||||
transcription_worker::spawn(pool.clone(), speech.clone(), log_context.clone());
|
||||
}
|
||||
summary_worker::spawn(pool, state.summary.clone(), log_context.clone());
|
||||
}
|
||||
let app = build_app(
|
||||
state,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -36,6 +39,9 @@ pub fn router() -> OpenApiRouter<SharedState> {
|
||||
.routes(routes!(mark_lesson_applied))
|
||||
.routes(routes!(retry_audio_segment))
|
||||
.routes(routes!(discard_audio_segment))
|
||||
.routes(routes!(create_feedback_summary_run))
|
||||
.routes(routes!(get_feedback_summary_run))
|
||||
.routes(routes!(apply_feedback_summary_run))
|
||||
.routes(routes!(generate_feedback_content))
|
||||
}
|
||||
|
||||
@@ -130,6 +136,7 @@ struct FeedbackSessionResponse {
|
||||
failed_segment_count: usize,
|
||||
needs_generation: bool,
|
||||
lessons: Vec<LessonSummaryResponse>,
|
||||
latest_summary_run: Option<SummaryRunResponse>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -191,6 +198,49 @@ struct GenerateFeedbackResponse {
|
||||
method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct CreateSummaryRunInput {
|
||||
lesson_ids: Vec<Uuid>,
|
||||
#[serde(default)]
|
||||
existing_content: String,
|
||||
idempotency_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct SummaryRunRow {
|
||||
id: Uuid,
|
||||
feedback_session_id: Uuid,
|
||||
status: String,
|
||||
content: Option<String>,
|
||||
method: Option<String>,
|
||||
model: Option<String>,
|
||||
prompt_version: String,
|
||||
character_count: Option<i32>,
|
||||
error_message: Option<String>,
|
||||
attempts: i32,
|
||||
created_at: DateTime<Utc>,
|
||||
completed_at: Option<DateTime<Utc>>,
|
||||
applied_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct SummaryRunResponse {
|
||||
id: Uuid,
|
||||
feedback_session_id: Uuid,
|
||||
lesson_ids: Vec<Uuid>,
|
||||
status: String,
|
||||
content: Option<String>,
|
||||
method: Option<String>,
|
||||
model: Option<String>,
|
||||
prompt_version: String,
|
||||
character_count: Option<i32>,
|
||||
error_message: Option<String>,
|
||||
attempts: i32,
|
||||
created_at: DateTime<Utc>,
|
||||
completed_at: Option<DateTime<Utc>>,
|
||||
applied_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct AudioSegmentUpload {
|
||||
@@ -851,6 +901,252 @@ async fn discard_audio_segment(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/feedback-sessions/{session_id}/summary-runs",
|
||||
tag = "语音反馈",
|
||||
summary = "创建反馈总结任务",
|
||||
description = "选择一节或多节已完成转录的录音,异步生成一份统一反馈正文。重复的幂等键返回同一个任务。",
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("session_id" = Uuid, Path, description = "反馈批次 ID")
|
||||
),
|
||||
request_body(content = CreateSummaryRunInput, content_type = "application/json"),
|
||||
responses(
|
||||
(status = 202, description = "已创建或恢复的总结任务", body = SummaryRunResponse),
|
||||
(status = 400, description = "所选录音不可用于总结", body = ErrorBody),
|
||||
(status = 404, description = "反馈批次不存在或不属于当前用户", body = ErrorBody),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn create_feedback_summary_run(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(session_id): Path<Uuid>,
|
||||
Json(input): Json<CreateSummaryRunInput>,
|
||||
) -> Result<(StatusCode, Json<SummaryRunResponse>), ApiError> {
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let pool = pool(&state)?;
|
||||
assert_session_owner(pool, owner_id, session_id).await?;
|
||||
validate_draft_content(&input.existing_content)?;
|
||||
let idempotency_key = input.idempotency_key.trim();
|
||||
if idempotency_key.is_empty() || idempotency_key.chars().count() > 100 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"idempotency_key must contain 1 to 100 characters".to_owned(),
|
||||
));
|
||||
}
|
||||
if input.lesson_ids.is_empty() || input.lesson_ids.len() > 100 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"请选择 1 到 100 节已就绪的语音记录".to_owned(),
|
||||
));
|
||||
}
|
||||
let unique_lesson_ids = input.lesson_ids.iter().copied().collect::<HashSet<_>>();
|
||||
if unique_lesson_ids.len() != input.lesson_ids.len() {
|
||||
return Err(ApiError::BadRequest("所选语音记录不能重复".to_owned()));
|
||||
}
|
||||
|
||||
if let Some(existing) =
|
||||
load_summary_run_by_idempotency(pool, owner_id, session_id, idempotency_key).await?
|
||||
{
|
||||
return Ok((StatusCode::ACCEPTED, Json(existing)));
|
||||
}
|
||||
|
||||
let ready_lesson_ids = sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT l.id FROM lesson_sessions l \
|
||||
WHERE l.feedback_session_id = $1 AND l.id = ANY($2) AND l.status = 'ready' \
|
||||
AND EXISTS (\
|
||||
SELECT 1 FROM audio_segments s \
|
||||
WHERE s.lesson_session_id = l.id AND s.status = 'ready' \
|
||||
AND s.transcript IS NOT NULL AND char_length(btrim(s.transcript)) > 0\
|
||||
)",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(&input.lesson_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let ready_lesson_ids = ready_lesson_ids.into_iter().collect::<HashSet<_>>();
|
||||
if ready_lesson_ids != unique_lesson_ids {
|
||||
return Err(ApiError::BadRequest(
|
||||
"所选语音记录中有内容尚未转录完成,请刷新后重新选择".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let run_id = Uuid::new_v4();
|
||||
let mut transaction = pool.begin().await?;
|
||||
let inserted_id = sqlx::query_scalar::<_, Uuid>(
|
||||
"INSERT INTO feedback_summary_runs (\
|
||||
id, feedback_session_id, idempotency_key, existing_content, prompt_version\
|
||||
) VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (idempotency_key) DO NOTHING RETURNING id",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(session_id)
|
||||
.bind(idempotency_key)
|
||||
.bind(&input.existing_content)
|
||||
.bind(crate::summary::PROMPT_VERSION)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
if inserted_id.is_none() {
|
||||
transaction.rollback().await?;
|
||||
let existing = load_summary_run_by_idempotency(pool, owner_id, session_id, idempotency_key)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("该幂等键已被其他总结任务使用".to_owned()))?;
|
||||
return Ok((StatusCode::ACCEPTED, Json(existing)));
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO feedback_summary_run_lessons (summary_run_id, lesson_session_id) \
|
||||
SELECT $1, lesson_id FROM UNNEST($2::uuid[]) AS selected(lesson_id)",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(&input.lesson_ids)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
tracing::info!(
|
||||
event = "summary_job_queued",
|
||||
summary_run_id = %run_id,
|
||||
feedback_session_id = %session_id,
|
||||
recording_count = input.lesson_ids.len(),
|
||||
existing_character_count = input.existing_content.chars().count(),
|
||||
prompt_version = crate::summary::PROMPT_VERSION,
|
||||
status = "queued",
|
||||
"feedback summary job queued"
|
||||
);
|
||||
let response = load_summary_run(pool, run_id)
|
||||
.await?
|
||||
.ok_or(ApiError::Internal)?;
|
||||
Ok((StatusCode::ACCEPTED, Json(response)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/feedback-summary-runs/{run_id}",
|
||||
tag = "语音反馈",
|
||||
summary = "查询反馈总结任务",
|
||||
description = "读取异步总结任务的状态、所选录音和预览正文;页面可据此恢复轮询。",
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("run_id" = Uuid, Path, description = "总结任务 ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "总结任务状态和预览内容", body = SummaryRunResponse),
|
||||
(status = 404, description = "总结任务不存在或不属于当前用户", body = ErrorBody),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn get_feedback_summary_run(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(run_id): Path<Uuid>,
|
||||
) -> Result<Json<SummaryRunResponse>, ApiError> {
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let response = load_summary_run_for_owner(pool(&state)?, owner_id, run_id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/feedback-summary-runs/{run_id}/apply",
|
||||
tag = "语音反馈",
|
||||
summary = "应用反馈总结",
|
||||
description = "确认预览后,用总结正文替换当前反馈草稿。重复应用同一任务是幂等的。",
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("run_id" = Uuid, Path, description = "总结任务 ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "应用总结后的反馈批次", body = FeedbackSessionResponse),
|
||||
(status = 400, description = "总结任务尚未生成完成", body = ErrorBody),
|
||||
(status = 404, description = "总结任务不存在或不属于当前用户", body = ErrorBody),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn apply_feedback_summary_run(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(run_id): Path<Uuid>,
|
||||
) -> Result<Json<FeedbackSessionResponse>, ApiError> {
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let pool = pool(&state)?;
|
||||
let mut transaction = pool.begin().await?;
|
||||
let run = sqlx::query_as::<_, SummaryRunRow>(
|
||||
"SELECT run.id, run.feedback_session_id, run.status, run.content, run.method, \
|
||||
run.model, run.prompt_version, run.character_count, run.error_message, \
|
||||
run.attempts, run.created_at, run.completed_at, run.applied_at \
|
||||
FROM feedback_summary_runs run \
|
||||
JOIN feedback_sessions session ON session.id = run.feedback_session_id \
|
||||
WHERE run.id = $1 AND session.owner_id = $2 AND session.status = 'active' \
|
||||
FOR UPDATE OF run",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(owner_id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
if run.status != "ready" && run.status != "applied" {
|
||||
return Err(ApiError::BadRequest(match run.status.as_str() {
|
||||
"failed" => "总结生成失败,请重新生成".to_owned(),
|
||||
_ => "总结仍在生成中,请稍后再应用".to_owned(),
|
||||
}));
|
||||
}
|
||||
if run.status == "ready" {
|
||||
let content = run.content.as_deref().ok_or(ApiError::Internal)?;
|
||||
sqlx::query("UPDATE feedback_sessions SET content = $2, updated_at = now() WHERE id = $1")
|
||||
.bind(run.feedback_session_id)
|
||||
.bind(content)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE lesson_sessions SET included_in_generation = TRUE, updated_at = now() \
|
||||
WHERE id IN (\
|
||||
SELECT lesson_session_id FROM feedback_summary_run_lessons WHERE summary_run_id = $1\
|
||||
)",
|
||||
)
|
||||
.bind(run_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE feedback_summary_runs \
|
||||
SET status = 'applied', applied_at = now(), updated_at = now() WHERE id = $1",
|
||||
)
|
||||
.bind(run_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
|
||||
tracing::info!(
|
||||
event = "summary_job_applied",
|
||||
summary_run_id = %run_id,
|
||||
feedback_session_id = %run.feedback_session_id,
|
||||
result_character_count = run.character_count.unwrap_or(0),
|
||||
method = run.method.as_deref().unwrap_or("unknown"),
|
||||
model = run.model.as_deref().unwrap_or("none"),
|
||||
prompt_version = %run.prompt_version,
|
||||
status = "applied",
|
||||
"feedback summary applied to draft"
|
||||
);
|
||||
let session = load_feedback_session_row(pool, owner_id, run.feedback_session_id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(load_session_response(pool, session).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/feedback-sessions/{session_id}/generate",
|
||||
@@ -956,6 +1252,113 @@ async fn generate_feedback_content(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn load_summary_run(
|
||||
pool: &PgPool,
|
||||
run_id: Uuid,
|
||||
) -> Result<Option<SummaryRunResponse>, ApiError> {
|
||||
let row = sqlx::query_as::<_, SummaryRunRow>(
|
||||
"SELECT id, feedback_session_id, status, content, method, model, prompt_version, \
|
||||
character_count, error_message, attempts, created_at, completed_at, applied_at \
|
||||
FROM feedback_summary_runs WHERE id = $1",
|
||||
)
|
||||
.bind(run_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
summary_run_response(pool, row).await
|
||||
}
|
||||
|
||||
async fn load_summary_run_for_owner(
|
||||
pool: &PgPool,
|
||||
owner_id: Uuid,
|
||||
run_id: Uuid,
|
||||
) -> Result<Option<SummaryRunResponse>, ApiError> {
|
||||
let row = sqlx::query_as::<_, SummaryRunRow>(
|
||||
"SELECT run.id, run.feedback_session_id, run.status, run.content, run.method, run.model, \
|
||||
run.prompt_version, run.character_count, run.error_message, run.attempts, \
|
||||
run.created_at, run.completed_at, run.applied_at \
|
||||
FROM feedback_summary_runs run \
|
||||
JOIN feedback_sessions session ON session.id = run.feedback_session_id \
|
||||
WHERE run.id = $1 AND session.owner_id = $2",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(owner_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
summary_run_response(pool, row).await
|
||||
}
|
||||
|
||||
async fn load_summary_run_by_idempotency(
|
||||
pool: &PgPool,
|
||||
owner_id: Uuid,
|
||||
session_id: Uuid,
|
||||
idempotency_key: &str,
|
||||
) -> Result<Option<SummaryRunResponse>, ApiError> {
|
||||
let row = sqlx::query_as::<_, SummaryRunRow>(
|
||||
"SELECT run.id, run.feedback_session_id, run.status, run.content, run.method, run.model, \
|
||||
run.prompt_version, run.character_count, run.error_message, run.attempts, \
|
||||
run.created_at, run.completed_at, run.applied_at \
|
||||
FROM feedback_summary_runs run \
|
||||
JOIN feedback_sessions session ON session.id = run.feedback_session_id \
|
||||
WHERE run.idempotency_key = $1 AND run.feedback_session_id = $2 \
|
||||
AND session.owner_id = $3",
|
||||
)
|
||||
.bind(idempotency_key)
|
||||
.bind(session_id)
|
||||
.bind(owner_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
summary_run_response(pool, row).await
|
||||
}
|
||||
|
||||
async fn summary_run_response(
|
||||
pool: &PgPool,
|
||||
row: Option<SummaryRunRow>,
|
||||
) -> Result<Option<SummaryRunResponse>, ApiError> {
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let lesson_ids = sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT lesson_session_id FROM feedback_summary_run_lessons selected \
|
||||
JOIN lesson_sessions lesson ON lesson.id = selected.lesson_session_id \
|
||||
WHERE selected.summary_run_id = $1 ORDER BY lesson.sequence",
|
||||
)
|
||||
.bind(row.id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(Some(SummaryRunResponse {
|
||||
id: row.id,
|
||||
feedback_session_id: row.feedback_session_id,
|
||||
lesson_ids,
|
||||
status: row.status,
|
||||
content: row.content,
|
||||
method: row.method,
|
||||
model: row.model,
|
||||
prompt_version: row.prompt_version,
|
||||
character_count: row.character_count,
|
||||
error_message: row.error_message,
|
||||
attempts: row.attempts,
|
||||
created_at: row.created_at,
|
||||
completed_at: row.completed_at,
|
||||
applied_at: row.applied_at,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn load_feedback_session_row(
|
||||
pool: &PgPool,
|
||||
owner_id: Uuid,
|
||||
session_id: Uuid,
|
||||
) -> Result<Option<FeedbackSessionRow>, ApiError> {
|
||||
Ok(sqlx::query_as::<_, FeedbackSessionRow>(
|
||||
"SELECT id, profile_id, feedback_date, content, status, created_at, updated_at \
|
||||
FROM feedback_sessions \
|
||||
WHERE id = $1 AND owner_id = $2 AND status = 'active'",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(owner_id)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn load_session_response(
|
||||
pool: &PgPool,
|
||||
session: FeedbackSessionRow,
|
||||
@@ -977,6 +1380,16 @@ async fn load_session_response(
|
||||
.bind(session.id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let latest_summary_run_row = sqlx::query_as::<_, SummaryRunRow>(
|
||||
"SELECT id, feedback_session_id, status, content, method, model, prompt_version, \
|
||||
character_count, error_message, attempts, created_at, completed_at, applied_at \
|
||||
FROM feedback_summary_runs WHERE feedback_session_id = $1 \
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
)
|
||||
.bind(session.id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let latest_summary_run = summary_run_response(pool, latest_summary_run_row).await?;
|
||||
let processing_segment_count = segments
|
||||
.iter()
|
||||
.filter(|segment| segment.status == "processing")
|
||||
@@ -1050,6 +1463,7 @@ async fn load_session_response(
|
||||
failed_segment_count,
|
||||
needs_generation,
|
||||
lessons: lesson_responses,
|
||||
latest_summary_run,
|
||||
created_at: session.created_at,
|
||||
updated_at: session.updated_at,
|
||||
})
|
||||
|
||||
@@ -924,7 +924,10 @@ mod tests {
|
||||
.filter(|operation| operation.get("responses").is_some())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(operations.len(), 24);
|
||||
assert_eq!(operations.len(), 27);
|
||||
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}/apply"));
|
||||
for operation in &operations {
|
||||
assert_non_empty(operation, "summary");
|
||||
assert_non_empty(operation, "description");
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use std::collections::HashSet;
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
|
||||
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::SummaryConfig;
|
||||
|
||||
const MAX_FEEDBACK_CHARS: usize = 2000;
|
||||
pub const MAX_FEEDBACK_CHARS: usize = 2000;
|
||||
pub const PROMPT_VERSION: &str = "summary-v1";
|
||||
const MAX_SEGMENT_SUMMARY_CHARS: usize = 800;
|
||||
const MAX_RECORDING_SUMMARY_CHARS: usize = 1200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SummaryService {
|
||||
@@ -17,6 +20,13 @@ pub struct SummaryService {
|
||||
pub struct SummaryResult {
|
||||
pub content: String,
|
||||
pub method: &'static str,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RecordingTranscript {
|
||||
pub sequence: i32,
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -51,7 +61,10 @@ impl SummaryService {
|
||||
pub fn new(config: Option<SummaryConfig>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: reqwest::Client::new(),
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(180))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,26 +76,96 @@ impl SummaryService {
|
||||
&self,
|
||||
existing_content: &str,
|
||||
lesson_transcripts: &[String],
|
||||
) -> Result<SummaryResult, String> {
|
||||
let recordings = lesson_transcripts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, transcript)| RecordingTranscript {
|
||||
sequence: i32::try_from(index + 1).unwrap_or(i32::MAX),
|
||||
segments: vec![transcript.clone()],
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.summarize_hierarchical(existing_content, &recordings)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn summarize_hierarchical(
|
||||
&self,
|
||||
existing_content: &str,
|
||||
recordings: &[RecordingTranscript],
|
||||
) -> Result<SummaryResult, String> {
|
||||
let Some(config) = &self.config else {
|
||||
let transcripts = recordings
|
||||
.iter()
|
||||
.flat_map(|recording| recording.segments.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
return Ok(SummaryResult {
|
||||
content: extractive_summary(existing_content, lesson_transcripts),
|
||||
content: extractive_summary(existing_content, &transcripts),
|
||||
method: "extractive",
|
||||
model: None,
|
||||
});
|
||||
};
|
||||
|
||||
let transcript_text = lesson_transcripts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, transcript)| format!("第{}节课:\n{}", index + 1, transcript))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
let user_prompt = format!(
|
||||
"教师已填写内容:\n{}\n\n新增课堂转录:\n{}",
|
||||
let mut recording_summaries = Vec::with_capacity(recordings.len());
|
||||
for recording in recordings {
|
||||
let mut segment_facts = Vec::with_capacity(recording.segments.len());
|
||||
for transcript in &recording.segments {
|
||||
let facts = self
|
||||
.chat(
|
||||
config,
|
||||
"你是教学课堂事实提取助手。从转录中提取可用于教学反馈的事实、表现、进步、困难和建议线索。忽略口头语和重复内容,不推测、不评价录音质量,不提及录音或转录,只输出简洁中文事实。",
|
||||
transcript,
|
||||
)
|
||||
.await?;
|
||||
segment_facts.push(limit_chars(&facts, MAX_SEGMENT_SUMMARY_CHARS));
|
||||
}
|
||||
|
||||
let recording_prompt = segment_facts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, facts)| format!("片段{}:{}", index + 1, facts))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let recording_summary = self
|
||||
.chat(
|
||||
config,
|
||||
"将同一次课堂记录的片段事实合并为一份紧凑的课堂摘要。去重并保留具体事实,不使用固定栏目,不提及片段、录音或转录,不虚构,只输出中文摘要正文。",
|
||||
&recording_prompt,
|
||||
)
|
||||
.await?;
|
||||
recording_summaries.push(format!(
|
||||
"第{}次课堂:{}",
|
||||
recording.sequence,
|
||||
limit_chars(&recording_summary, MAX_RECORDING_SUMMARY_CHARS)
|
||||
));
|
||||
}
|
||||
|
||||
let final_prompt = format!(
|
||||
"教师当前输入(可为空):\n{}\n\n所选课堂事实摘要:\n{}",
|
||||
existing_content.trim(),
|
||||
transcript_text
|
||||
recording_summaries.join("\n\n")
|
||||
);
|
||||
let system_prompt = "你是教学反馈整理助手。请把教师已有内容与课堂转录合并成一份面向家长的中文反馈,保留具体事实,按课堂表现、进步、问题和后续建议组织。不要虚构,不要提及录音或转录,不超过2000个汉字,只输出反馈正文。";
|
||||
let content = self
|
||||
.chat(
|
||||
config,
|
||||
"你是教学反馈整理助手。请把教师当前输入与所有课堂事实合并、去重并改写为一份面向家长的连贯中文反馈。最终内容是整体总结,不按录音次数分段,不使用固定栏目标题,不提及第几次课堂、录音、片段或转录。保留具体事实,包含整体表现、关键进步、仍需关注的问题和可执行建议,但不要虚构。只输出可直接使用的正文,最多2000个汉字。",
|
||||
&final_prompt,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(SummaryResult {
|
||||
content: limit_chars(&content, MAX_FEEDBACK_CHARS),
|
||||
method: "llm_hierarchical",
|
||||
model: Some(config.model.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
config: &SummaryConfig,
|
||||
system_prompt: &str,
|
||||
user_prompt: &str,
|
||||
) -> Result<String, String> {
|
||||
let request = ChatRequest {
|
||||
model: &config.model,
|
||||
messages: vec![
|
||||
@@ -92,7 +175,7 @@ impl SummaryService {
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user",
|
||||
content: &user_prompt,
|
||||
content: user_prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
@@ -117,29 +200,37 @@ impl SummaryService {
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("反馈汇总响应格式无效:{error}"))?;
|
||||
let content = result
|
||||
result
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|choice| choice.message.content.trim().to_owned())
|
||||
.map(|choice| normalize_model_output(&choice.message.content))
|
||||
.filter(|content| !content.is_empty())
|
||||
.ok_or_else(|| "反馈汇总服务未返回内容".to_owned())?;
|
||||
|
||||
Ok(SummaryResult {
|
||||
content: content.chars().take(MAX_FEEDBACK_CHARS).collect(),
|
||||
method: "llm",
|
||||
})
|
||||
.ok_or_else(|| "反馈汇总服务未返回内容".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_chars(content: &str, limit: usize) -> String {
|
||||
content.trim().chars().take(limit).collect()
|
||||
}
|
||||
|
||||
fn normalize_model_output(content: &str) -> String {
|
||||
let trimmed = content.trim();
|
||||
if !trimmed.starts_with("```") {
|
||||
return trimmed.to_owned();
|
||||
}
|
||||
let mut lines = trimmed.lines();
|
||||
lines.next();
|
||||
let mut remaining = lines.collect::<Vec<_>>();
|
||||
if remaining.last().is_some_and(|line| line.trim() == "```") {
|
||||
remaining.pop();
|
||||
}
|
||||
remaining.join("\n").trim().to_owned()
|
||||
}
|
||||
|
||||
fn extractive_summary(existing_content: &str, lesson_transcripts: &[String]) -> String {
|
||||
let existing = existing_content.trim();
|
||||
let mut result = existing
|
||||
.chars()
|
||||
.take(MAX_FEEDBACK_CHARS)
|
||||
.collect::<String>();
|
||||
let mut result = limit_chars(existing_content, MAX_FEEDBACK_CHARS);
|
||||
let mut seen = HashSet::new();
|
||||
let mut selected = Vec::new();
|
||||
|
||||
for transcript in lesson_transcripts {
|
||||
for sentence in transcript.split_inclusive(['。', '!', '?', '\n']) {
|
||||
@@ -147,28 +238,13 @@ fn extractive_summary(existing_content: &str, lesson_transcripts: &[String]) ->
|
||||
if sentence.chars().count() < 4 || !seen.insert(sentence.to_owned()) {
|
||||
continue;
|
||||
}
|
||||
selected.push(sentence.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
if !selected.is_empty() && result.chars().count() < MAX_FEEDBACK_CHARS {
|
||||
if !result.is_empty() {
|
||||
result.push_str("\n\n");
|
||||
}
|
||||
result.push_str("课堂记录汇总:\n");
|
||||
for sentence in selected {
|
||||
let prefix = "- ";
|
||||
let separator = if result.is_empty() { "" } else { "\n" };
|
||||
let remaining = MAX_FEEDBACK_CHARS.saturating_sub(result.chars().count());
|
||||
if remaining <= prefix.chars().count() + 4 {
|
||||
break;
|
||||
if remaining <= separator.chars().count() + 4 {
|
||||
return result.trim().to_owned();
|
||||
}
|
||||
result.push_str(prefix);
|
||||
result.extend(
|
||||
sentence
|
||||
.chars()
|
||||
.take(remaining - prefix.chars().count() - 1),
|
||||
);
|
||||
result.push('\n');
|
||||
result.push_str(separator);
|
||||
result.extend(sentence.chars().take(remaining - separator.chars().count()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +253,10 @@ fn extractive_summary(existing_content: &str, lesson_transcripts: &[String]) ->
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extractive_summary;
|
||||
use super::{
|
||||
MAX_FEEDBACK_CHARS, RecordingTranscript, SummaryService, extractive_summary,
|
||||
normalize_model_output,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn extractive_summary_preserves_manual_content_and_deduplicates() {
|
||||
@@ -189,5 +268,41 @@ mod tests {
|
||||
assert!(result.starts_with("手动备注。"));
|
||||
assert_eq!(result.matches("完成了构图练习。").count(), 1);
|
||||
assert!(result.contains("色彩更大胆。"));
|
||||
assert!(!result.contains("课堂记录汇总"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_hierarchical_summary_uses_every_recording_and_caps_output() {
|
||||
let service = SummaryService::new(None);
|
||||
let result = service
|
||||
.summarize_hierarchical(
|
||||
"教师备注。",
|
||||
&[
|
||||
RecordingTranscript {
|
||||
sequence: 1,
|
||||
segments: vec!["第一节完成了构图练习。".to_owned()],
|
||||
},
|
||||
RecordingTranscript {
|
||||
sequence: 2,
|
||||
segments: vec![format!("第二节色彩更大胆。{}", "补充表现。".repeat(500))],
|
||||
},
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("local summary should succeed");
|
||||
|
||||
assert!(result.content.contains("第一节完成了构图练习。"));
|
||||
assert!(result.content.contains("第二节色彩更大胆。"));
|
||||
assert!(result.content.chars().count() <= MAX_FEEDBACK_CHARS);
|
||||
assert_eq!(result.method, "extractive");
|
||||
assert!(result.model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_output_removes_code_fence() {
|
||||
assert_eq!(
|
||||
normalize_model_output("```text\n反馈正文。\n```"),
|
||||
"反馈正文。"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
308
server/src/summary_worker.rs
Normal file
308
server/src/summary_worker.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use tracing::Instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
LogContext,
|
||||
summary::{PROMPT_VERSION, RecordingTranscript, SummaryService},
|
||||
};
|
||||
|
||||
const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(750);
|
||||
const UNAVAILABLE_POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const JOB_LEASE_SECONDS: i64 = 30 * 60;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct SummaryJob {
|
||||
id: Uuid,
|
||||
feedback_session_id: Uuid,
|
||||
existing_content: String,
|
||||
created_at: DateTime<Utc>,
|
||||
attempts: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct SummarySourceRow {
|
||||
lesson_id: Uuid,
|
||||
lesson_sequence: i32,
|
||||
segment_id: Uuid,
|
||||
segment_sequence: i32,
|
||||
transcript: String,
|
||||
}
|
||||
|
||||
pub fn spawn(pool: PgPool, summary: SummaryService, log_context: LogContext) {
|
||||
let span = tracing::info_span!(
|
||||
"summary_worker",
|
||||
service = "api",
|
||||
environment = %log_context.environment,
|
||||
app_version = %log_context.app_version,
|
||||
git_sha = %log_context.git_sha,
|
||||
);
|
||||
tokio::spawn(async move { run(pool, summary).await }.instrument(span));
|
||||
}
|
||||
|
||||
async fn run(pool: PgPool, summary: SummaryService) {
|
||||
loop {
|
||||
match claim_next_job(&pool).await {
|
||||
Ok(Some(job)) => {
|
||||
if process_job(&pool, &summary, &job).await.is_err() {
|
||||
tracing::error!(
|
||||
event = "summary_job_update_failed",
|
||||
summary_run_id = %job.id,
|
||||
feedback_session_id = %job.feedback_session_id,
|
||||
error_class = "database_update_failed",
|
||||
"summary job update failed"
|
||||
);
|
||||
tokio::time::sleep(IDLE_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
Ok(None) => tokio::time::sleep(IDLE_POLL_INTERVAL).await,
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
event = "summary_job_claim_failed",
|
||||
error_class = "database_query_failed",
|
||||
"failed to claim summary job"
|
||||
);
|
||||
tokio::time::sleep(UNAVAILABLE_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn claim_next_job(pool: &PgPool) -> Result<Option<SummaryJob>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SummaryJob>(
|
||||
"WITH next_job AS (\
|
||||
SELECT id FROM feedback_summary_runs \
|
||||
WHERE status = 'queued' \
|
||||
OR (status = 'processing' AND processing_started_at < now() - ($1 * interval '1 second')) \
|
||||
ORDER BY created_at, id \
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1\
|
||||
) \
|
||||
UPDATE feedback_summary_runs AS run \
|
||||
SET status = 'processing', processing_started_at = now(), attempts = attempts + 1, \
|
||||
error_class = NULL, error_message = NULL, updated_at = now() \
|
||||
FROM next_job \
|
||||
WHERE run.id = next_job.id \
|
||||
RETURNING run.id, run.feedback_session_id, run.existing_content, run.created_at, run.attempts",
|
||||
)
|
||||
.bind(JOB_LEASE_SECONDS)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn process_job(
|
||||
pool: &PgPool,
|
||||
summary: &SummaryService,
|
||||
job: &SummaryJob,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let started = Instant::now();
|
||||
let rows = sqlx::query_as::<_, SummarySourceRow>(
|
||||
"SELECT l.id AS lesson_id, l.sequence AS lesson_sequence, \
|
||||
s.id AS segment_id, s.sequence AS segment_sequence, s.transcript \
|
||||
FROM feedback_summary_run_lessons selected \
|
||||
JOIN lesson_sessions l ON l.id = selected.lesson_session_id \
|
||||
JOIN audio_segments s ON s.lesson_session_id = l.id \
|
||||
WHERE selected.summary_run_id = $1 AND l.status = 'ready' \
|
||||
AND s.status = 'ready' AND s.transcript IS NOT NULL \
|
||||
AND char_length(btrim(s.transcript)) > 0 \
|
||||
ORDER BY l.sequence, s.sequence",
|
||||
)
|
||||
.bind(job.id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let expected_lesson_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM feedback_summary_run_lessons WHERE summary_run_id = $1",
|
||||
)
|
||||
.bind(job.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let source_lesson_count = rows
|
||||
.iter()
|
||||
.map(|row| row.lesson_id)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len();
|
||||
if rows.is_empty() || source_lesson_count != usize::try_from(expected_lesson_count).unwrap_or(0)
|
||||
{
|
||||
fail_job(
|
||||
pool,
|
||||
job,
|
||||
"summary_source_unavailable",
|
||||
"所选录音尚未全部转录完成,请重新选择后生成",
|
||||
elapsed_ms(started),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source_character_count = rows
|
||||
.iter()
|
||||
.map(|row| row.transcript.chars().count())
|
||||
.sum::<usize>();
|
||||
let source_segment_count = rows.len();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(job.existing_content.as_bytes());
|
||||
let mut recordings = Vec::<RecordingTranscript>::new();
|
||||
for row in rows {
|
||||
hasher.update(row.lesson_id.as_bytes());
|
||||
hasher.update(row.segment_id.as_bytes());
|
||||
hasher.update(row.segment_sequence.to_be_bytes());
|
||||
hasher.update(row.transcript.as_bytes());
|
||||
match recordings.last_mut() {
|
||||
Some(recording) if recording.sequence == row.lesson_sequence => {
|
||||
recording.segments.push(row.transcript);
|
||||
}
|
||||
_ => recordings.push(RecordingTranscript {
|
||||
sequence: row.lesson_sequence,
|
||||
segments: vec![row.transcript],
|
||||
}),
|
||||
}
|
||||
}
|
||||
let source_hash = format!("{:x}", hasher.finalize());
|
||||
let queue_wait_ms = elapsed_since(job.created_at);
|
||||
tracing::info!(
|
||||
event = "summary_job_started",
|
||||
summary_run_id = %job.id,
|
||||
feedback_session_id = %job.feedback_session_id,
|
||||
attempt = job.attempts,
|
||||
queue_wait_ms,
|
||||
recording_count = recordings.len(),
|
||||
source_segment_count,
|
||||
source_character_count,
|
||||
estimated_model_request_count = if summary.configured() {
|
||||
source_segment_count + recordings.len() + 1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
prompt_version = PROMPT_VERSION,
|
||||
provider = if summary.configured() { "openai_compatible" } else { "local" },
|
||||
status = "processing",
|
||||
"generating feedback summary"
|
||||
);
|
||||
|
||||
match summary
|
||||
.summarize_hierarchical(&job.existing_content, &recordings)
|
||||
.await
|
||||
{
|
||||
Ok(result) if !result.content.trim().is_empty() => {
|
||||
let character_count = result.content.chars().count();
|
||||
let latency_ms = elapsed_ms(started);
|
||||
sqlx::query(
|
||||
"UPDATE feedback_summary_runs \
|
||||
SET status = 'ready', source_hash = $2, content = $3, method = $4, model = $5, \
|
||||
prompt_version = $6, character_count = $7, processing_started_at = NULL, \
|
||||
completed_at = now(), updated_at = now() \
|
||||
WHERE id = $1 AND status = 'processing'",
|
||||
)
|
||||
.bind(job.id)
|
||||
.bind(&source_hash)
|
||||
.bind(&result.content)
|
||||
.bind(result.method)
|
||||
.bind(&result.model)
|
||||
.bind(PROMPT_VERSION)
|
||||
.bind(i32::try_from(character_count).unwrap_or(i32::MAX))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
event = "summary_job_finished",
|
||||
summary_run_id = %job.id,
|
||||
feedback_session_id = %job.feedback_session_id,
|
||||
attempt = job.attempts,
|
||||
recording_count = recordings.len(),
|
||||
source_segment_count,
|
||||
source_character_count,
|
||||
model_request_count = if summary.configured() {
|
||||
source_segment_count + recordings.len() + 1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
result_character_count = character_count,
|
||||
latency_ms,
|
||||
method = result.method,
|
||||
model = result.model.as_deref().unwrap_or("none"),
|
||||
prompt_version = PROMPT_VERSION,
|
||||
status = "ready",
|
||||
"feedback summary generated"
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
fail_job(
|
||||
pool,
|
||||
job,
|
||||
"summary_empty_result",
|
||||
"未能从所选录音生成有效总结",
|
||||
elapsed_ms(started),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(message) => {
|
||||
fail_job(
|
||||
pool,
|
||||
job,
|
||||
summary_error_class(&message),
|
||||
&message,
|
||||
elapsed_ms(started),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fail_job(
|
||||
pool: &PgPool,
|
||||
job: &SummaryJob,
|
||||
error_class: &str,
|
||||
message: &str,
|
||||
latency_ms: u64,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let error_message = message.chars().take(1000).collect::<String>();
|
||||
sqlx::query(
|
||||
"UPDATE feedback_summary_runs \
|
||||
SET status = 'failed', error_class = $2, error_message = $3, \
|
||||
processing_started_at = NULL, completed_at = now(), updated_at = now() \
|
||||
WHERE id = $1 AND status = 'processing'",
|
||||
)
|
||||
.bind(job.id)
|
||||
.bind(error_class)
|
||||
.bind(&error_message)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
tracing::warn!(
|
||||
event = "summary_job_failed",
|
||||
summary_run_id = %job.id,
|
||||
feedback_session_id = %job.feedback_session_id,
|
||||
attempt = job.attempts,
|
||||
latency_ms,
|
||||
error_class,
|
||||
status = "failed",
|
||||
error = %error_message,
|
||||
"feedback summary generation failed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn summary_error_class(message: &str) -> &'static str {
|
||||
if message.contains("连接失败") {
|
||||
"summary_service_connection_failed"
|
||||
} else if message.contains("HTTP") {
|
||||
"summary_service_http_error"
|
||||
} else if message.contains("格式无效") {
|
||||
"summary_response_invalid"
|
||||
} else {
|
||||
"summary_generation_failed"
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_since(timestamp: DateTime<Utc>) -> i64 {
|
||||
Utc::now()
|
||||
.signed_duration_since(timestamp)
|
||||
.num_milliseconds()
|
||||
.max(0)
|
||||
}
|
||||
|
||||
fn elapsed_ms(started: Instant) -> u64 {
|
||||
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ function session(overrides = {}) {
|
||||
failedSegmentCount: 0,
|
||||
needsGeneration: false,
|
||||
lessons: [],
|
||||
latestSummaryRun: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides
|
||||
@@ -40,10 +41,10 @@ test('failed segments take priority over processing and generation', () => {
|
||||
assert.equal(getVoicePrimaryActionText(action, value.failedSegmentCount), '重试转录(2)')
|
||||
})
|
||||
|
||||
test('voice primary action follows retry, processing, then generation states', () => {
|
||||
test('voice primary action keeps summary generation separate from save', () => {
|
||||
assert.equal(getVoicePrimaryAction(session({ failedSegmentCount: 1 })), 'retry')
|
||||
assert.equal(getVoicePrimaryAction(session({ processingSegmentCount: 1 })), 'processing')
|
||||
assert.equal(getVoicePrimaryAction(session({ needsGeneration: true })), 'generate')
|
||||
assert.equal(getVoicePrimaryAction(session({ needsGeneration: true })), 'save')
|
||||
})
|
||||
|
||||
test('no clear speech failures provide an actionable explanation', () => {
|
||||
|
||||
78
utils/api.ts
78
utils/api.ts
@@ -2,6 +2,8 @@ import type {
|
||||
FeedbackDraft,
|
||||
FeedbackRecord,
|
||||
FeedbackSession,
|
||||
FeedbackSummaryRun,
|
||||
FeedbackSummaryStatus,
|
||||
GeneratedFeedback,
|
||||
HealthStatus,
|
||||
StudentDraft,
|
||||
@@ -104,10 +106,28 @@ interface RawFeedbackSession {
|
||||
failed_segment_count: number
|
||||
needs_generation: boolean
|
||||
lessons: RawVoiceLesson[]
|
||||
latest_summary_run?: RawFeedbackSummaryRun | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface RawFeedbackSummaryRun {
|
||||
id: string
|
||||
feedback_session_id: string
|
||||
lesson_ids: string[]
|
||||
status: FeedbackSummaryStatus
|
||||
content: string | null
|
||||
method: string | null
|
||||
model: string | null
|
||||
prompt_version: string
|
||||
character_count: number | null
|
||||
error_message: string | null
|
||||
attempts: number
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
applied_at: string | null
|
||||
}
|
||||
|
||||
interface RawVoiceLessonCreated {
|
||||
id: string
|
||||
sequence: number
|
||||
@@ -125,7 +145,7 @@ interface RawVoiceLessonFinished {
|
||||
|
||||
interface RawGeneratedFeedback {
|
||||
content: string
|
||||
method: 'llm' | 'extractive'
|
||||
method: 'llm' | 'llm_hierarchical' | 'extractive'
|
||||
}
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
@@ -345,11 +365,33 @@ function mapFeedbackSession(session: RawFeedbackSession): FeedbackSession {
|
||||
failedSegmentCount: session.failed_segment_count,
|
||||
needsGeneration: session.needs_generation,
|
||||
lessons: session.lessons.map(mapVoiceLesson),
|
||||
latestSummaryRun: session.latest_summary_run
|
||||
? mapFeedbackSummaryRun(session.latest_summary_run)
|
||||
: null,
|
||||
createdAt: parseTimestamp(session.created_at),
|
||||
updatedAt: parseTimestamp(session.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
function mapFeedbackSummaryRun(run: RawFeedbackSummaryRun): FeedbackSummaryRun {
|
||||
return {
|
||||
id: run.id,
|
||||
feedbackSessionId: run.feedback_session_id,
|
||||
lessonIds: run.lesson_ids,
|
||||
status: run.status,
|
||||
content: run.content,
|
||||
method: run.method,
|
||||
model: run.model,
|
||||
promptVersion: run.prompt_version,
|
||||
characterCount: run.character_count,
|
||||
errorMessage: run.error_message,
|
||||
attempts: run.attempts,
|
||||
createdAt: parseTimestamp(run.created_at),
|
||||
completedAt: run.completed_at ? parseTimestamp(run.completed_at) : null,
|
||||
appliedAt: run.applied_at ? parseTimestamp(run.applied_at) : null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHealth(): Promise<HealthStatus> {
|
||||
const result = await request<RawHealthStatus>('/health', 'GET', undefined, 10000, false)
|
||||
return {
|
||||
@@ -607,3 +649,37 @@ export async function generateFeedbackFromVoice(
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function createFeedbackSummaryRun(
|
||||
sessionId: string,
|
||||
lessonIds: string[],
|
||||
existingContent: string,
|
||||
idempotencyKey: string
|
||||
): Promise<FeedbackSummaryRun> {
|
||||
const result = await request<RawFeedbackSummaryRun>(
|
||||
`/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}/summary-runs`,
|
||||
'POST',
|
||||
{
|
||||
lesson_ids: lessonIds,
|
||||
existing_content: existingContent,
|
||||
idempotency_key: idempotencyKey
|
||||
},
|
||||
30000
|
||||
)
|
||||
return mapFeedbackSummaryRun(result)
|
||||
}
|
||||
|
||||
export async function getFeedbackSummaryRun(runId: string): Promise<FeedbackSummaryRun> {
|
||||
const result = await request<RawFeedbackSummaryRun>(
|
||||
`/api/v1/feedback-summary-runs/${encodeURIComponent(runId)}`
|
||||
)
|
||||
return mapFeedbackSummaryRun(result)
|
||||
}
|
||||
|
||||
export async function applyFeedbackSummaryRun(runId: string): Promise<FeedbackSession> {
|
||||
const result = await request<RawFeedbackSession>(
|
||||
`/api/v1/feedback-summary-runs/${encodeURIComponent(runId)}/apply`,
|
||||
'POST'
|
||||
)
|
||||
return mapFeedbackSession(result)
|
||||
}
|
||||
|
||||
@@ -93,10 +93,30 @@ export interface FeedbackSession {
|
||||
failedSegmentCount: number
|
||||
needsGeneration: boolean
|
||||
lessons: VoiceLesson[]
|
||||
latestSummaryRun: FeedbackSummaryRun | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type FeedbackSummaryStatus = 'queued' | 'processing' | 'ready' | 'failed' | 'applied'
|
||||
|
||||
export interface FeedbackSummaryRun {
|
||||
id: string
|
||||
feedbackSessionId: string
|
||||
lessonIds: string[]
|
||||
status: FeedbackSummaryStatus
|
||||
content: string | null
|
||||
method: string | null
|
||||
model: string | null
|
||||
promptVersion: string
|
||||
characterCount: number | null
|
||||
errorMessage: string | null
|
||||
attempts: number
|
||||
createdAt: number
|
||||
completedAt: number | null
|
||||
appliedAt: number | null
|
||||
}
|
||||
|
||||
export interface VoiceLessonCreated {
|
||||
id: string
|
||||
sequence: number
|
||||
@@ -114,5 +134,5 @@ export interface VoiceLessonFinished {
|
||||
|
||||
export interface GeneratedFeedback {
|
||||
content: string
|
||||
method: 'llm' | 'extractive'
|
||||
method: 'llm' | 'llm_hierarchical' | 'extractive'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user