EnglishSpeakingIntegration.md 15 KB

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

状态: WIP,讨论中 日期: 2026-04-21 相关系统:

  • 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) - 口语后端

1. 背景

英文口语功能(toolType=77)以 Vue 组件形式直接写在 PPT 项目里,不走 iframe 嵌入路线,这与 PPT 现有其他工具组件(选择题 toolType=45、问答 toolType=15、AI 工具 toolType=72 等)的架构模式不同。

本文档梳理英文口语功能要与 PPT 内其他工具组件行为一致,需要补充和改造的内容。


2. 现状对比

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

Rendering:

  • 以 iframe 形式渲染,src 指向外部 URL(如 https://pbl.cocorobo.cn/work#workPageNew
  • URL 里注入参数:courseiduseridstagetasktool

答题阶段:

  • 学生在 iframe 内操作,答案只在 iframe 内存中
  • 没有每题自动保存机制,纯 Vue data binding

提交作业(单次触发):

  • 学生点"提交"按钮 → PPT Student/index.vue 遍历当前 slide 的所有 iframe
  • 对每个 iframe 调 iframe.contentWindow.submitWork(slideIndex) 拿到答题 JSON
  • api.submitWork()POST addCourseWorks_workPage
  • 通过 Yjs WebSocket 广播 { type: 'homework_submitted', courseid, slideIndex, userid }

addCourseWorks_workPage 存储结构:

uid, cid, stage=0, task=slideIndex, tool=0
atool = "45"(选择) / "15"(问答) / "72"(AI)
type  = "8" / "3" / "20"
content = encodeURIComponent(JSON.stringify(答题JSON))
  • 选择题 content: { testJson: [{ teststitle, checkList, userAnswer }] }
  • 问答 content: { answerQ, answer, fileList }
  • AI 工具 content: S3 文件 URL(iframe 内 exposed_outputs 序列化后上传)

取回展示:

  • select_courseWorks_workPageData 返回 content → 解析 JSON → 填回题目组件显示

2.2 英文口语的现状

Rendering:

  • 以 Vue 组件 TopicDiscussionPreview 渲染(直接在 DOM,不是 iframe)
  • BaseFrameElement.vue 根据 toolType === 77 判断后挂载

答题阶段:

  • 学生和 AI 对话,每轮经过 POST /api/speaking/dialogue/speak
  • 口语后端自动存储每轮对话到 DB:
    • dialogue_message (音频 URL + 转录文本)
    • pronunciation_evaluation (评分,后台异步填充)
    • dialogue_session (总体状态,当 current_round > total_roundsexpires_at 到期时自动 status='completed')

提交作业:

  • 完全缺失 - 对话结束后没有任何调用写入 addCourseWorks_workPage
  • Student/index.vue 的 submitWork 循环只遍历 iframe,找不到 toolType=77 的 Vue 组件

结果页:

  • GET /report?sessionId=xxx 轮询(最多 15 次,2s 间隔),等所有发音评估完成

父页面感知:

  • pbl-teacher-table 收不到任何"学生完成口语"的信号
  • 不知道 atool=77 是什么、如何展示

3. 关键架构约束(已确认)

  • 对话流畅性优先/speak 请求必须尽快返回 AI 响应,评分不能阻塞对话
  • 评分异步:发音评估在后端后台跑,通过 pronunciation_evaluation.status: pending → completed 追踪
  • 结果页才需要评分:对话途中不展示评分,评分只在对话结束后的结果页显示
  • 每条对话包含三件套:音频 URL + 转录文本 + 评分
  • 口语后端单一职责cococlass-english-speaking-api 只管口语部分,不承担 workPage 作业提交

4. 设计决策(已确认)

4.1 数据所有权

口语后端是口语数据的 source of truth - 音频、转录、评分、摘要都存在口语后端,不复制到 addCourseWorks_workPage

4.2 提交模型(模型 Z:双触发)

"完成" 和 "提交" 在口语场景里天然分开,与其他工具不同:

  • 其他工具(选择题/问答):提交 = 完成,只有一个触发点 —— 学生点"提交"按钮
  • 英文口语:完成是系统事件(轮次/时间到),提交是向 teacher-table 报告的动作

采用 模型 Z(双触发)

自动路径(主流程)

  • 轮次用完 / 时间到 → isComplete = true → 自动调 submitWork

手动路径(fallback)

  • 学生主动放弃(点"结束对话"按钮 —— 是否要加待定)→ 显式结束 → 触发提交

提交 ≠ 评分完成 - 两者进一步解耦:

  • 提交:学生对话结束 → 立即写入 addCourseWorks_workPage
  • 评分:后台异步完成,通过 SSE 增量推送给结果页

addCourseWorks_workPageatool=77content 字段存:

{
  "sessionId": "uuid-xxx",
  "totalRounds": 5,
  "completedRounds": 5,
  "completedAt": "2026-04-21T10:00:00Z"
}

注:是否追加评分摘要(overallScore 等)待讨论。

4.3 避免前端轮询

放弃对 GET /report 的轮询,改用 SSE 流式推送:

  • 新增 GET /report/stream?sessionId=xxx
  • 每个 pronunciation_evaluation 从 pending → completed 时推一条 evaluation_ready 事件
  • 全部完成 + 摘要生成完毕时推 done 事件
  • 结果页订阅该流,评分就绪即显示

4.4 Teacher 侧查看详情

  • 教师侧拿到 atool=77 的 work 记录 → 解析 content.sessionId
  • 点击查看详情 → 直接调口语后端 GET /report?sessionId=xxx 拿完整数据渲染

4.5 完成触发路径(含时间到期处理)

前置:两个独立的后端数据流

  • 评分(pronunciation_evaluation:每次 /speak 结束时后端自动派发 background task,不依赖会话是否结束,自驱完成
  • 总结(dialogue_session.summary:首次调 /report 时,若"所有评分完成 + 无 summary"则调 LLM 生成,依赖有人来查报告

四种触发路径的处理

情形 谁触发 isComplete 后端 session.status 谁负责置 'completed'
轮次用完 /speak done 事件(后端自检) /speak 后端自检(现有逻辑)
时间到 + 学生正在说 /speak done 事件(后端自检) /speak 后端自检(现有逻辑)
时间到 + 学生沉默 前端 countdown 到 0 /report 被调用时懒结束(新加逻辑)
关页面 无人管(数据废弃,不影响其他人)
学生主动放弃 点"结束对话"按钮(待定是否加) POST /session/{id}/end(待定是否加)

关键策略:/report 懒结束

后端 GET /report 处理开始时加一段:

if session.expires_at and now > session.expires_at and session.status == 'active':
    session.status = 'completed'
    session.completed_at = now
    # commit 后再继续原有报告生成逻辑
  • 零新接口、零新基础设施
  • 时间到 + 沉默场景:前端 isComplete=true → 走结果页 → 订阅 SSE(必调 /report 或 /report/stream)→ 后端懒结束
  • 轮次/说话中场景:/speak 已经处理,无变化

关页面场景(可接受的弃置)

  • 已派发的评分 background task 照跑照存 ✅
  • session.status 永远 'active'、summary 永远 null ⚠️
  • 但:addCourseWorks_workPage 无记录 → 教师侧看不到 → 没人会去调 /report → 没影响
  • 后端 cron 清理 stuck 'active' sessions 是可选的运维优化,功能上不需要

4.6 触发机制(模型 A:组件自提交 + provide/inject)

背景:其他工具走 iframe,Student/index.vue 通过 iframe.contentWindow.submitWork() 主动拉答题 JSON;英文口语是直挂 Vue 组件,这条路径不通。

决策:组件自己在 isComplete 变为 true 时提交,不让父层扫描。父层通过 provide 提供"广播+提交"能力,组件通过 inject 使用。

"一致性" 拆成三层看

是否需要一致 说明
L1 后端契约 必须 同样调 addCourseWorks_workPage,同样走 Yjs homework_submitted 广播 —— 教师侧无感知差异
L2 学生 UX 不必 选择题有"提交"按钮给"做完了"的反馈;口语的反馈是结果页(评分/总结),不需要再点一次按钮
L3 代码架构 不必 iframe.contentWindow.submitWork() 是 iframe 模式的产物,Vue 组件没有 contentWindow,强行模仿反而别扭

组件自提交只偏离 L3,对 L1/L2 无影响 —— 教师侧和 Yjs 协同逻辑完全复用既有机制。

接口约定

Student/index.vue 通过 provide 暴露:

provide('homeworkContext', {
  courseid,
  userid,
  slideIndex,
  submitAndBroadcast: async (atool: string, type: string, content: any) => {
    await api.submitWork({ uid, cid, stage:'0', task:slideIndex, tool:'0', atool, type, content })
    sendMessage({ type: 'homework_submitted', courseid, slideIndex, userid })
  }
})

DialogueChatView.vue(或 TopicDiscussionPreview)通过 inject 使用:

const ctx = inject<HomeworkContext | null>('homeworkContext', null)

watch(isComplete, async (done) => {
  if (!done) return
  if (props.mode === 'preview') return  // 编辑器预览不提交
  if (!ctx) return                       // 非 Student 场景(老师端预览/草稿)不提交
  await ctx.submitAndBroadcast('77', '77', {
    sessionId: sessionId.value,
    totalRounds,
    completedRounds: currentRound.value,
    completedAt: new Date().toISOString(),
  })
})

全局"提交作业"按钮在 toolType=77 场景

Student/index.vuehandleHomeworkSubmit 现在只遍历 iframe;当前 slide 全是 toolType=77(没有 iframe)时:

  • 不做任何网络请求
  • 弹 toast 提示:"口语对话结束后会自动提交,无需手动点击"

混合场景(slide 同时有 iframe 工具 + 口语组件):iframe 部分走原流程,口语部分由组件自提交处理,互不干扰。


5. 改动清单

5.1 口语后端 (cococlass-english-speaking-api)

改动 位置
新增 SSE 接口 GET /report/stream?sessionId=xxx app/api/dialogue.py
实现评估完成时推事件(内部事件总线或 DB 轮询触发) app/service/speaking/dialogue_service.py
GET /report 入口加懒结束逻辑:expires_at < now AND status='active' 时置 completed app/service/speaking/dialogue_service.py lines 206-280
(可选)POST /session/{id}/end 用于学生主动放弃 app/api/dialogue.py

5.2 PPT 前端

改动 位置
Student/index.vue 通过 provide('homeworkContext', ...) 暴露 { courseid, userid, slideIndex, submitAndBroadcast } src/views/Student/index.vue
Student/index.vuehandleHomeworkSubmit:全为 toolType=77 时走 toast 提示分支,不阻断混合场景 src/views/Student/index.vue (lines ~1749-1867)
TopicDiscussionPreview / DialogueChatView.vue 通过 inject('homeworkContext', null) + watch(isComplete) 自提交 src/views/Editor/EnglishSpeaking/preview/*.vue
DialogueChatView.vuecomplete 事件携带 sessionId src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
TopicDiscussionPreview 结果页改用 SSE,移除轮询 src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
llmService.ts 增加 streamReport(sessionId): AsyncGenerator<SSEEvent> src/views/Editor/EnglishSpeaking/services/llmService.ts

5.3 pbl-teacher-table

改动 位置
select_courseWorks_workPageData 返回 atool=77 时解析 content src/components/pages/workPage/index.vue 或新建口语查看组件
新增"口语详情"视图,调口语 API /report?sessionId=xxx 渲染 新组件
成绩列表添加 atool=77 类型展示(完成状态 + 简要信息) 教师作业查看入口

6. 数据流图

┌─────────────────┐         ┌──────────────────────┐
│  学生 (PPT)     │         │ pbl-teacher-table    │
└────────┬────────┘         └──────────┬───────────┘
         │                             │
         │ 对话中 (每轮)                 │
         │ POST /speak                  │
         ├────────► 口语后端            │
         │ (后端自动存 message + async eval)
         │                             │
         │ 对话结束                      │
         │                             │
         ├── submitWork(atool=77,      │
         │    content={sessionId,...}) │
         │    → addCourseWorks_workPage│
         │                             │
         ├── Yjs 广播 homework_submitted────► ✅ 看到"已完成"
         │                             │
         │                             │
         │ 结果页                        │
         │ GET /report/stream          │
         │    (SSE 增量推评分)          │
         ├────────► 口语后端            │
         │                             │
         │                             │  教师点详情
         │                             │ GET /report?sessionId=xxx
         │          口语后端 ◄──────────┤
         │                             │

7. 待讨论/待确认

  • 是否需要"结束对话"按钮(学生主动放弃的手动路径)?若需要,对应后端 POST /session/{id}/end
  • addCourseWorks_workPage.content 是否需要冗余存评分摘要(overallScore 等)?当前倾向:不存,保持单一数据源
  • 如果口语后端未来宕机,教师侧完全看不到任何成绩是否可接受?
  • SSE 报告流的事件结构详细定义(事件名、payload schema)
  • 编辑器预览模式(mode='preview')是否走同一套流程?还是完全跳过提交?
  • 与 Yjs homework_submitted 广播的兼容性:教师侧已有机制接收,直接复用即可
  • teacher table 中 atool=77 的展示入口(沿用 workPage 组件 / 新建独立组件)
  • 错误处理:submit 失败、SSE 断连、评分 stuck 超过 N 秒等场景
  • atool=77 对应的 type 字段取值(延用"77"还是另起一档,需与后端/教师端对齐)
  • inject key 命名(字符串 'homeworkContext' vs Symbol vs Pinia store 封装)
  • 同一张幻灯片放多个口语组件的处理(理论上可能,但 UX 价值存疑)
  • 学生刷新页面后是否支持续接原 session(当前设计:不支持,每次新建 session)

8. 参考

  • addCourseWorks_workPage 提交逻辑:pbl-teacher-table/src/components/pages/workPage/index.vue lines 359-411
  • submitWork 循环(iframe 扫描):PPT/src/views/Student/index.vue lines 1749-1867
  • Yjs 广播模式:PPT/src/views/Student/index.vue lines 3054-3093
  • 口语后端 /speak 流程:cococlass-english-speaking-api/app/service/speaking/dialogue_service.py lines 95-204
  • 口语后端 /report 流程:cococlass-english-speaking-api/app/service/speaking/dialogue_service.py lines 206-280
  • 评分后台任务:cococlass-english-speaking-api/app/service/speaking/dialogue_service.py lines 321-352