feat: add voice transcription feedback workflow

This commit is contained in:
2026-07-20 10:49:49 +08:00
parent 0a07538dce
commit bed80aa807
17 changed files with 1691 additions and 19 deletions

View File

@@ -1,11 +1,18 @@
import type {
FeedbackDraft,
FeedbackRecord,
FeedbackSession,
GeneratedFeedback,
HealthStatus,
StudentDraft,
StudentProfile,
StudentProfileDefaults,
Term
Term,
VoiceAudioSegment,
VoiceLesson,
VoiceLessonCreated,
VoiceLessonFinished,
VoiceProcessingStatus
} from './types'
const DEFAULT_API_BASE_URL = 'http://127.0.0.1:8080'
@@ -50,6 +57,66 @@ interface RawFeedbackRecord {
interface RawHealthStatus {
status: string
database_configured: boolean
speech_configured: boolean
speech_available: boolean
speech_provider: string | null
summary_configured: boolean
}
interface RawVoiceAudioSegment {
id: string
sequence: number
duration_ms: number
status: 'processing' | 'ready' | 'failed'
error_message: string | null
}
interface RawVoiceLesson {
id: string
sequence: number
status: VoiceProcessingStatus
duration_ms: number
applied_directly: boolean
included_in_generation: boolean
segments: RawVoiceAudioSegment[]
started_at: string
ended_at: string | null
}
interface RawFeedbackSession {
id: string
profile_id: string | null
feedback_date: string
content: string
status: 'active' | 'finalized'
lesson_count: number
total_duration_ms: number
processing_segment_count: number
failed_segment_count: number
needs_generation: boolean
lessons: RawVoiceLesson[]
created_at: string
updated_at: string
}
interface RawVoiceLessonCreated {
id: string
sequence: number
status: VoiceProcessingStatus
started_at: string
}
interface RawVoiceLessonFinished {
id: string
status: VoiceProcessingStatus
duration_ms: number
transcript: string
can_apply_directly: boolean
}
interface RawGeneratedFeedback {
content: string
method: 'llm' | 'extractive'
}
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
@@ -81,17 +148,17 @@ export function getApiErrorMessage(error: unknown): string {
if (!(error instanceof ApiRequestError)) return '请求失败,请稍后重试'
if (error.statusCode === 401) return '开发身份未通过后端校验'
if (error.statusCode === 404) return '请求的数据不存在或无权访问'
if (error.statusCode === 503) return '后端尚未配置数据库连接'
if (error.statusCode === 503 && error.message.includes('database')) return '后端尚未配置数据库连接'
return error.message
}
function request<T>(path: string, method: HttpMethod = 'GET', data?: object): Promise<T> {
function request<T>(path: string, method: HttpMethod = 'GET', data?: object, timeout = 10000): Promise<T> {
return new Promise((resolve, reject) => {
wx.request({
url: `${getApiBaseUrl()}${path}`,
method,
data,
timeout: 10000,
timeout,
header: {
'Content-Type': 'application/json',
'X-User-Id': DEVELOPMENT_USER_ID
@@ -165,11 +232,57 @@ function mapFeedbackRecord(record: RawFeedbackRecord): FeedbackRecord {
}
}
function mapVoiceSegment(segment: RawVoiceAudioSegment): VoiceAudioSegment {
return {
id: segment.id,
sequence: segment.sequence,
durationMs: segment.duration_ms,
status: segment.status,
errorMessage: segment.error_message
}
}
function mapVoiceLesson(lesson: RawVoiceLesson): VoiceLesson {
return {
id: lesson.id,
sequence: lesson.sequence,
status: lesson.status,
durationMs: lesson.duration_ms,
appliedDirectly: lesson.applied_directly,
includedInGeneration: lesson.included_in_generation,
segments: lesson.segments.map(mapVoiceSegment),
startedAt: parseTimestamp(lesson.started_at),
endedAt: lesson.ended_at ? parseTimestamp(lesson.ended_at) : null
}
}
function mapFeedbackSession(session: RawFeedbackSession): FeedbackSession {
return {
id: session.id,
profileId: session.profile_id,
feedbackDate: session.feedback_date,
content: session.content,
status: session.status,
lessonCount: session.lesson_count,
totalDurationMs: session.total_duration_ms,
processingSegmentCount: session.processing_segment_count,
failedSegmentCount: session.failed_segment_count,
needsGeneration: session.needs_generation,
lessons: session.lessons.map(mapVoiceLesson),
createdAt: parseTimestamp(session.created_at),
updatedAt: parseTimestamp(session.updated_at)
}
}
export async function getHealth(): Promise<HealthStatus> {
const result = await request<RawHealthStatus>('/health')
return {
status: result.status,
databaseConfigured: result.database_configured
databaseConfigured: result.database_configured,
speechConfigured: result.speech_configured,
speechAvailable: result.speech_available,
speechProvider: result.speech_provider,
summaryConfigured: result.summary_configured
}
}
@@ -209,7 +322,8 @@ export async function createFeedbackRecord(draft: FeedbackDraft): Promise<Feedba
const result = await request<RawFeedbackRecord>('/api/v1/feedback-records', 'POST', {
profile_id: draft.profileId,
feedback_date: draft.feedbackDate,
content: draft.content
content: draft.content,
feedback_session_id: draft.feedbackSessionId || null
})
return mapFeedbackRecord(result)
}
@@ -217,3 +331,134 @@ export async function createFeedbackRecord(draft: FeedbackDraft): Promise<Feedba
export async function deleteFeedbackRecord(recordId: string): Promise<void> {
await request<unknown>(`/api/v1/feedback-records/${encodeURIComponent(recordId)}`, 'DELETE')
}
export async function getActiveFeedbackSession(profileId: string | null): Promise<FeedbackSession | null> {
const query = profileId ? `?profile_id=${encodeURIComponent(profileId)}` : ''
const result = await request<RawFeedbackSession | null>(`/api/v1/feedback-sessions/active${query}`)
return result ? mapFeedbackSession(result) : null
}
export async function ensureFeedbackSession(
profileId: string | null,
feedbackDate: string,
content: string
): Promise<FeedbackSession> {
const result = await request<RawFeedbackSession>('/api/v1/feedback-sessions', 'POST', {
profile_id: profileId,
feedback_date: feedbackDate,
content
})
return mapFeedbackSession(result)
}
export async function updateFeedbackSession(
sessionId: string,
feedbackDate: string,
content: string
): Promise<FeedbackSession> {
const result = await request<RawFeedbackSession>(
`/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}`,
'PUT',
{ feedback_date: feedbackDate, content }
)
return mapFeedbackSession(result)
}
export async function createVoiceLesson(sessionId: string): Promise<VoiceLessonCreated> {
const result = await request<RawVoiceLessonCreated>(
`/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}/lessons`,
'POST'
)
return {
id: result.id,
sequence: result.sequence,
status: result.status,
startedAt: parseTimestamp(result.started_at)
}
}
export function uploadVoiceSegment(
lessonId: string,
filePath: string,
sequence: number,
durationMs: number
): Promise<VoiceAudioSegment> {
return new Promise((resolve, reject) => {
wx.uploadFile({
url: `${getApiBaseUrl()}/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/segments`,
filePath,
name: 'audio',
formData: {
sequence: String(sequence),
duration_ms: String(Math.max(1, Math.round(durationMs))),
audio_format: 'mp3'
},
timeout: 120000,
header: { 'X-User-Id': DEVELOPMENT_USER_ID },
success(response) {
let body: RawVoiceAudioSegment | ApiErrorBody
try {
body = JSON.parse(response.data) as RawVoiceAudioSegment | ApiErrorBody
} catch {
reject(new ApiRequestError('服务返回了无效的录音响应', response.statusCode))
return
}
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(mapVoiceSegment(body as RawVoiceAudioSegment))
return
}
reject(new ApiRequestError((body as ApiErrorBody).error || `服务返回 HTTP ${response.statusCode}`, response.statusCode))
},
fail(error) {
reject(new ApiRequestError(`录音上传失败:${error.errMsg}`))
}
})
})
}
export async function finishVoiceLesson(lessonId: string): Promise<VoiceLessonFinished> {
const result = await request<RawVoiceLessonFinished>(
`/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/finish`,
'POST',
undefined,
120000
)
return {
id: result.id,
status: result.status,
durationMs: result.duration_ms,
transcript: result.transcript,
canApplyDirectly: result.can_apply_directly
}
}
export async function markVoiceLessonApplied(lessonId: string, content: string): Promise<void> {
await request<unknown>(
`/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/mark-applied`,
'POST',
{ content }
)
}
export async function retryVoiceSegment(segmentId: string): Promise<VoiceAudioSegment> {
const result = await request<RawVoiceAudioSegment>(
`/api/v1/audio-segments/${encodeURIComponent(segmentId)}/retry`,
'POST',
undefined,
120000
)
return mapVoiceSegment(result)
}
export async function generateFeedbackFromVoice(
sessionId: string,
existingContent: string
): Promise<GeneratedFeedback> {
const result = await request<RawGeneratedFeedback>(
`/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}/generate`,
'POST',
{ existing_content: existingContent },
120000
)
return result
}

View File

@@ -44,9 +44,72 @@ export interface FeedbackDraft {
profileId: string | null
feedbackDate: string
content: string
feedbackSessionId?: string | null
}
export interface HealthStatus {
status: string
databaseConfigured: boolean
speechConfigured: boolean
speechAvailable: boolean
speechProvider: string | null
summaryConfigured: boolean
}
export type VoiceProcessingStatus = 'recording' | 'processing' | 'ready' | 'failed'
export interface VoiceAudioSegment {
id: string
sequence: number
durationMs: number
status: 'processing' | 'ready' | 'failed'
errorMessage: string | null
}
export interface VoiceLesson {
id: string
sequence: number
status: VoiceProcessingStatus
durationMs: number
appliedDirectly: boolean
includedInGeneration: boolean
segments: VoiceAudioSegment[]
startedAt: number
endedAt: number | null
}
export interface FeedbackSession {
id: string
profileId: string | null
feedbackDate: string
content: string
status: 'active' | 'finalized'
lessonCount: number
totalDurationMs: number
processingSegmentCount: number
failedSegmentCount: number
needsGeneration: boolean
lessons: VoiceLesson[]
createdAt: number
updatedAt: number
}
export interface VoiceLessonCreated {
id: string
sequence: number
status: VoiceProcessingStatus
startedAt: number
}
export interface VoiceLessonFinished {
id: string
status: VoiceProcessingStatus
durationMs: number
transcript: string
canApplyDirectly: boolean
}
export interface GeneratedFeedback {
content: string
method: 'llm' | 'extractive'
}