Просмотр исходного кода

feat: render English speaking report pipeline

jimmylee 2 недель назад
Родитель
Сommit
88d94232e5

+ 5 - 7
src/types/englishSpeaking.ts

@@ -153,13 +153,8 @@ export interface SentenceEvaluation {
     fluency: number
   }
   feedback?: {
-    highlights: string[]
-    corrections: {
-      original: string
-      corrected: string
-      explanation: string
-    }[]
-    suggestions: string[]
+    comment: string
+    betterExpression: string
   }
 }
 
@@ -267,7 +262,10 @@ export interface SessionStartInfo {
 }
 
 // 对话报告
+export type DialogueReportStatus = 'evaluating' | 'ready' | 'failed' | 'incomplete'
+
 export interface DialogueReport {
+  status: DialogueReportStatus
   evaluation: OverallEvaluation
 }
 

+ 16 - 20
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -210,28 +210,24 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
 
   // ==================== Report ====================
 
-  function getReport(): Promise<DialogueReport> {
+  async function waitForReport(sessionId: string): Promise<DialogueReport> {
+    const maxAttempts = 30
+    const intervalMs = 2000
+    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+      const report = await api.getReport(sessionId)
+      if (report.status === 'ready' || report.status === 'failed' || report.status === 'incomplete') {
+        return report
+      }
+      await new Promise(resolve => setTimeout(resolve, intervalMs))
+    }
+    throw new Error('Report generation timed out')
+  }
+
+  async function getReport(): Promise<DialogueReport> {
     if (!sessionId.value) return Promise.reject(new Error('No session'))
 
-    return new Promise((resolve, reject) => {
-      let attempts = 0
-      const maxAttempts = 15 // 30s / 2s
-
-      const poll = async () => {
-        attempts++
-        try {
-          const report = await api.getReport(sessionId.value!)
-          resolve(report)
-        } catch {
-          if (attempts >= maxAttempts) {
-            reject(new Error('Report timeout'))
-          } else {
-            setTimeout(poll, 2000)
-          }
-        }
-      }
-      poll()
-    })
+    const report = await waitForReport(sessionId.value!)
+    return report
   }
 
   // ==================== TTS ====================

+ 31 - 26
src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue

@@ -23,7 +23,7 @@
           :key="sentence.id"
           class="sentence-card"
           :class="{ 'card-student': sentence.role === 'student', 'card-expanded': expandedIds.has(sentence.id) }"
-          @click="sentence.role === 'student' && sentence.score !== undefined && toggleExpand(sentence.id)"
+          @click="sentence.role === 'student' && hasDetails(sentence) && toggleExpand(sentence.id)"
         >
           <!-- 卡片主体 -->
           <div class="card-body" :class="{ 'body-student': sentence.role === 'student' }">
@@ -56,9 +56,9 @@
             </div>
 
             <!-- 评分标签 -->
-            <div v-if="sentence.role === 'student' && sentence.score !== undefined" class="card-right">
-              <span class="card-score" :class="getScoreClass(sentence.score)">{{ sentence.score }}</span>
-              <svg v-if="sentence.role === 'student'" class="expand-icon" :class="{ 'expanded': expandedIds.has(sentence.id) }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+            <div v-if="sentence.role === 'student' && hasDetails(sentence)" class="card-right">
+              <span v-if="sentence.score !== undefined" class="card-score" :class="getScoreClass(sentence.score)">{{ sentence.score }}</span>
+              <svg class="expand-icon" :class="{ 'expanded': expandedIds.has(sentence.id) }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                 <polyline points="6 9 12 15 18 9" />
               </svg>
             </div>
@@ -92,28 +92,13 @@
 
             <!-- 反馈信息 -->
             <div v-if="sentence.feedback" class="feedback-section">
-              <div v-if="sentence.feedback.highlights.length" class="feedback-block">
-                <div class="feedback-block-label"><span class="fb-good">✓</span> 亮点</div>
-                <ul class="feedback-list">
-                  <li v-for="(h, i) in sentence.feedback.highlights" :key="i">{{ h }}</li>
-                </ul>
+              <div v-if="sentence.feedback.comment" class="feedback-block">
+                <div class="feedback-block-label"><span class="fb-good">✓</span> 一句话点评</div>
+                <p class="feedback-text">{{ sentence.feedback.comment }}</p>
               </div>
-              <div v-if="sentence.feedback.corrections.length" class="feedback-block">
-                <div class="feedback-block-label"><span class="fb-fix">✎</span> 改正</div>
-                <div v-for="(c, i) in sentence.feedback.corrections" :key="i" class="correction-item">
-                  <div class="correction-row">
-                    <span class="correction-original">{{ c.original }}</span>
-                    <span class="correction-arrow">→</span>
-                    <span class="correction-corrected">{{ c.corrected }}</span>
-                  </div>
-                  <p class="correction-explanation">{{ c.explanation }}</p>
-                </div>
-              </div>
-              <div v-if="sentence.feedback.suggestions.length" class="feedback-block">
-                <div class="feedback-block-label"><span class="fb-suggest">💡</span> 建议</div>
-                <ul class="feedback-list">
-                  <li v-for="(s, i) in sentence.feedback.suggestions" :key="i">{{ s }}</li>
-                </ul>
+              <div v-if="sentence.feedback.betterExpression" class="feedback-block">
+                <div class="feedback-block-label"><span class="fb-suggest">→</span> 进阶表达</div>
+                <p class="better-expression">{{ sentence.feedback.betterExpression }}</p>
               </div>
             </div>
           </div>
@@ -159,12 +144,16 @@ function toggleAll() {
     expandedIds.value.clear()
   } else {
     props.sentenceEvaluations
-      .filter(s => s.role === 'student' && s.score !== undefined)
+      .filter(s => s.role === 'student' && hasDetails(s))
       .forEach(s => expandedIds.value.add(s.id))
   }
   allExpanded.value = !allExpanded.value
 }
 
+function hasDetails(sentence: SentenceEvaluation) {
+  return Boolean(sentence.pronunciation || sentence.feedback)
+}
+
 function formatDuration(seconds: number) {
   const mins = Math.floor(seconds / 60)
   const secs = seconds % 60
@@ -307,6 +296,22 @@ function getScoreClass(score: number) {
 .fb-good { color: #22c55e; }
 .fb-fix { color: #3b82f6; }
 .fb-suggest { color: #f59e0b; }
+.feedback-text {
+  margin: 0;
+  font-size: 11px;
+  color: #4b5563;
+  line-height: 1.5;
+}
+.better-expression {
+  margin: 0;
+  padding: 8px 10px;
+  background: #fff;
+  border: 1px solid #f3f4f6;
+  border-radius: 8px;
+  font-size: 12px;
+  color: #111827;
+  line-height: 1.5;
+}
 .feedback-list {
   margin: 0;
   padding-left: 16px;

+ 29 - 10
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -48,6 +48,9 @@
     <!-- Completed 阶段:直接渲染报告 -->
     <div v-else class="report-stage">
       <div class="report-scroll">
+        <div v-if="reportError" class="report-error">
+          {{ reportError }}
+        </div>
         <OverallReport
           :evaluation="displayEvaluation"
           :role="mockRole"
@@ -155,9 +158,8 @@ const mockEvaluation: OverallEvaluation = {
       audioDuration: 4, score: 88,
       pronunciation: { accuracy: 90, intonation: 85, stress: 82, fluency: 88 },
       feedback: {
-        highlights: ['Good pronunciation of "pandas"', 'Natural intonation'],
-        corrections: [],
-        suggestions: ['Try: "I really love pandas because they\'re incredibly cute!"'],
+        comment: 'Good pronunciation of "pandas" with natural intonation.',
+        betterExpression: 'I really love pandas because they are incredibly cute!',
       },
     },
     {
@@ -171,9 +173,8 @@ const mockEvaluation: OverallEvaluation = {
       audioDuration: 3, score: 92,
       pronunciation: { accuracy: 95, intonation: 90, stress: 88, fluency: 92 },
       feedback: {
-        highlights: ['Excellent fluency', 'Clear past tense usage'],
-        corrections: [],
-        suggestions: ['Add more details: "I visited the Beijing Zoo last month and saw giant pandas there."'],
+        comment: 'Excellent fluency and clear past tense usage.',
+        betterExpression: 'I visited the Beijing Zoo last month and saw giant pandas there.',
       },
     },
     {
@@ -187,19 +188,20 @@ const mockEvaluation: OverallEvaluation = {
       audioDuration: 3, score: 78,
       pronunciation: { accuracy: 80, intonation: 75, stress: 76, fluency: 82 },
       feedback: {
-        highlights: ['Correct answer'],
-        corrections: [{ original: 'like to eat', corrected: 'mainly feed on', explanation: '"feed on" is more natural for describing animal diets' }],
-        suggestions: ['Expand: "Pandas mainly feed on bamboo, but they also eat fruits and vegetables."'],
+        comment: 'Correct answer. Try using a more natural phrase for animal diets.',
+        betterExpression: 'Pandas mainly feed on bamboo, but they also eat fruits and vegetables.',
       },
     },
   ],
 }
 
 const realEvaluation = ref<OverallEvaluation | null>(null)
+const reportStatus = ref<DialogueReport['status'] | null>(null)
+const reportError = ref('')
 
 const displayEvaluation = computed<OverallEvaluation>(() => {
   const real = realEvaluation.value
-  if (real && real.sentenceEvaluations.length > 0) return real
+  if (real) return real
   return mockEvaluation
 })
 
@@ -235,13 +237,19 @@ async function startDialogue() {
 }
 
 function handleDialogueComplete(report: DialogueReport | null) {
+  reportStatus.value = report?.status ?? null
   realEvaluation.value = report?.evaluation ?? null
+  if (report?.status === 'failed') reportError.value = '报告生成失败,部分语音评分未完成。'
+  else if (report?.status === 'incomplete') reportError.value = '本次练习没有足够的有效回答生成报告。'
+  else reportError.value = ''
   dialogueState.value = 'completed'
 }
 
 function resetPreview() {
   dialogueState.value = 'ready'
   realEvaluation.value = null
+  reportStatus.value = null
+  reportError.value = ''
   preparedSession.value = null
   sessionError.value = null
   sessionCreating.value = false
@@ -410,5 +418,16 @@ onUnmounted(() => { speakingStore.setPreviewState('ready') })
   background: #e5e7eb;
   margin: 0;
 }
+.report-error {
+  max-width: 448px;
+  margin: 0 auto 12px;
+  padding: 10px 12px;
+  border: 1px solid #fed7aa;
+  border-radius: 8px;
+  background: #fff7ed;
+  color: #c2410c;
+  font-size: 12px;
+  line-height: 1.5;
+}
 
 </style>

+ 50 - 21
src/views/Editor/EnglishSpeaking/services/llmService.ts

@@ -76,9 +76,8 @@ interface BackendEvaluation {
   prosodyScore: number | null
   wordAnalysis: unknown
   contentFeedback: {
-    highlights: string[]
-    corrections: { original: string; corrected: string; explanation: string }[]
-    suggestions: string[]
+    comment: string
+    betterExpression: string
   } | null
 }
 
@@ -93,28 +92,42 @@ interface BackendRound {
 interface BackendReportResponse {
   sessionId: string
   topic: string
-  status: 'evaluating' | 'ready'
+  status: 'evaluating' | 'ready' | 'failed' | 'incomplete'
   rounds: BackendRound[]
+  overall: BackendOverall | null
   summary: string | null
 }
 
+interface BackendOverall {
+  aiComment: string
+  highlights: string[]
+  improvements: string[]
+}
+
 function adaptReport(raw: BackendReportResponse): DialogueReport {
-  const sentenceEvaluations: SentenceEvaluation[] = raw.rounds.map((r, idx) => ({
-    id: `${raw.sessionId}-${idx}`,
-    round: r.round,
-    role: r.role,
-    content: r.content,
-    audioUrl: r.audioUrl ?? undefined,
-    pronunciation: r.evaluation && r.role === 'student'
+  const sentenceEvaluations: SentenceEvaluation[] = raw.rounds.map((r, idx) => {
+    const pronunciation = r.evaluation && r.role === 'student'
       ? {
           accuracy: r.evaluation.accuracyScore ?? 0,
           fluency: r.evaluation.fluencyScore ?? 0,
           intonation: r.evaluation.prosodyScore ?? 0,
           stress: r.evaluation.completenessScore ?? 0,
         }
-      : undefined,
-    feedback: r.evaluation?.contentFeedback ?? undefined,
-  }))
+      : undefined
+
+    return {
+      id: `${raw.sessionId}-${idx}`,
+      round: r.round,
+      role: r.role,
+      content: r.content,
+      audioUrl: r.audioUrl ?? undefined,
+      score: pronunciation
+        ? Math.round((pronunciation.accuracy + pronunciation.fluency + pronunciation.intonation + pronunciation.stress) / 4)
+        : undefined,
+      pronunciation,
+      feedback: r.evaluation?.contentFeedback ?? undefined,
+    }
+  })
 
   const studentEvals = sentenceEvaluations.filter(s => s.role === 'student' && s.pronunciation)
   const avg = studentEvals.length > 0
@@ -125,22 +138,37 @@ function adaptReport(raw: BackendReportResponse): DialogueReport {
         ) / studentEvals.length,
       )
     : 0
+  const avgDim = (key: 'accuracy' | 'fluency' | 'intonation' | 'stress') => {
+    if (studentEvals.length === 0) return 0
+    return Math.round(studentEvals.reduce((sum, s) => sum + (s.pronunciation?.[key] ?? 0), 0) / studentEvals.length)
+  }
+  const overall = raw.overall
+  const highest = studentEvals.reduce<SentenceEvaluation | null>(
+    (best, s) => (!best || (s.score ?? 0) > (best.score ?? 0) ? s : best),
+    null,
+  )
 
   return {
+    status: raw.status,
     evaluation: {
       overallScore: avg,
       scoreLevel: avg >= 85 ? 'excellent' : avg >= 70 ? 'good' : avg >= 60 ? 'fair' : 'needsWork',
       percentile: 0,
-      dimensions: { fluency: 0, interaction: 0, vocabulary: 0, grammar: 0 },
-      aiComment: raw.summary ?? '',
-      highlights: [],
-      improvements: [],
+      dimensions: {
+        fluency: avgDim('fluency'),
+        interaction: avgDim('intonation'),
+        vocabulary: avgDim('stress'),
+        grammar: avgDim('accuracy'),
+      },
+      aiComment: overall?.aiComment ?? raw.summary ?? '',
+      highlights: overall?.highlights ?? [],
+      improvements: overall?.improvements ?? [],
       nextChallenge: {},
       statistics: {
-        totalRounds: Math.max(...sentenceEvaluations.map(s => s.round), 0),
+        totalRounds: sentenceEvaluations.length ? Math.max(...sentenceEvaluations.map(s => s.round)) : 0,
         averageScore: avg,
-        highestScore: 0,
-        highestRound: 0,
+        highestScore: highest?.score ?? 0,
+        highestRound: highest?.round ?? 0,
         grammarErrors: 0,
         excellentExpressions: 0,
         totalDuration: 0,
@@ -308,6 +336,7 @@ export class MockDialogueAPI implements DialogueAPI {
 
   async getReport(_sessionId: string): Promise<DialogueReport> {
     return {
+      status: 'ready',
       evaluation: {
         overallScore: 85,
         scoreLevel: 'good',