EnglishSpeakingIntegration.md 27 KB

英文口语 - PPT 组件集成设计

状态:决策收敛,待实现 日期:2026-04-22 相关系统:

  • PPT (/Users/buoy/Development/gitrepo/PPT) — 幻灯片编辑器,嵌在父页面 iframe 中
  • pbl-teacher-table (/Users/buoy/Development/gitrepo/pbl-teacher-table) — 父页面,课程/作业管理
  • cococlass-english-speaking-api (/Users/buoy/Development/gitrepo/cococlass-english-speaking-api) — 口语后端(FastAPI)
  • enspeak (/Users/buoy/Development/gitrepo/enspeak) — 原型项目,UI/交互参照基准

1. 背景

英文口语功能以 Vue 组件(toolType === 77atool='77')直挂在 PPT 项目里,不走 iframe 路径。这与 PPT 其他工具组件(选择题 45 / 问答 15 / AI 工具 72 / 截图 73)的 iframe 架构不同,因此提交、感知、展示各环节都需要在 Vue 组件体系下重新接上。

本文档固化与 pbl-teacher-table / cococlass-english-speaking-api 协同的设计契约。


2. 现状对比

2.1 PPT 其他工具组件的标准流程

  • 渲染:iframe,src 指向外部 URL,URL 注入 courseid / userid / stage / task / tool
  • 答题:学生在 iframe 内答题,答案只在 iframe 内存
  • 提交:学生点"提交" → Student/index.vue 遍历 slide 的 iframe,通过 iframe.contentWindow.submitWork(slideIndex) 拉答题 JSON
  • 存储POST addCourseWorks_workPagestage=0 / task=slideIndex / tool=0 / atool='15'|'45'|'72'...
  • 感知:Yjs 广播 { type:'homework_submitted', courseid, slideIndex, userid },教师侧幂等消费(调 getWork(true) 刷新)
  • 回显select_courseWorks_workPageData 返回 content → 解析 → 渲染

2.2 英文口语的差异

  • 渲染:Vue 组件 TopicDiscussionPreview,由 BaseFrameElement.vue 根据 toolType === 77 挂载(无 iframe,无 contentWindow
  • 答题:多轮语音对话,每轮经 POST /api/speaking/dialogue/speak(SSE 流)
  • 后端持久化DialogueSession / DialogueMessage / PronunciationEvaluation(音频 URL + 转录 + 评分)
  • 结果GET /api/speaking/dialogue/report?sessionId=xxx 返回完整报告(含 LLM summary)
  • 评分异步asyncio.create_task fire-and-forget,status: pending → completed | failed

3. 核心约束

  1. 数据单一所有权:音频 / 转录 / 评分 / summary 只存在口语后端。addCourseWorks_workPage 只存指针(sessionId + 进度摘要),不复制内容。
  2. 对话流畅性优先/speak 不能等评分;评分后台跑,对话途中不显示评分。
  3. 教师侧直接访问口语后端:pbl-teacher-table 通过 sessionId 调 /report;CORS 由后端配置解决。
  4. 与既有 homework_submitted 广播兼容:口语的 workPage 写入后同样发 Yjs 广播,教师侧无需新增分支(homework_submitted 消费是幂等的,都走 getWork(true))。
  5. UI 全盘还原 enspeak 原型:所有可视元素(布局、配色、动效、状态机文案、消息气泡、录音按钮、倒计时、空/错/过渡态、结果页四维分、summary 展示等)严格对齐 /Users/buoy/Development/gitrepo/enspeak。实现方式是React TSX → Vue 3 移植:保留行为与视觉 1:1,语法层面由 hooks→composables、JSX→template、useState→ref/reactive、useEffect→watch/watchEffect/onMounted;不得为"Vue 更自然"而改动交互。映射表见 §5.4。

4. 数据契约

4.1 addCourseWorks_workPage payload

uid     = userid
cid     = courseid
stage   = '0'
task    = String(slideIndex)
tool    = '0'
atool   = '77'
type    = '21'
content = JSON.stringify({
  sessionId:       "uuid-xxx",
  totalRounds:     3,
  completedRounds: 2,
  isComplete:      false,
  lastUpdatedAt:   "2026-04-22T10:15:30Z"
})
  • 每轮对话结束都写一次(支持 B1 resume:刷新后能判断 in-progress)
  • 重做直接覆盖addCourseWorks_workPage 原生是 upsert 语义,无需历史数组)

4.2 后端接口(cococlass-english-speaking-api)

方法 路径 作用
POST /api/speaking/dialogue/session 创建 session,返回 sessionId + AI 开场白
POST /api/speaking/dialogue/speak 提交一轮录音,SSE 流式响应
GET /api/speaking/dialogue/report?sessionId=xxx 拉完整报告(含历史 + 评分 + summary)

4.3 SSE 事件类型(/speak

event data 触发
transcript { text, round } ASR 完成后
token { content } LLM 流式输出每个 token(最后一轮不发
done { isComplete, nextRound } 本轮所有 DB 操作完成
error { message } 异常兜底(DB 已 rollback)

4.4 /report 返回结构(取关键字段)

{
  sessionId: string
  topic: string
  status: 'evaluating' | 'ready'    // evaluating = 还有评分在跑
  rounds: Array<{
    round: number
    role: 'ai' | 'student'
    content: string
    audioUrl: string | null
    evaluation?: {                  // 仅 role='student' 且评分有结果
      status: 'pending' | 'completed' | 'failed'
      accuracyScore: number
      fluencyScore: number
      completenessScore: number
      prosodyScore: number
      wordAnalysis: unknown
    }
  }>
  summary: string | null            // 全部评分完成(含 failed)后生成
}

5. 组件边界与注入

5.1 分层

TopicDiscussionPreview.vue        ← runner context 注入层(provide SPEAKING_RUNNER_KEY)
  └─ DialogueChatView.vue         ← 纯 UI 状态机 + SSE 消费(inject)
  • DialogueChatView 不直接调 submitWork、不广播 Yjs、不知道 PPT 外壳的存在
  • TopicDiscussionPreview(或更外层 wrapper)负责把 PPT 运行时上下文(courseId / userId / slideIndex / mode)和"提交能力"注入下去

5.2 Inject key 与 context

// src/views/Editor/EnglishSpeaking/types.ts (新增)
export const SPEAKING_RUNNER_KEY = Symbol('english-speaking-runner')

export interface RoundSnapshot {
  sessionId: string
  round: number
  transcript: string
  audioUrl: string
  totalRounds: number
}

export interface SessionSnapshot {
  sessionId: string
  totalRounds: number
  completedRounds: number
  isComplete: boolean
}

export interface SpeakingRunnerContext {
  userId: string
  courseId: string
  slideIndex: number
  mode: 'preview' | 'student' | 'teacher'
  onRoundFinished: (snap: RoundSnapshot) => Promise<void>
  onSessionComplete: (snap: SessionSnapshot) => Promise<void>
  initialSessionId?: string       // B1 resume 时由 parent 读 workPage 后塞入
}
  • mode==='preview':编辑器预览, submitWork,不调真后端(走 mock / 直通)
  • mode==='student':Student/index.vue 场景,onRoundFinished / onSessionComplete 里实际调 submitWork + Yjs 广播
  • mode==='teacher':教师只读回放,不新建 session

5.3 DialogueChatView Props & Emits

Props(参照 enspeak DialogueChatView.tsx:200-214):

defineProps<{
  topic: string
  keywords?: string[]
  totalRounds?: number           // 默认 3
  timeLimitSeconds?: number      // 未传 = 无倒计时(正计时);传 = 倒计时
  aiName?: string
  aiAvatar?: string
  showEnglishText?: boolean
  showChineseText?: boolean
  showTaskHint?: boolean
  showSilenceHint?: boolean
  mode: 'preview' | 'student' | 'teacher'
}>()

Emits

defineEmits<{
  (e: 'session-start', sessionId: string): void
  (e: 'round-finished', payload: RoundSnapshot): void
  (e: 'complete', payload: SessionSnapshot): void
  (e: 'error', payload: { code: ErrorCode; message?: string }): void
}>()

type ErrorCode =
  | 'asr_failed'         // /speak error 事件
  | 'network_error'      // SSE 连接中断 / 请求失败
  | 'session_expired'    // 409 Session is not active
  | 'permission_denied'  // 麦克风权限拒绝
  | 'unknown'
  • 无 "role / identity / personality / speakingStyle / speed" 字段(产品明确不需要)
  • 无"结束对话"按钮(MVP 不允许学生主动放弃)

5.4 UI 还原基准(enspeak → Vue 映射)

全部 UI 从 enspeak 原型移植,视觉与交互 1:1。以下是源文件与目标 Vue 文件的映射。所有 UI 细节(文案、颜色、动效时长、气泡样式、按钮状态切换、空态、提示 modal、倒计时样式、结果页四维分卡片、summary 排版等)以 enspeak 为准,不自行发挥。

功能区域 enspeak 源(React TSX) PPT 目标(Vue)
对话主界面(状态机 + 录音按钮 + 消息气泡 + 倒计时显示 + 过渡动画) enspeak/src/components/dialogue/DialogueChatView.tsx (47KB,主干) src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
话题预览(配置面板进入对话前的 landing) enspeak/src/components/dialogue/DialoguePreview.tsx src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue(入口部分)
详细报告页(每轮录音 / transcript / 四维分 / word analysis) enspeak/src/components/dialogue/DetailedReport.tsx (17KB) src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue
总览报告页(四维分汇总 + summary + 总体评价) enspeak/src/components/dialogue/OverallReport.tsx (8KB) src/views/Editor/EnglishSpeaking/preview/OverallReport.vue
结果入口(控制详细/总览切换) enspeak/src/components/dialogue/DialogueResult.tsx 合入 TopicDiscussionPreview.vue 的完成态分支或新建 DialogueResult.vue
提示弹窗(沉默提示 / 帮助等) enspeak/src/components/dialogue/HintDialog.tsx src/views/Editor/EnglishSpeaking/preview/HintDialog.vue
实时反馈条(录音中的声纹/能量指示) enspeak/src/components/dialogue/RealtimeFeedback.tsx src/views/Editor/EnglishSpeaking/preview/RealtimeFeedback.vue
话题讨论父容器(上下文包装层) enspeak/src/components/preview/TopicDiscussionPreview.tsx src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue(包装部分)

移植规则(TSX → Vue)

React 原语 Vue 对应
useState ref / reactive
useRef ref(DOM 引用) / shallowRef(持有对象引用如 MediaRecorder)
useEffect(..., [deps]) watch(deps, cb) / watchEffect / onMounted + onBeforeUnmount
useMemo computed
useCallback 普通函数即可(Vue 不需要引用稳定性)
Props with optional callbacks defineEmits + 父组件 listener
条件 render {cond && <X/>} v-if / v-show(动画场景慎选 v-show
className={clsx(...)} :class="[...]"
Tailwind classes 保留 class 名不动(Tailwind 在 PPT 项目若可用直接沿用;若不可用,按 class 名对照抽 CSS vars)

不移植项(enspeak 独有,PPT 不需要):

  • 角色选择器 RoleSelector.tsx、话题选择器 TopicSelector.tsx(PPT 编辑器侧已有自己的配置面板)
  • 自定义角色/话题 modal CustomRoleModal.tsx / CustomTopicModal.tsx(同上)
  • DialogueChat.tsx(enspeak 的路由容器,PPT 不需要路由层)

可能的对齐差异(实现时标注在代码注释):

  • 字体:enspeak 可能用 Inter / 系统字体,PPT 沿用项目字体栈
  • 尺寸:enspeak 是独立页面(全屏),PPT 是 slide 内嵌组件,需按 elementInfo 的 width/height 缩放;保持相对比例不变
  • 主题色:enspeak 有自己的色板,如与 PPT 冲突以 PPT 为准(最小改动)

6. 会话生命周期

6.1 B1 — 刷新 / 返回时恢复(允许 resume)

挂载时,Parent wrapper 按 workPage 状态分三支:

读 workPage.content
├─ 无记录 / content 为空          → 新建 session (POST /session)
├─ isComplete = true              → 直接渲染报告 (GET /report)
└─ isComplete = false + sessionId → 恢复模式
      └─ GET /report 拿已有 rounds
      └─ DialogueChatView 初始化 history + 定位 currentRound
      └─ 用户点"开始录音"继续

降级

  • /speak 返回 409 Session is not active(后端超时/已 completed)→ 降级为"展示最终报告"或"新建一次"(看 /report 的 status)
  • /report 返回 404(极少见)→ 直接新建

6.2 B2 — 重做(重新进入已完成页面)

  • 点"重做" → 新建 v2,即刻覆盖 workPage(第 1 轮结束时写入 {sessionId:v2, isComplete:false, completedRounds:1}
  • 只保留最新一份,不维护历史数组
  • v1 原始数据仍在后端 DialogueSession 表(通过 uuid 可查),但前端无恢复入口

6.3 B3 — 时长限制(倒计时)

  • 老师侧可配置 timeLimitSeconds(slide 属性,传递给 /sessiondurationSeconds),UI 对齐 enspeak DialogueChatView.tsx:200 / 292-315
  • timeLimitSeconds 未设置 = 正计时(仅轮数限制)
  • timeLimitSeconds 有值 = 倒计时
  • 到期处理(α 方案):倒计时到 0 → 禁用录音按钮 → 若正在录音则取消录音,不调 /speak → 触发 onSessionComplete 走报告页

不变量

  1. 倒计时以后端 expires_at 为真实依据;前端显示是客户端时钟(允许偏差但不超过几秒)
  2. 重新可见时如已过期,立即跳报告页
  3. 不可能真正"暂停"倒计时expires_at 是绝对时间戳)

6.4 B4 — 对话中 PPT 翻页(组件不卸载)

ScreenSlideList.vue:27 使用 v-show(非 v-if),翻页后 DialogueChatView 仍挂载。行为(γ+ 方案):

  • 翻走 → 倒计时继续跑(反映真实 wall-clock)
  • 翻走时若正在录音 → 取消 in-flight 录音(禁止上传到 /speak)
  • 翻回 → 重新同步后端状态:若已过期,跳报告页;未过期,可以继续下一轮,UI 显示"已消耗 XX 秒"

硬规则

  1. 不可见时必须取消 in-flight 录音
  2. 倒计时以 expires_at 为准;前端过期检测基于剩余时间 ≤ 0

7. 错误态

7.1 A1 — ASR 失败

  • 前端缓存 MediaRecorder Blob(录音结束后不释放)
  • 收到 error 事件 → toast "识别失败" + 显示「重试」按钮
  • 点重试 → 用同一份 blob 再 POST /speak(sessionId 相同,round 未前进)
  • 2 次都失败 → 按钮变「重新录制」,清 blob 回录音态

为什么幂等/speak 失败走 rollbackdialogue_service.py:203),student_msg / evaluation 都未落库;S3 key {sessionId}/round_{round}.webm 重传覆盖,无脏数据。

7.2 A2 — Azure 发音评分失败(后台)

后端改动_evaluate_pronunciation):加 1 次重试

for attempt in range(2):
    try:
        result = await self.assessor.assess(...)
        # set completed fields, break
        break
    except Exception as e:
        if attempt == 0:
            logger.warning(f"Eval attempt 1 failed, retrying: {e}")
            await asyncio.sleep(1)
            continue
        evaluation.status = "failed"
        evaluation.error_message = str(e)

结果页渲染规则

status UI
completed 展示四项分数
failed 整个评分块(不显示占位/错误文字)
pending 继续轮询

Summary:后端 _generate_summary 仅把 status == 'completed' 的评分喂给 LLM(dialogue_service.py:295),failed 轮自动被跳过,无需额外改。

7.3 A3 — "结束录音"并发点击

前端状态机(不依赖后端幂等):

idle → recording → [点击 finish] → submitting → [SSE done] → idle
                                 ↑ 期间所有点击无效
  • recording → 收到 finish 点击:立即置 submitting,按钮禁用
  • submitting 期间任何点击丢弃
  • done 事件到达才切回 idle / 转 report

7.4 A4 — 网络中断

场景 处理
/session 创建断网 toast + 手动重试;未进入对话 UI
/speak SSE 流中断 toast "网络异常,请刷新重试" → 用户手动刷新 → 走 B1 resume,从服务端真相重建
/report 轮询断 指数退避自动重试(见 §8)
idle 时断网 无感,下次操作才发现

不自动重试 /speak:后端 commit 在 LLM stream 之后(dialogue_service.py:185),自动重试可能造成重复轮次。让刷新走 resume,状态由服务端真相决定。


8. 报告轮询策略

结束后调 GET /report,若 status === 'evaluating' 则进入轮询。

退避序列(总上限 ~30s):

1s, 1s, 1s, 2s, 3s, 5s, 5s, 5s, 5s  (cumulative: 1,2,3,5,8,13,18,23,28s)

UI 时间阈值文案

累计等待 文案
0-15s "正在生成报告..."
15-30s "评分耗时较长,请稍候..."
30-60s "仍在处理,建议稍后刷新查看"
> 60s toast "评分未能完成,请稍后刷新;音频和对话内容已保存"

最终失败 fallback:显示当前已拿到的部分数据(pending 的评分块留空),不阻塞用户阅读历史对话。


9. 时序

9.1 E1 — 最后一轮特殊处理(后端改动)

产品决策:第 N 轮(N = totalRounds)以学生回应作为结束,不触发 AI 回复

后端改动dialogue_service.py::speak,约 line 160-175):

is_last_round = (current_round >= session.total_rounds)

# 评估记录、transcript yield、student_msg 写入保持不变

if not is_last_round:
    # ⑥ 构建 LLM prompt
    # ⑦ LLM 流式 (yield token)
    # ⑧ 写入 AI 消息
    ...

# ⑨ 推进轮次
session.current_round += 1
is_complete = session.current_round > session.total_rounds
...

SSE 流形态对比

  • 普通轮:transcript → token* → done
  • 最后一轮:transcript → done(无 token)

前端状态机:监听 SSE 时,收到 done 前未收到任何 token → "最后一轮"路径:保留学生 transcript 气泡,不渲染"AI 正在说",1.5s 过渡(显示"正在生成报告...")→ 进入结果页 + 启动轮询。

single-round sessiontotal_rounds = 1):AI 开场白(create_session 时已生成)→ 学生说一轮 → 无 AI 回复 → 直接结束。自洽。

9.2 E2 — 回滚不变量

副作用 异常回滚行为
S3 音频文件 不回滚(可能遗留孤儿文件;重试同 key 覆盖,无害)
student_msg / evaluation(flushed 未 commit) session 关闭自动丢弃 ✓
ai_msg / round 递增 / session status 未 commit 即异常,rollback 清理 ✓

硬不变量:每轮要么完整成功(全部写入 + commit),要么完全没发生(从 /report 看不到这一轮)。

SSE 中断但后端已 commit 的情况(网络断在 yield done 之前但 commit 已完成):前端看"连接断"但真相"这轮已完成"。处理方式:刷新页面走 B1 resume(α),/report 返回真相即可重建正确 UI。不做 SSE 自动重连。


10. 后端改动清单(cococlass-english-speaking-api)

改动 位置 必须?
_evaluate_pronunciation 加 1 次重试 app/service/speaking/dialogue_service.py:321-352 ✅ 必须
speak 最后一轮跳过 LLM + AI msg app/service/speaking/dialogue_service.py:160-175 ✅ 必须
CORS 允许 pbl-teacher-table 域名访问 /report FastAPI middleware 配置 ✅ 必须
S3 ACL 确认 public-read 已确认,保持现状

11. 前端改动清单(PPT)

改动 位置
定义 SPEAKING_RUNNER_KEY + SpeakingRunnerContext 类型 src/views/Editor/EnglishSpeaking/types.ts(新)
TopicDiscussionPreview.vue 作为 runner context 注入层:读 workPage → 决定 B1 resume / 新建 / 查看;provide SPEAKING_RUNNER_KEY src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
DialogueChatView.vue 重写为纯 UI + SSE 消费:inject runner context;Props/Emits 见 §5.3;状态机 §7.3;倒计时 §6.3;B4 isVisible 处理 §6.4。UI 严格对齐 enspeak(见 §5.4 映射) src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
Student/index.vue 通过 provide(SPEAKING_RUNNER_KEY, ...) 暴露 runner context src/views/Student/index.vue
Student/index.vue::handleHomeworkSubmit 处理全 toolType=77 slide:toast "对话结束后自动提交" src/views/Student/index.vue (~line 1749-1867)
详细报告页 / 总览报告页:按 §4.4 数据结构渲染 + 轮询逻辑 §8;UI 严格对齐 enspeak(见 §5.4 映射) src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue / OverallReport.vue
提示弹窗、实时反馈条等辅助 UI 按 §5.4 映射移植 src/views/Editor/EnglishSpeaking/preview/HintDialog.vue / RealtimeFeedback.vue
types/englishSpeaking.ts:加 timeLimitSeconds?: number;清理 role.identity / personality / speakingStyle / speed 等不需要的字段 src/types/englishSpeaking.ts

11.1 WIP 代码处置(开发起步前先做)

上一次提交(2ce4e49 chore: WIP checkpoint before english-speaking redesign)里保留了一批探索性代码,基于已被推翻的旧设计(老 inject key 'homeworkContext'、老 role 字段、SSE /report/stream 等)。新会话开始实现前应做显式处置,避免误把 WIP 当成可用基础。

文件 状态 建议
src/types/englishSpeaking.ts 含已废弃字段(Role.identity / Role.personality / PreviewAIRole.speakingStyle / PreviewAIRole.speed 按 §11 表的"清理" + 加 timeLimitSeconds 项执行;读 §5.3 Props 对齐
src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts 录音封装,基础设施类 通读 + 评估复用:录音本身与设计无耦合,若实现干净可保留;若依赖老类型则重写
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts 引用老类型 PreviewChatMessage / SessionConfig / DialogueReport + 老 MockDialogueAPI/RealDialogueAPI 抽象 重写。新设计里对话引擎的状态由 DialogueChatView + SpeakingRunnerContext 承载,不需要独立 engine composable(或 composable 要按新 inject 模式重构)
src/views/Editor/EnglishSpeaking/services/llmService.ts parseSSEStream 逻辑本身可能可复用;API_BASE 硬编码需改 env SSE 解析保留 / 其余重写。新设计没有 report/stream,只有 /session + /speak + /report 三个 endpoint
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue 基于旧 Props(有 role 字段)+ 旧状态机 重写(§11 主表已标明)。按 §5.4 从 enspeak TSX 1:1 移植
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue 基于旧流程(无 B1 resume / 无 runner context) 重写。按 §5.1 / §6.1 承担 runner context 注入 + B1 分支路由
public/pcm-recorder-worklet.js Audio worklet,基础设施类 若 enspeak 没对应 worklet(enspeak 用浏览器原生 MediaRecorder),按 §5.4 移植时对齐选择;目前文件保留
vite.config.ts 改动 应该是为了 worklet / 路径别名 先看 diff 决定是否保留

推荐动作:新会话开始时先 git diff 500179d HEAD -- src/ 回顾 WIP 改动;把"直接重写"的文件先 git checkout 500179d -- <path> 恢复到 checkpoint 前状态(干净起点),再按 §11 主表从零实现。保留性工具(worklet / recorder)先不动,实现到对应环节再决定。

11.2 teacher-table 侧

改动 位置
识别 atool='77' 的 workPage content(含 sessionId) pbl-teacher-table/src/components/pages/workPage/index.vue
"口语详情"视图:直接调 GET /api/speaking/dialogue/report?sessionId=xxx 渲染 新组件
复用现有 homework_submitted 消费(幂等的 getWork(true))— 无需新增

12. 已知缺陷 / Known Limitations

  1. 评分任务进程重启会丢失asyncio.create_task(self._evaluate_pronunciation(...)) 是 fire-and-forget。若 uvicorn 进程在评分完成前重启 / 崩溃,那一轮评分会永远 status='pending'/report 不会生成 summary。MVP 接受;未来应改为持久化任务队列(Celery / RQ / DB polling)。
  2. 客户端时钟偏差:倒计时显示以前端时钟为准。客户端时钟偏慢时,UI 可能显示"还剩 3s"但后端已过期;此时发起的 /speak 会得到 done + isComplete:true(录音被丢弃)。后端 expires_at 是唯一真相。
  3. workPage 只保留最新一份:学生重做后原作业在前端不可见(后端表里仍有记录,通过 sessionId 可查)。老师看到的始终是"最近一次"。
  4. SSE 无自动重连:网络中断只能刷新页面走 B1 resume。
  5. 音频孤儿文件/speak 失败时 S3 文件可能已上传,不自动清理;同 key 会被重试覆盖,实际无害。

13. 参考

  • addCourseWorks_workPage 提交模式:pbl-teacher-table/src/components/pages/workPage/index.vue:327, 394-408
  • submitWork 包装 + aichat S3 快照模式:PPT/src/views/Student/index.vue:1665-1849
  • homework_submitted Yjs 广播 + 幂等消费:PPT/src/views/Student/index.vue:2196, 3116-3124
  • 口语后端 /speak 主流程:cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:95-204
  • 口语后端 /report 主流程:cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:206-280
  • 后台评分:cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:321-352
  • enspeak 倒计时模式:enspeak/src/components/dialogue/DialogueChatView.tsx:200, 292-315
  • enspeak 结果页参考:enspeak/src/components/dialogue/DialogueResult.tsx
  • PPT slide 渲染(v-show 行为):PPT/src/views/Screen/ScreenSlideList.vue:27
  • 轮询方案详解:/Users/buoy/Development/gitrepo/interview/network/polling.md

14. 本次未讨论 / 待实现阶段处理

  • 老师侧"口语详情"视图:UI 按 §5.4 从 enspeak DetailedReport.tsx + OverallReport.tsx 移植(布局是否需要压缩为嵌入式等实现阶段决定)
  • 编辑器 mode='preview' 下的 mock 数据/直通策略
  • 单 slide 上多个口语组件的处理(理论上可能,产品价值存疑,MVP 暂不支持)
  • showEnglishText / showChineseText / showTaskHint / showSilenceHint 几个展示开关的默认值由产品决定
  • 生产部署的 API_BASE 配置(llmService.ts:3 当前是 http://localhost:8000,需改环境变量)
  • B4 isVisible 的具体传递链路(ScreenSlide 已经把 is-visible 传给子组件,DialogueChatView 接收方式到实现阶段确认)
  • Tailwind 在 PPT 项目的可用性(enspeak 使用 Tailwind;若 PPT 不开启,需把 class 转为 scoped CSS,保留样式值)