diff --git a/README.md b/README.md index 55311ae..7f6662f 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/docs/incidents/2026-07-22-miniprogram-voice-retry.md b/docs/incidents/2026-07-22-miniprogram-voice-retry.md index e128a83..9025c23 100644 --- a/docs/incidents/2026-07-22-miniprogram-voice-retry.md +++ b/docs/incidents/2026-07-22-miniprogram-voice-retry.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 错误都能在页面上获得可复制的请求编号。 - 自动化测试覆盖失败片段优先级、重试状态流转和生成失败后的会话刷新。 diff --git a/pages/feedback/index.ts b/pages/feedback/index.ts index 7f159fb..131faa9 100644 --- a/pages/feedback/index.ts +++ b/pages/feedback/index.ts @@ -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 = 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 { 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): 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 = { 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 { 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 { @@ -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 { + 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' }) } diff --git a/pages/feedback/index.wxml b/pages/feedback/index.wxml index 0c74d96..1c269a0 100644 --- a/pages/feedback/index.wxml +++ b/pages/feedback/index.wxml @@ -62,60 +62,103 @@ {{voiceStatus}} {{recordingTime}} - {{voiceDetailsExpanded ? '⌃' : '⌄'}} + 语音记录 - {{voiceLessonViews.length}}节 - - - - - 第{{lesson.sequence}}节 - {{lesson.durationText}} · {{lesson.transcriptLength}}字 - - {{lesson.statusText}} - - {{lesson.transcript}} - {{lesson.emptyText}} - + + 已选 {{selectedSummaryLessonCount}} / {{eligibleSummaryLessonCount}} - + disabled="{{recording || voiceBusy || summaryBusy || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing'))}}" + bindtap="toggleAllSummaryLessons" + >{{allSummaryLessonsSelected ? '取消全选' : '全选'}} + + + + + + + 第{{lesson.sequence}}节 + {{lesson.durationText}} · {{lesson.transcriptLength}}字 + + + {{lesson.statusText}} + + {{lesson.transcript}} + {{lesson.emptyText}} + + + + + + + + + + + + 总结预览 + {{summaryStatusText}} + + {{summaryRun.content}} + 已选择 {{summaryRun.lessonIds.length}} 节录音 + + {{voiceFailureHint}} - + diff --git a/pages/feedback/index.wxss b/pages/feedback/index.wxss index 5524e5b..d7bd1ea 100644 --- a/pages/feedback/index.wxss +++ b/pages/feedback/index.wxss @@ -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; } diff --git a/pages/feedback/voice-state.ts b/pages/feedback/voice-state.ts index 4c12155..2440f2a 100644 --- a/pages/feedback/voice-state.ts +++ b/pages/feedback/voice-state.ts @@ -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 '保存反馈' } diff --git a/server/API.md b/server/API.md index bbe1376..8fda893 100644 --- a/server/API.md +++ b/server/API.md @@ -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 | `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 字限制。 ### 创建学生档案 diff --git a/server/API_GUIDE.md b/server/API_GUIDE.md index e0fb42f..29e4f6d 100644 --- a/server/API_GUIDE.md +++ b/server/API_GUIDE.md @@ -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 维护方式 diff --git a/server/migrations/0005_feedback_summary_runs.sql b/server/migrations/0005_feedback_summary_runs.sql new file mode 100644 index 0000000..0cde85d --- /dev/null +++ b/server/migrations/0005_feedback_summary_runs.sql @@ -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); diff --git a/server/src/main.rs b/server/src/main.rs index 109a022..e9756d3 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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> { }; 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, diff --git a/server/src/recording_routes.rs b/server/src/recording_routes.rs index a7599f9..bc8a3d2 100644 --- a/server/src/recording_routes.rs +++ b/server/src/recording_routes.rs @@ -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 { .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, + latest_summary_run: Option, created_at: DateTime, updated_at: DateTime, } @@ -191,6 +198,49 @@ struct GenerateFeedbackResponse { method: String, } +#[derive(Debug, Deserialize, ToSchema)] +struct CreateSummaryRunInput { + lesson_ids: Vec, + #[serde(default)] + existing_content: String, + idempotency_key: String, +} + +#[derive(Debug, FromRow)] +struct SummaryRunRow { + id: Uuid, + feedback_session_id: Uuid, + status: String, + content: Option, + method: Option, + model: Option, + prompt_version: String, + character_count: Option, + error_message: Option, + attempts: i32, + created_at: DateTime, + completed_at: Option>, + applied_at: Option>, +} + +#[derive(Debug, Serialize, ToSchema)] +struct SummaryRunResponse { + id: Uuid, + feedback_session_id: Uuid, + lesson_ids: Vec, + status: String, + content: Option, + method: Option, + model: Option, + prompt_version: String, + character_count: Option, + error_message: Option, + attempts: i32, + created_at: DateTime, + completed_at: Option>, + applied_at: Option>, +} + #[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 "), + ("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, + headers: HeaderMap, + Path(session_id): Path, + Json(input): Json, +) -> Result<(StatusCode, Json), 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::>(); + 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::>(); + 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 "), + ("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, + headers: HeaderMap, + Path(run_id): Path, +) -> Result, 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 "), + ("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, + headers: HeaderMap, + Path(run_id): Path, +) -> Result, 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, 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, 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, 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, +) -> Result, 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, 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, }) diff --git a/server/src/routes.rs b/server/src/routes.rs index a4940f1..b26faa5 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -924,7 +924,10 @@ mod tests { .filter(|operation| operation.get("responses").is_some()) .collect::>(); - 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"); diff --git a/server/src/summary.rs b/server/src/summary.rs index df5dbd5..ede2ae7 100644 --- a/server/src/summary.rs +++ b/server/src/summary.rs @@ -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, +} + +#[derive(Debug)] +pub struct RecordingTranscript { + pub sequence: i32, + pub segments: Vec, } #[derive(Serialize)] @@ -51,7 +61,10 @@ impl SummaryService { pub fn new(config: Option) -> 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 { + 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::>(); + self.summarize_hierarchical(existing_content, &recordings) + .await + } + + pub async fn summarize_hierarchical( + &self, + existing_content: &str, + recordings: &[RecordingTranscript], ) -> Result { let Some(config) = &self.config else { + let transcripts = recordings + .iter() + .flat_map(|recording| recording.segments.iter().cloned()) + .collect::>(); 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::>() - .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::>() + .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 { 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::>(); + 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::(); + 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```"), + "反馈正文。" + ); } } diff --git a/server/src/summary_worker.rs b/server/src/summary_worker.rs new file mode 100644 index 0000000..07a207a --- /dev/null +++ b/server/src/summary_worker.rs @@ -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, + 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, 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::>() + .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::(); + let source_segment_count = rows.len(); + let mut hasher = Sha256::new(); + hasher.update(job.existing_content.as_bytes()); + let mut recordings = Vec::::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::(); + 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) -> 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) +} diff --git a/tests/voice-state.test.js b/tests/voice-state.test.js index 59dd903..878f8fa 100644 --- a/tests/voice-state.test.js +++ b/tests/voice-state.test.js @@ -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', () => { diff --git a/utils/api.ts b/utils/api.ts index 7ba1718..e06816d 100644 --- a/utils/api.ts +++ b/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 { const result = await request('/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 { + const result = await request( + `/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 { + const result = await request( + `/api/v1/feedback-summary-runs/${encodeURIComponent(runId)}` + ) + return mapFeedbackSummaryRun(result) +} + +export async function applyFeedbackSummaryRun(runId: string): Promise { + const result = await request( + `/api/v1/feedback-summary-runs/${encodeURIComponent(runId)}/apply`, + 'POST' + ) + return mapFeedbackSession(result) +} diff --git a/utils/types.ts b/utils/types.ts index e0c26d9..fec67aa 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -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' }