DialogueSessionSplitDesign.md 12 KB

对话 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-956onMounted 里调 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 /speakWS /speak-streamGET /report 不变


4. 前端改造

4.1 useDialogueEngine.ts

拆分 initSession 为两个方法:

// 仅设置 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 返回结构改变:

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":

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:

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:

<button class="retry-btn" @click="engine.regenerateAiMessage(message.id)">重新生成</button>

改为(在 msg-ai 分支内):

<button class="retry-btn" @click="retryAiMessage(message)">重新生成</button>
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:

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)补充:

'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:拆 initSessionattachSession + 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 自然过期)