Explorar o código

docs: rewrite english-speaking integration design

Complete rewrite based on locked design decisions:

- Data contract: workPage content stores pointer only (sessionId +
  progress snapshot), speaking-api remains source of truth; teacher
  calls /report directly.
- Component boundary: DialogueChatView is pure UI + SSE consumer;
  TopicDiscussionPreview injects SpeakingRunnerContext via
  SPEAKING_RUNNER_KEY (Symbol). Removed role config fields.
- Session lifecycle:
  - B1 resume on refresh via workPage.content + GET /report
  - B2 redo overwrites workPage (single latest record)
  - B3 countdown aligned with enspeak prototype; α expiry (disable
    record, cancel in-flight, no /speak)
  - B4 page switch: countdown keeps ticking (expires_at is absolute),
    cancel in-flight recording on invisible, re-sync on return
- Error handling (A1-A4): cache blob + retry for ASR, backend retry
  once for pronunciation eval, state-machine mutex for double-click,
  manual refresh + B1 resume for SSE break.
- Timing:
  - E1 last round: student response ends session, no AI reply
    (backend skip LLM + ai_msg write)
  - E2 rollback invariant: round is atomic (commit-or-nothing)
- Polling: client-side backoff (1,1,1,2,3,5,5,5,5s, ~30s cap) +
  multi-tier UI messaging.
- Backend changes required: eval retry loop, last-round branch in
  speak(), CORS for teacher-table.
- Known limitations: fire-and-forget task loss on restart, client
  clock drift, overwrite-only workPage, no SSE auto-reconnect.

Supersedes previous WIP doc committed in the prior checkpoint.
jimmylee hai 2 meses
pai
achega
bd852375dd
Modificáronse 1 ficheiros con 359 adicións e 227 borrados
  1. 359 227
      doc/EnglishSpeakingIntegration.md

+ 359 - 227
doc/EnglishSpeakingIntegration.md

@@ -1,19 +1,20 @@
 # 英文口语 - 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`) - 口语后端
+> 状态:决策收敛,待实现
+> 日期: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. 背景
 
-英文口语功能(toolType=77)以 Vue 组件形式直接写在 PPT 项目里,不走 iframe 嵌入路线,这与 PPT 现有其他工具组件(选择题 toolType=45、问答 toolType=15、AI 工具 toolType=72 等)的架构模式不同
+英文口语功能以 Vue 组件(`toolType === 77`,`atool='77'`)直挂在 PPT 项目里,不走 iframe 路径。这与 PPT 其他工具组件(选择题 45 / 问答 15 / AI 工具 72 / 截图 73)的 iframe 架构不同,因此提交、感知、展示各环节都需要在 Vue 组件体系下重新接上
 
-本文档梳理英文口语功能要与 PPT 内其他工具组件行为一致,需要补充和改造的内容
+本文档固化与 pbl-teacher-table / cococlass-english-speaking-api 协同的设计契约
 
 ---
 
@@ -21,313 +22,444 @@
 
 ### 2.1 PPT 其他工具组件的标准流程
 
-**Rendering:**
-- 以 iframe 形式渲染,`src` 指向外部 URL(如 `https://pbl.cocorobo.cn/work#workPageNew`)
-- URL 里注入参数:`courseid`、`userid`、`stage`、`task`、`tool`
+- **渲染**:iframe,`src` 指向外部 URL,URL 注入 `courseid / userid / stage / task / tool`
+- **答题**:学生在 iframe 内答题,答案只在 iframe 内存
+- **提交**:学生点"提交" → `Student/index.vue` 遍历 slide 的 iframe,通过 `iframe.contentWindow.submitWork(slideIndex)` 拉答题 JSON
+- **存储**:`POST addCourseWorks_workPage`(`stage=0 / task=slideIndex / tool=0 / atool='15'|'45'|'72'...`)
+- **感知**:Yjs 广播 `{ type:'homework_submitted', courseid, slideIndex, userid }`,教师侧幂等消费(调 `getWork(true)` 刷新)
+- **回显**:`select_courseWorks_workPageData` 返回 content → 解析 → 渲染
 
-**答题阶段:**
-- 学生在 iframe 内操作,答案**只在 iframe 内存中**
-- **没有**每题自动保存机制,纯 Vue data binding
+### 2.2 英文口语的差异
 
-**提交作业(单次触发):**
-- 学生点"提交"按钮 → 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 }`
+- **渲染**: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`
 
-**`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` 序列化后上传)
+## 3. 核心约束
 
-**取回展示:**
-- `select_courseWorks_workPageData` 返回 `content` → 解析 JSON → 填回题目组件显示
+1. **数据单一所有权**:音频 / 转录 / 评分 / summary 只存在口语后端。`addCourseWorks_workPage` 只存**指针**(sessionId + 进度摘要),不复制内容。
+2. **对话流畅性优先**:`/speak` 不能等评分;评分后台跑,对话途中**不显示**评分。
+3. **教师侧直接访问口语后端**:pbl-teacher-table 通过 sessionId 调 `/report`;CORS 由后端配置解决。
+4. **与既有 homework_submitted 广播兼容**:口语的 workPage 写入后同样发 Yjs 广播,教师侧无需新增分支(`homework_submitted` 消费是幂等的,都走 `getWork(true)`)。
 
 ---
 
-### 2.2 英文口语的现状
+## 4. 数据契约
 
-**Rendering:**
-- 以 Vue 组件 `TopicDiscussionPreview` 渲染(直接在 DOM,不是 iframe)
-- 由 `BaseFrameElement.vue` 根据 `toolType === 77` 判断后挂载
+### 4.1 `addCourseWorks_workPage` payload
 
-**答题阶段:**
-- 学生和 AI 对话,每轮经过 `POST /api/speaking/dialogue/speak`
-- 口语后端**自动存储**每轮对话到 DB:
-  - `dialogue_message` (音频 URL + 转录文本)
-  - `pronunciation_evaluation` (评分,后台异步填充)
-  - `dialogue_session` (总体状态,当 `current_round > total_rounds` 或 `expires_at` 到期时自动 `status='completed'`)
+```
+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"
+})
+```
 
-**提交作业:**
-- **完全缺失** - 对话结束后没有任何调用写入 `addCourseWorks_workPage`
-- `Student/index.vue` 的 submitWork 循环只遍历 iframe,找不到 toolType=77 的 Vue 组件
+- **每轮对话结束都写一次**(支持 B1 resume:刷新后能判断 in-progress)
+- **重做直接覆盖**(`addCourseWorks_workPage` 原生是 upsert 语义,无需历史数组)
 
-**结果页:**
-- 调 `GET /report?sessionId=xxx` 轮询(最多 15 次,2s 间隔),等所有发音评估完成
+### 4.2 后端接口(cococlass-english-speaking-api)
 
-**父页面感知:**
-- pbl-teacher-table 收不到任何"学生完成口语"的信号
-- 不知道 atool=77 是什么、如何展示
+| 方法 | 路径 | 作用 |
+|---|---|---|
+| 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`)
 
-## 3. 关键架构约束(已确认)
+| event | data | 触发 |
+|---|---|---|
+| `transcript` | `{ text, round }` | ASR 完成后 |
+| `token` | `{ content }` | LLM 流式输出每个 token(**最后一轮不发**) |
+| `done` | `{ isComplete, nextRound }` | 本轮所有 DB 操作完成 |
+| `error` | `{ message }` | 异常兜底(DB 已 rollback) |
 
-- **对话流畅性优先**:`/speak` 请求必须尽快返回 AI 响应,评分不能阻塞对话
-- **评分异步**:发音评估在后端后台跑,通过 `pronunciation_evaluation.status: pending → completed` 追踪
-- **结果页才需要评分**:对话途中不展示评分,评分只在对话结束后的结果页显示
-- **每条对话包含三件套**:音频 URL + 转录文本 + 评分
-- **口语后端单一职责**:`cococlass-english-speaking-api` 只管口语部分,不承担 workPage 作业提交
+### 4.4 /report 返回结构(取关键字段)
+
+```ts
+{
+  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)后生成
+}
+```
 
 ---
 
-## 4. 设计决策(已确认)
+## 5. 组件边界与注入
 
-### 4.1 数据所有权
+### 5.1 分层
 
-**口语后端是口语数据的 source of truth** - 音频、转录、评分、摘要都存在口语后端,不复制到 `addCourseWorks_workPage`。
+```
+TopicDiscussionPreview.vue        ← runner context 注入层(provide SPEAKING_RUNNER_KEY)
+  └─ DialogueChatView.vue         ← 纯 UI 状态机 + SSE 消费(inject)
+```
 
-### 4.2 提交模型(模型 Z:双触发)
+- `DialogueChatView` 不直接调 `submitWork`、不广播 Yjs、不知道 PPT 外壳的存在
+- `TopicDiscussionPreview`(或更外层 wrapper)负责把 PPT 运行时上下文(`courseId / userId / slideIndex / mode`)和"提交能力"注入下去
 
-**"完成" 和 "提交" 在口语场景里天然分开**,与其他工具不同:
+### 5.2 Inject key 与 context
 
-- 其他工具(选择题/问答):**提交 = 完成**,只有一个触发点 —— 学生点"提交"按钮
-- 英文口语:**完成**是系统事件(轮次/时间到),**提交**是向 teacher-table 报告的动作
+```ts
+// 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
+}
 
-采用 **模型 Z(双触发)**:
+export interface SessionSnapshot {
+  sessionId: string
+  totalRounds: number
+  completedRounds: number
+  isComplete: boolean
+}
 
-**自动路径(主流程)**:
-- 轮次用完 / 时间到 → `isComplete = true` → 自动调 `submitWork`
+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 后塞入
+}
+```
 
-**手动路径(fallback)**:
-- 学生主动放弃(点"结束对话"按钮 —— 是否要加待定)→ 显式结束 → 触发提交
+- `mode==='preview'`:编辑器预览,**不** submitWork,不调真后端(走 mock / 直通)
+- `mode==='student'`:Student/index.vue 场景,`onRoundFinished` / `onSessionComplete` 里实际调 `submitWork` + Yjs 广播
+- `mode==='teacher'`:教师只读回放,不新建 session
 
-**提交 ≠ 评分完成** - 两者进一步解耦:
+### 5.3 DialogueChatView Props & Emits
 
-- **提交**:学生对话结束 → 立即写入 `addCourseWorks_workPage`
-- **评分**:后台异步完成,通过 SSE 增量推送给结果页
+**Props**(参照 enspeak `DialogueChatView.tsx:200-214`):
 
-`addCourseWorks_workPage` 中 `atool=77` 的 `content` 字段存:
+```ts
+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'
+}>()
+```
 
-```json
-{
-  "sessionId": "uuid-xxx",
-  "totalRounds": 5,
-  "completedRounds": 5,
-  "completedAt": "2026-04-21T10:00:00Z"
-}
+**Emits**:
+
+```ts
+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'
 ```
 
-> 注:是否追加评分摘要(overallScore 等)待讨论。
+- **无 "role / identity / personality / speakingStyle / speed" 字段**(产品明确不需要)
+- **无"结束对话"按钮**(MVP 不允许学生主动放弃)
 
-### 4.3 避免前端轮询
+---
 
-放弃对 `GET /report` 的轮询,改用 SSE 流式推送:
+## 6. 会话生命周期
 
-- 新增 `GET /report/stream?sessionId=xxx`
-- 每个 `pronunciation_evaluation` 从 pending → completed 时推一条 `evaluation_ready` 事件
-- 全部完成 + 摘要生成完毕时推 `done` 事件
-- 结果页订阅该流,评分就绪即显示
+### 6.1 B1 — 刷新 / 返回时恢复(允许 resume)
 
-### 4.4 Teacher 侧查看详情
+挂载时,Parent wrapper 按 workPage 状态分三支:
 
-- 教师侧拿到 `atool=77` 的 work 记录 → 解析 `content.sessionId`
-- 点击查看详情 → 直接调口语后端 `GET /report?sessionId=xxx` 拿完整数据渲染
+```
+读 workPage.content
+├─ 无记录 / content 为空          → 新建 session (POST /session)
+├─ isComplete = true              → 直接渲染报告 (GET /report)
+└─ isComplete = false + sessionId → 恢复模式
+      └─ GET /report 拿已有 rounds
+      └─ DialogueChatView 初始化 history + 定位 currentRound
+      └─ 用户点"开始录音"继续
+```
 
-### 4.5 完成触发路径(含时间到期处理)
+**降级**:
+- `/speak` 返回 409 Session is not active(后端超时/已 completed)→ 降级为"展示最终报告"或"新建一次"(看 `/report` 的 status)
+- `/report` 返回 404(极少见)→ 直接新建
 
-**前置:两个独立的后端数据流**
+### 6.2 B2 — 重做(重新进入已完成页面)
 
-- **评分(`pronunciation_evaluation`)**:每次 `/speak` 结束时后端自动派发 background task,**不依赖会话是否结束**,自驱完成
-- **总结(`dialogue_session.summary`)**:首次调 `/report` 时,若"所有评分完成 + 无 summary"则调 LLM 生成,**依赖有人来查报告**
+- 点"重做" → 新建 v2,**即刻覆盖 workPage**(第 1 轮结束时写入 `{sessionId:v2, isComplete:false, completedRounds:1}`)
+- **只保留最新一份**,不维护历史数组
+- v1 原始数据仍在后端 `DialogueSession` 表(通过 uuid 可查),但前端**无恢复入口**
 
-**四种触发路径的处理**
+### 6.3 B3 — 时长限制(倒计时)
 
-| 情形 | 谁触发 `isComplete` | 后端 `session.status` 谁负责置 'completed' |
-|---|---|---|
-| 轮次用完 | `/speak` done 事件(后端自检) | `/speak` 后端自检(现有逻辑) |
-| 时间到 + 学生正在说 | `/speak` done 事件(后端自检) | `/speak` 后端自检(现有逻辑) |
-| 时间到 + 学生沉默 | 前端 countdown 到 0 | `/report` 被调用时懒结束(新加逻辑) |
-| 关页面 | 无 | 无人管(数据废弃,不影响其他人) |
-| 学生主动放弃 | 点"结束对话"按钮(待定是否加) | `POST /session/{id}/end`(待定是否加) |
+- 老师侧可配置 `timeLimitSeconds`(slide 属性,传递给 `/session` 的 `durationSeconds`),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 回录音态
 
-**关键策略:`/report` 懒结束**
+**为什么幂等**:`/speak` 失败走 `rollback`(`dialogue_service.py:203`),`student_msg` / `evaluation` 都未落库;S3 key `{sessionId}/round_{round}.webm` 重传覆盖,无脏数据。
 
-后端 `GET /report` 处理开始时加一段:
+### 7.2 A2 — Azure 发音评分失败(后台)
+
+**后端改动**(`_evaluate_pronunciation`):加 1 次重试
 
 ```python
-if session.expires_at and now > session.expires_at and session.status == 'active':
-    session.status = 'completed'
-    session.completed_at = now
-    # commit 后再继续原有报告生成逻辑
+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)
 ```
 
-- 零新接口、零新基础设施
-- 时间到 + 沉默场景:前端 `isComplete=true` → 走结果页 → 订阅 SSE(必调 /report 或 /report/stream)→ 后端懒结束
-- 轮次/说话中场景:`/speak` 已经处理,无变化
+**结果页渲染规则**:
 
-**关页面场景(可接受的弃置)**
+| status | UI |
+|---|---|
+| `completed` | 展示四项分数 |
+| `failed` | 整个评分块**空**(不显示占位/错误文字) |
+| `pending` | 继续轮询 |
 
-- 已派发的评分 background task 照跑照存 ✅
-- `session.status` 永远 'active'、`summary` 永远 null ⚠️
-- 但:`addCourseWorks_workPage` 无记录 → 教师侧看不到 → **没人会去调 `/report` → 没影响**
-- 后端 cron 清理 stuck 'active' sessions 是**可选的运维优化**,功能上不需要
+**Summary**:后端 `_generate_summary` 仅把 `status == 'completed'` 的评分喂给 LLM(`dialogue_service.py:295`),failed 轮自动被跳过,无需额外改。
 
-### 4.6 触发机制(模型 A:组件自提交 + provide/inject)
+### 7.3 A3 — "结束录音"并发点击
 
-**背景**:其他工具走 iframe,`Student/index.vue` 通过 `iframe.contentWindow.submitWork()` 主动拉答题 JSON;英文口语是直挂 Vue 组件,这条路径不通。
+前端状态机(不依赖后端幂等):
 
-**决策**:组件自己在 `isComplete` 变为 true 时提交,不让父层扫描。父层通过 provide 提供"广播+提交"能力,组件通过 inject 使用。
+```
+idle → recording → [点击 finish] → submitting → [SSE done] → idle
+                                 ↑ 期间所有点击无效
+```
 
-**"一致性" 拆成三层看**
+- `recording` → 收到 finish 点击:立即置 `submitting`,按钮禁用
+- `submitting` 期间任何点击丢弃
+- `done` 事件到达才切回 `idle` / 转 report
 
-| 层 | 是否需要一致 | 说明 |
-|---|---|---|
-| L1 后端契约 | **必须** | 同样调 `addCourseWorks_workPage`,同样走 Yjs `homework_submitted` 广播 —— 教师侧无感知差异 |
-| L2 学生 UX | 不必 | 选择题有"提交"按钮给"做完了"的反馈;口语的反馈是结果页(评分/总结),不需要再点一次按钮 |
-| L3 代码架构 | 不必 | `iframe.contentWindow.submitWork()` 是 iframe 模式的产物,Vue 组件没有 contentWindow,强行模仿反而别扭 |
+### 7.4 A4 — 网络中断
 
-组件自提交只偏离 L3,对 L1/L2 无影响 —— 教师侧和 Yjs 协同逻辑完全复用既有机制。
+| 场景 | 处理 |
+|---|---|
+| `/session` 创建断网 | toast + 手动重试;未进入对话 UI |
+| `/speak` SSE 流中断 | toast "网络异常,请刷新重试" → 用户手动刷新 → 走 B1 resume,从服务端真相重建 |
+| `/report` 轮询断 | 指数退避自动重试(见 §8) |
+| idle 时断网 | 无感,下次操作才发现 |
 
-**接口约定**
+**不自动重试 `/speak`**:后端 commit 在 LLM stream 之后(`dialogue_service.py:185`),自动重试可能造成重复轮次。让刷新走 resume,状态由服务端真相决定。
 
-`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 })
-  }
-})
-```
+## 8. 报告轮询策略
 
-`DialogueChatView.vue`(或 `TopicDiscussionPreview`)通过 `inject` 使用:
+结束后调 `GET /report`,若 `status === 'evaluating'` 则进入轮询。
 
-```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(),
-  })
-})
+**退避序列**(总上限 ~30s):
+
+```
+1s, 1s, 1s, 2s, 3s, 5s, 5s, 5s, 5s  (cumulative: 1,2,3,5,8,13,18,23,28s)
 ```
 
-**全局"提交作业"按钮在 toolType=77 场景**
+**UI 时间阈值文案**:
 
-`Student/index.vue` 的 `handleHomeworkSubmit` 现在只遍历 iframe;当前 slide 全是 toolType=77(没有 iframe)时:
-- 不做任何网络请求
-- 弹 toast 提示:"口语对话结束后会自动提交,无需手动点击"
+| 累计等待 | 文案 |
+|---|---|
+| 0-15s | "正在生成报告..." |
+| 15-30s | "评分耗时较长,请稍候..." |
+| 30-60s | "仍在处理,建议稍后刷新查看" |
+| > 60s | toast "评分未能完成,请稍后刷新;音频和对话内容已保存" |
 
-混合场景(slide 同时有 iframe 工具 + 口语组件):iframe 部分走原流程,口语部分由组件自提交处理,互不干扰。
+**最终失败 fallback**:显示当前已拿到的部分数据(pending 的评分块留空),不阻塞用户阅读历史对话
 
 ---
 
-## 5. 改动清单
+## 9. 时序
 
-### 5.1 口语后端 (cococlass-english-speaking-api)
+### 9.1 E1 — 最后一轮特殊处理(后端改动)
 
-| 改动 | 位置 |
+**产品决策**:第 N 轮(N = totalRounds)以学生回应作为结束,**不触发 AI 回复**。
+
+**后端改动**(`dialogue_service.py::speak`,约 line 160-175):
+
+```python
+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 session**(`total_rounds = 1`):AI 开场白(`create_session` 时已生成)→ 学生说一轮 → 无 AI 回复 → 直接结束。自洽。
+
+### 9.2 E2 — 回滚不变量
+
+| 副作用 | 异常回滚行为 |
 |---|---|
-| 新增 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` |
+| 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 自动重连。
 
-### 5.2 PPT 前端
+---
+
+## 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)
 
 | 改动 | 位置 |
 |---|---|
-| `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` |
+| 定义 `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 | `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) |
+| 报告页(DetailedReport / OverallReport)按 §4.4 结构渲染;轮询逻辑 §8 | `src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue` / `OverallReport.vue` |
+| `types/englishSpeaking.ts`:加 `timeLimitSeconds?: number`;清理 role.identity / personality / speakingStyle / speed 等不需要的字段 | `src/types/englishSpeaking.ts` |
+| 结果页 UI 还原 enspeak 原型(轮次列表 / 录音播放 / transcript / 四维分 / summary) | 实现阶段逐个从 `/Users/buoy/Development/gitrepo/enspeak/src/components/dialogue/DialogueResult.tsx` 对齐 |
 
-### 5.3 pbl-teacher-table
+### 11.1 teacher-table 侧
 
 | 改动 | 位置 |
 |---|---|
-| `select_courseWorks_workPageData` 返回 atool=77 时解析 content | `src/components/pages/workPage/index.vue` 或新建口语查看组件 |
-| 新增"口语详情"视图,调口语 API `/report?sessionId=xxx` 渲染 | 新组件 |
-| 成绩列表添加 atool=77 类型展示(完成状态 + 简要信息) | 教师作业查看入口 |
+| 识别 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)`)— 无需新增 | — |
 
 ---
 
-## 6. 数据流图
+## 12. 已知缺陷 / Known Limitations
 
-```
-┌─────────────────┐         ┌──────────────────────┐
-│  学生 (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
-         │          口语后端 ◄──────────┤
-         │                             │
-```
+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 会被重试覆盖,实际无害。
 
 ---
 
-## 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)
+## 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`
 
 ---
 
-## 8. 参考
+## 14. 本次未讨论 / 待实现阶段处理
 
-- `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
+- 老师侧"口语详情"视图的具体 UI(直接复用 enspeak `DialogueResult.tsx` 还是重写)
+- 编辑器 `mode='preview'` 下的 mock 数据/直通策略
+- 单 slide 上多个口语组件的处理(理论上可能,产品价值存疑,MVP 暂不支持)
+- `showEnglishText` / `showChineseText` / `showTaskHint` / `showSilenceHint` 几个展示开关的默认值由产品决定
+- 生产部署的 `API_BASE` 配置(`llmService.ts:3` 当前是 `http://localhost:8000`,需改环境变量)
+- B4 `isVisible` 的具体传递链路(ScreenSlide 已经把 `is-visible` 传给子组件,DialogueChatView 接收方式到实现阶段确认)