feat(voice): add async selectable summaries

This commit is contained in:
zhangheng
2026-07-22 19:13:55 +08:00
parent 108e280b3f
commit 0a2509d923
17 changed files with 1530 additions and 159 deletions

View File

@@ -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)。

View File

@@ -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 错误都能在页面上获得可复制的请求编号。
- 自动化测试覆盖失败片段优先级、重试状态流转和生成失败后的会话刷新。

View File

@@ -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
const feedbackDate = this.data.draft.feedbackDate
const content = this.data.draft.content
const save = async () => {
try {
const updated = await updateFeedbackSession(
sessionId,
this.data.draft.feedbackDate,
this.data.draft.content
)
const updated = await updateFeedbackSession(sessionId, feedbackDate, content)
if (this.data.session?.id !== sessionId) return
this.setData({ session: updated })
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' })
}

View File

@@ -62,13 +62,22 @@
<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 class="voice-summary-selection-heading">
<text>已选 {{selectedSummaryLessonCount}} / {{eligibleSummaryLessonCount}}</text>
<button
wx:if="{{eligibleSummaryLessonCount > 0}}"
class="voice-record-text-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"
@@ -76,10 +85,20 @@
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
@@ -103,19 +122,43 @@
>{{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">

View File

@@ -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;
}

View File

@@ -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 '保存反馈'
}

View File

@@ -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 字限制。
### 创建学生档案

View File

@@ -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 维护方式

View 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);

View File

@@ -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,

View File

@@ -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,
})

View File

@@ -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");

View File

@@ -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
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, transcript)| format!("{}节课:\n{}", index + 1, transcript))
.map(|(index, facts)| format!("片段{}{}", index + 1, facts))
.collect::<Vec<_>>()
.join("\n\n");
let user_prompt = format!(
"教师已填写内容:\n{}\n\n新增课堂转录:\n{}",
.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```"),
"反馈正文。"
);
}
}

View 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)
}

View File

@@ -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', () => {

View File

@@ -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)
}

View File

@@ -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'
}