|
@@ -0,0 +1,311 @@
|
|
|
|
|
+# 对话 Session 创建拆分设计
|
|
|
|
|
+
|
|
|
|
|
+> 状态:设计收敛,待实现
|
|
|
|
|
+> 日期:2026-04-24
|
|
|
|
|
+> 相关系统:
|
|
|
|
|
+> - PPT (`/Users/buoy/Development/gitrepo/PPT`) — 前端,对话页面 `DialogueChatView.vue`
|
|
|
|
|
+> - cococlass-english-speaking-api (`/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`) — 后端,`/api/speaking/dialogue/...`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 1. 背景与问题
|
|
|
|
|
+
|
|
|
|
|
+当前 `POST /api/speaking/dialogue/session` 同时做两件事(`app/service/speaking/dialogue_service.py:44-97`):
|
|
|
|
|
+
|
|
|
|
|
+1. 建 `DialogueSession` DB 记录
|
|
|
|
|
+2. 调 LLM 生成开场白并落 `DialogueMessage`
|
|
|
|
|
+
|
|
|
|
|
+前端 `DialogueChatView.vue:950-956` 在 `onMounted` 里调 `engine.initSession(...)`,失败只 `console.error`(`useDialogueEngine.ts:39-41`),产生三个用户体验问题:
|
|
|
|
|
+
|
|
|
|
|
+1. **无 loading 反馈** —— 聊天区空白,用户不知道 AI 正在准备开场白
|
|
|
|
|
+2. **失败不可感知、不可重试** —— 报错被吞到控制台,用户只看到一个永远不会说话的 AI
|
|
|
|
|
+3. **重试边界错位** —— 即使暴露错误,bundled 接口的重试意味着"重建一整个 session",语义上"重新生成开场白"和"重开一场"被混为一谈
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 2. 决策
|
|
|
|
|
+
|
|
|
|
|
+### 2.1 拆后端,不硬塞 `/speak`
|
|
|
|
|
+
|
|
|
|
|
+`POST /speak` 的语义是"学生音频 → ASR → LLM 回复",必带 `audio` 字段,开场白没有学生音频可喂。硬塞会污染 `/speak` 的单一职责。**新加 `POST /session/{id}/greeting` 独立端点**。
|
|
|
|
|
+
|
|
|
|
|
+### 2.2 拆的收益确认(相比 bundled)
|
|
|
|
|
+
|
|
|
|
|
+| 维度 | bundled (现状) | split |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| LLM 失败时 DB 状态 | rollback,无孤儿 | 无孤儿(session 早已 commit) |
|
|
|
|
|
+| LLM 成功但响应丢包 | **真孤儿**(session + msg 已 commit,前端无 sessionId) | 同样孤儿 **但** 有机会触发幂等 retry 自愈 |
|
|
|
|
|
+| 重试一次开场白的成本 | 1 DB INSERT + 1 LLM 全量调用 | 1 LLM 全量调用 |
|
|
|
|
|
+| sessionId 生命周期 | 每次重试都变 | 稳定,整个练习期一个 |
|
|
|
|
|
+| 语义 | "重开一场" | "重新生成开场白" |
|
|
|
|
|
+| 可靠性耦合 | session 建立被 LLM 失败率拖累 (~1% vs ~0.01%) | 解耦 |
|
|
|
|
|
+
|
|
|
|
|
+### 2.3 幂等性是关键约束
|
|
|
|
|
+
|
|
|
|
|
+`POST /session/{id}/greeting` 必须幂等:若该 session `round=1` 已存在 `role='ai'` 消息,直接返回已有内容,不重复落库、不重复调 LLM。这让前端重试 100% 安全。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 3. API 契约变化
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 `POST /api/speaking/dialogue/session` (改)
|
|
|
|
|
+
|
|
|
|
|
+**Before:** 创建 session + LLM 开场白 + 落 AI 消息 + commit
|
|
|
|
|
+
|
|
|
|
|
+**After:** 只建 session,立即 commit 返回
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+Request: { topic, totalRounds, durationSeconds?, roleId?, userId? } // 不变
|
|
|
|
|
+Response: {
|
|
|
|
|
+ sessionId: string,
|
|
|
|
|
+ totalRounds: number,
|
|
|
|
|
+ currentRound: 1,
|
|
|
|
|
+ expiresAt: string | null
|
|
|
|
|
+ // 删除 aiMessage 字段
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+失败场景:DB 错误(极低概率) → 500。
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 `POST /api/speaking/dialogue/session/{session_id}/greeting` (新)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+Request: (no body)
|
|
|
|
|
+Response: { aiMessage: string }
|
|
|
|
|
+
|
|
|
|
|
+200 正常生成 或 已存在(幂等命中)
|
|
|
|
|
+404 session 不存在
|
|
|
|
|
+409 session.status != 'active'
|
|
|
|
|
+500 LLM 失败
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**行为:**
|
|
|
|
|
+1. 查 session,校验存在且 active
|
|
|
|
|
+2. 查该 session `round=1 && role='ai'` 消息,若存在 → 直接返回其 content
|
|
|
|
|
+3. 否则按原 `create_session` 步骤 2-3 调 LLM + 落 `DialogueMessage` + commit
|
|
|
|
|
+
|
|
|
|
|
+**幂等性实现重点:** 查询必须在同一事务内先查后写,避免并发双开场白(虽然单个用户并发调此接口的概率极低,仍加一层兜底)。
|
|
|
|
|
+
|
|
|
|
|
+### 3.3 其它接口
|
|
|
|
|
+
|
|
|
|
|
+`POST /speak`、`WS /speak-stream`、`GET /report` **不变**。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 4. 前端改造
|
|
|
|
|
+
|
|
|
|
|
+### 4.1 `useDialogueEngine.ts`
|
|
|
|
|
+
|
|
|
|
|
+**拆分 `initSession` 为两个方法:**
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 仅设置 session refs + 启动倒计时。不产生网络请求。
|
|
|
|
|
+function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
|
|
|
|
|
+ sessionId.value = info.sessionId
|
|
|
|
|
+ expiresAt.value = info.expiresAt ?? null
|
|
|
|
|
+ if (info.expiresAt) startCountdown(info.expiresAt)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// push loading AI 占位消息 → 请求 /greeting → 填内容或打 error
|
|
|
|
|
+async function generateGreeting() {
|
|
|
|
|
+ if (!sessionId.value) return
|
|
|
|
|
+ const aiMsg = reactive<PreviewChatMessage>({
|
|
|
|
|
+ id: crypto.randomUUID(),
|
|
|
|
|
+ role: 'ai',
|
|
|
|
|
+ content: '',
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ status: 'loading',
|
|
|
|
|
+ })
|
|
|
|
|
+ messages.value.push(aiMsg)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { aiMessage } = await api.generateGreeting(sessionId.value)
|
|
|
|
|
+ aiMsg.content = aiMessage
|
|
|
|
|
+ aiMsg.status = 'done'
|
|
|
|
|
+ speakTTS(aiMessage)
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ aiMsg.status = 'error'
|
|
|
|
|
+ aiMsg.error = friendlyErrorMessage(err?.message)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 重试开场白:找到失败的首条 AI 消息,移除后重跑 generateGreeting
|
|
|
|
|
+async function retryGreeting() {
|
|
|
|
|
+ const firstAi = messages.value.find(m => m.role === 'ai')
|
|
|
|
|
+ if (firstAi?.status !== 'error') return
|
|
|
|
|
+ messages.value = messages.value.filter(m => m.id !== firstAi.id)
|
|
|
|
|
+ await generateGreeting()
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**`DialogueAPI` 接口新增 `generateGreeting`,`createSession` 返回结构改变:**
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+interface DialogueAPI {
|
|
|
|
|
+ createSession(config: SessionConfig): Promise<{ sessionId: string; totalRounds: number; currentRound: number; expiresAt: string | null }>
|
|
|
|
|
+ generateGreeting(sessionId: string): Promise<{ aiMessage: string }>
|
|
|
|
|
+ // speak / getReport 不变
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`SessionInfo` 类型相应精简(去掉 `aiMessage`)。
|
|
|
|
|
+
|
|
|
|
|
+**MockDialogueAPI** 同步拆:`createSession` 只返 mock sessionId;`generateGreeting` 返 mock 开场白。
|
|
|
|
|
+
|
|
|
|
|
+### 4.2 `TopicDiscussionPreview.vue`
|
|
|
|
|
+
|
|
|
|
|
+"开始对话"按钮从"纯本地状态切换"改为"先调 `createSession`":
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+const sessionCreating = ref(false)
|
|
|
|
|
+const sessionError = ref<string | null>(null)
|
|
|
|
|
+const preparedSession = ref<{ sessionId: string; expiresAt: string | null } | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+async function startDialogue() {
|
|
|
|
|
+ if (sessionCreating.value) return
|
|
|
|
|
+ sessionCreating.value = true
|
|
|
|
|
+ sessionError.value = null
|
|
|
|
|
+ try {
|
|
|
|
|
+ const api = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
|
|
|
|
|
+ const info = await api.createSession({
|
|
|
|
|
+ topic: speakingStore.config.topic || topic,
|
|
|
|
|
+ totalRounds: speakingStore.config.practice.rounds || totalRounds,
|
|
|
|
|
+ roleId: 'tom',
|
|
|
|
|
+ vocabulary: speakingStore.config.learningGoals.vocabulary,
|
|
|
|
|
+ })
|
|
|
|
|
+ preparedSession.value = { sessionId: info.sessionId, expiresAt: info.expiresAt }
|
|
|
|
|
+ dialogueState.value = 'chatting'
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ sessionError.value = friendlyErrorMessage(err?.message) || '创建会话失败,请重试'
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ sessionCreating.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**模板改动:**
|
|
|
|
|
+- 按钮在 `sessionCreating` 时变 disabled + 内嵌 spinner,文案可保持"开始对话"(或加省略号)
|
|
|
|
|
+- 按钮下方/旁边出 `sessionError` 文案(非阻塞,按钮保持可点击,用户再次点击即重试)
|
|
|
|
|
+
|
|
|
|
|
+**重启流程:** `resetPreview()` 额外清 `preparedSession.value = null`。
|
|
|
|
|
+
|
|
|
|
|
+### 4.3 `DialogueChatView.vue`
|
|
|
|
|
+
|
|
|
|
|
+**Props 新增 `sessionInfo`:**
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+interface Props {
|
|
|
|
|
+ // ... existing
|
|
|
|
|
+ sessionInfo?: { sessionId: string; expiresAt: string | null } | null
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**`onMounted` 改动:**
|
|
|
|
|
+- 不再调 `engine.initSession(...)`
|
|
|
|
|
+- 若 `props.sessionInfo` 存在 → `engine.attachSession(props.sessionInfo)` + `engine.generateGreeting()`
|
|
|
|
|
+- 否则保持现状兜底(preview mode 或未来场景)
|
|
|
|
|
+
|
|
|
|
|
+**error-card 重试按钮需要分支:**
|
|
|
|
|
+
|
|
|
|
|
+当前模板 `DialogueChatView.vue:85-88`:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<button class="retry-btn" @click="engine.regenerateAiMessage(message.id)">重新生成</button>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+改为(在 `msg-ai` 分支内):
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<button class="retry-btn" @click="retryAiMessage(message)">重新生成</button>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+function retryAiMessage(message: PreviewChatMessage) {
|
|
|
|
|
+ // 第一条 AI 消息(前面没有学生消息)→ 走 retryGreeting
|
|
|
|
|
+ const idx = engine.messages.value.indexOf(message)
|
|
|
|
|
+ const hasPrevStudent = engine.messages.value.slice(0, idx).some(m => m.role === 'student')
|
|
|
|
|
+ if (!hasPrevStudent) {
|
|
|
|
|
+ engine.retryGreeting()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ engine.regenerateAiMessage(message.id)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**`handleRestart` 改动:** 不再调 `engine.initSession`,改为走父组件 `resetPreview` 流程 —— 当前 `handleRestart` 在子组件内部直接重置状态并调 `initSession`,拆分后应由父组件发起"重新创建 session"。最简单的做法:`handleRestart` 触发一个 `emit('restart')` 事件,父组件接收后清状态回 ready。
|
|
|
|
|
+
|
|
|
|
|
+### 4.4 `llmService.ts`
|
|
|
|
|
+
|
|
|
|
|
+**`RealDialogueAPI`:**
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+async createSession(config: SessionConfig): Promise<{ sessionId: string; ... }> {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/session`, { method: 'POST', ... })
|
|
|
|
|
+ if (!res.ok) throw new Error(`createSession failed: ${res.status}`)
|
|
|
|
|
+ return res.json() // 无 aiMessage 字段
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async generateGreeting(sessionId: string): Promise<{ aiMessage: string }> {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ credentials: 'include',
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ const text = await res.text().catch(() => '')
|
|
|
|
|
+ throw new Error(`greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ return res.json()
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.5 错误文案
|
|
|
|
|
+
|
|
|
|
|
+`friendlyErrorMessage` 映射表(`useDialogueEngine.ts:429-442`)补充:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+'Session not found': '会话已失效,请刷新页面重新开始', // 已有
|
|
|
|
|
+'Session is not active': '会话已结束', // 已有
|
|
|
|
|
+// 新加 LLM 失败时前端显示的中文文案:
|
|
|
|
|
+'Greeting generation failed': '开场白生成失败,请重试', // 对应后端 500
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+后端 500 时前端拿不到稳定 error code,`friendlyErrorMessage` 对未知 message 回落到"请求失败,请重试"已可用。可不特殊处理,保留通用文案即可。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 5. 实施顺序
|
|
|
|
|
+
|
|
|
|
|
+1. **后端**
|
|
|
|
|
+ 1. `dialogue_service.py`:`create_session` 拆成 `create_session_only` + `generate_greeting`(含幂等查现有消息)
|
|
|
|
|
+ 2. `api/dialogue.py`:`/session` 返回去掉 `aiMessage`;新增 `POST /session/{session_id}/greeting` 路由
|
|
|
|
|
+ 3. 单测:session 只建不生成、greeting 幂等(重复调返回同一内容)、greeting 对非 active session 返 409、greeting 对不存在 session 返 404
|
|
|
|
|
+2. **前端**
|
|
|
|
|
+ 1. `llmService.ts`:`DialogueAPI` 接口扩展;Real/Mock 都实现 `generateGreeting`;`createSession` 返回类型更新
|
|
|
|
|
+ 2. `useDialogueEngine.ts`:拆 `initSession` → `attachSession` + `generateGreeting` + `retryGreeting`
|
|
|
|
|
+ 3. `DialogueChatView.vue`:接受 `sessionInfo` prop;`onMounted` 改为 `attachSession + generateGreeting`;error-card 重试分支;`handleRestart` 改走 emit
|
|
|
|
|
+ 4. `TopicDiscussionPreview.vue`:`startDialogue` 改为 async,持有 `preparedSession`;按钮 loading/error UI;接收子组件 `restart` 事件
|
|
|
|
|
+3. **联调**
|
|
|
|
|
+ - 正常流:点击 → session 创建 → 进入聊天 → typing-bubble → greeting 出现
|
|
|
|
|
+ - session 失败:按钮可见 error + 再次点击成功
|
|
|
|
|
+ - greeting 失败:聊天页 error-card + 点击重试 → 同一 sessionId 再次请求成功
|
|
|
|
|
+ - greeting 幂等:人工强制重复请求,验证不重复落库
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 6. 验收标准
|
|
|
|
|
+
|
|
|
|
|
+- [ ] `POST /session` 响应体不含 `aiMessage`
|
|
|
|
|
+- [ ] `POST /session/{id}/greeting` 对同一 session 重复调用返回完全相同的 `aiMessage` 且 DB 中仅一条 round=1 AI 消息
|
|
|
|
|
+- [ ] `POST /session/{id}/greeting` 对非 active session 返 409,对不存在 session 返 404
|
|
|
|
|
+- [ ] Ready 页点击"开始对话"期间按钮显示 loading,session 创建失败后按钮恢复可点且下方显示错误文案,再点击可成功重试
|
|
|
|
|
+- [ ] 进入聊天页后立刻出现 typing-bubble(LLM 期间),greeting 到达后变为正常 AI 消息气泡
|
|
|
|
|
+- [ ] greeting 生成失败时,聊天页 error-card 出现"重新生成"按钮,点击后复用同一 sessionId 再请求
|
|
|
|
|
+- [ ] 原有对话中间 AI 消息失败重试(`regenerateAiMessage`)行为不变
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 7. 非目标
|
|
|
|
|
+
|
|
|
|
|
+- 不改 `/speak`、`/speak-stream`、`/report`
|
|
|
|
|
+- 不引入开场白 streaming(1-2 句话,非 streaming 体感差异可忽略)
|
|
|
|
|
+- 不做"换一种开场白风格"等产品功能(未来若需要,`/greeting` 可扩展 query 参数)
|
|
|
|
|
+- 不做孤儿 session 清理(依赖 `expires_at` 自然过期)
|