# 英文口语 - 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 里注入参数：`courseid`、`userid`、`stage`、`task`、`tool`

**答题阶段:**
- 学生在 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_rounds` 或 `expires_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_workPage` 中 `atool=77` 的 `content` 字段存：

```json
{
  "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` 处理开始时加一段：

```python
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` 暴露：

```ts
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` 使用：

```ts
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.vue` 的 `handleHomeworkSubmit` 现在只遍历 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.vue` 的 `handleHomeworkSubmit`：全为 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.vue` 的 `complete` 事件携带 `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
