Browse Source

feat(speaking): stream class report markdown in useClassSummary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 1 month ago
parent
commit
2a4d5df6f1
1 changed files with 31 additions and 57 deletions
  1. 31 57
      src/views/Student/components/SpeakingClassPanel/useClassSummary.ts

+ 31 - 57
src/views/Student/components/SpeakingClassPanel/useClassSummary.ts

@@ -2,9 +2,8 @@ import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue'
 import { lang } from '@/main'
 import { lang } from '@/main'
 import {
 import {
   listSpeakingSessionsByConfig,
   listSpeakingSessionsByConfig,
-  generateClassSummary,
+  streamClassReport,
   type ClassSessionSummary,
   type ClassSessionSummary,
-  type ClassSummaryResponse,
 } from '@/services/speaking'
 } from '@/services/speaking'
 import type { ClassStudent, ClassStudentSummary, Locale } from './types'
 import type { ClassStudent, ClassStudentSummary, Locale } from './types'
 
 
@@ -49,28 +48,6 @@ function mergeWithRoster(
   })
   })
 }
 }
 
 
-// 前端 fallback 规则版本(必须与后端 class_summary_rules.py 保持镜像)
-function frontendRuleBullet2(summaries: ClassStudentSummary[]): string {
-  const submitted = summaries.filter(s => s.status === 'submitted')
-  if (submitted.length === 0) return (lang as any).ssSpkWaitingFirst
-  const scores = submitted.map(s => s.overallScore).filter((x): x is number => x != null)
-  if (!scores.length) return (lang as any).ssSpkWaitingFirst
-  const avg = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
-  const max = Math.max(...scores)
-  return formatTpl((lang as any).ssSpkAvgScoreTpl, { avg, max })
-}
-
-function frontendRuleBullet3(summaries: ClassStudentSummary[]): string {
-  const total = summaries.length
-  const submitted = summaries.filter(s => s.status === 'submitted').length
-  const unfinished = total - submitted
-  const rate = total ? Math.round(submitted / total * 100) : 0
-  if (rate === 0)   return (lang as any).ssSpkWaitingStart
-  if (rate < 50)    return formatTpl((lang as any).ssSpkRemindUnfinishedTpl, { n: unfinished })
-  if (rate < 100)   return (lang as any).ssSpkProgressHalf
-  return (lang as any).ssSpkAllDone
-}
-
 export function useClassSummary(opts: UseClassSummaryOpts) {
 export function useClassSummary(opts: UseClassSummaryOpts) {
   // ─── list 数据 ─────────────────────────────────────────
   // ─── list 数据 ─────────────────────────────────────────
   const summaries = ref<ClassStudentSummary[]>([])
   const summaries = ref<ClassStudentSummary[]>([])
@@ -108,66 +85,63 @@ export function useClassSummary(opts: UseClassSummaryOpts) {
     refetchTimer = window.setTimeout(fetchClassSummary, 1000)
     refetchTimer = window.setTimeout(fetchClassSummary, 1000)
   }
   }
 
 
-  // ─── AI 总结(混合模式) ───────────────────────────────
-  const aiBackendBullets = ref<[string, string, string] | null>(null)
-  const aiLoading = ref(false)
-  const aiGeneratedAt = ref<string | null>(null)
-  let aiToken = 0
+  // ─── AI 报告(流式 Markdown) ───────────────────────────
+  const reportMarkdown = ref('')
+  const reportStreaming = ref(false)
+  const reportGeneratedAt = ref<string | null>(null)
+  const reportError = ref<string | null>(null)
+  let reportToken = 0
 
 
-  // bullet 1 永远 live(数字派生)
-  const liveBullet1 = computed(() => {
+  // 折叠态常驻行:完成人数/比例(纯前端派生,不需要 LLM)
+  const completionLine = computed(() => {
     const total = summaries.value.length
     const total = summaries.value.length
     const done = summaries.value.filter(s => s.status === 'submitted').length
     const done = summaries.value.filter(s => s.status === 'submitted').length
     const rate = total ? Math.round(done / total * 100) : 0
     const rate = total ? Math.round(done / total * 100) : 0
     return formatTpl((lang as any).ssSpkCompletedCountTpl, { done, total, rate })
     return formatTpl((lang as any).ssSpkCompletedCountTpl, { done, total, rate })
   })
   })
 
 
-  // 暴露的 3 条 bullet:第 1 条永远 live,2/3 跟随后端或 fallback
-  const aiBullets = computed<[string, string, string]>(() => {
-    const b1 = liveBullet1.value
-    if (aiBackendBullets.value) {
-      return [b1, aiBackendBullets.value[1], aiBackendBullets.value[2]]
-    }
-    return [b1, frontendRuleBullet2(summaries.value), frontendRuleBullet3(summaries.value)]
-  })
-
-  async function refreshAISummary() {
+  async function refreshReport() {
     if (!opts.configId.value) return
     if (!opts.configId.value) return
     if (!opts.studentArray.value.length) return
     if (!opts.studentArray.value.length) return
 
 
-    const token = ++aiToken
-    aiLoading.value = true
+    const token = ++reportToken
+    reportStreaming.value = true
+    reportError.value = null
+    reportMarkdown.value = ''
     try {
     try {
-      const userIds = opts.studentArray.value.map(s => s.userid)
-      const data: ClassSummaryResponse = await generateClassSummary(
-        opts.configId.value, userIds, opts.locale.value,
-      )
-      if (token !== aiToken) return
-      aiBackendBullets.value = data.bullets
-      aiGeneratedAt.value = data.generatedAt
+      const students = opts.studentArray.value.map(s => ({ userId: s.userid, name: s.name }))
+      for await (const chunk of streamClassReport(
+        opts.configId.value, students, opts.locale.value,
+      )) {
+        if (token !== reportToken) return
+        reportMarkdown.value += chunk
+      }
+      if (token !== reportToken) return
+      reportGeneratedAt.value = new Date().toISOString()
     } catch (e) {
     } catch (e) {
-      if (token !== aiToken) return
-      // 失败保持上次值;若从未成功,aiBullets 自动用前端 fallback
+      if (token !== reportToken) return
+      reportError.value = 'REPORT_FAILED'
     } finally {
     } finally {
-      if (token === aiToken) aiLoading.value = false
+      if (token === reportToken) reportStreaming.value = false
     }
     }
   }
   }
 
 
   // ─── 生命周期 ─────────────────────────────────────────
   // ─── 生命周期 ─────────────────────────────────────────
   onMounted(() => {
   onMounted(() => {
     fetchClassSummary()
     fetchClassSummary()
-    refreshAISummary()
+    refreshReport()
   })
   })
   onUnmounted(() => {
   onUnmounted(() => {
     if (refetchTimer) clearTimeout(refetchTimer)
     if (refetchTimer) clearTimeout(refetchTimer)
     fetchToken++
     fetchToken++
-    aiToken++
+    reportToken++
   })
   })
 
 
   return {
   return {
     // list 数据
     // list 数据
     summaries, loading, error, fetchClassSummary, scheduleRefetch,
     summaries, loading, error, fetchClassSummary, scheduleRefetch,
-    // AI 总结
-    aiBullets, aiLoading, aiGeneratedAt, refreshAISummary,
+    // AI 报告
+    completionLine, reportMarkdown, reportStreaming, reportGeneratedAt, reportError,
+    refreshReport,
   }
   }
 }
 }