|
|
@@ -0,0 +1,515 @@
|
|
|
+# Dialogue Recording Resilience & Idempotency Design
|
|
|
+
|
|
|
+**Date:** 2026-04-26
|
|
|
+**Scope:**
|
|
|
+- Frontend: `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` and its composables (`useDialogueEngine`, `useAudioRecorder`).
|
|
|
+- Backend: `cococlass-english-speaking-api` — `app/service/speaking/dialogue_service.py`, `app/api/dialogue.py`, schema migration.
|
|
|
+**Status:** Pending user review
|
|
|
+
|
|
|
+## Context
|
|
|
+
|
|
|
+The English-speaking dialogue feature has five interlocking UX / correctness gaps in the recording flow:
|
|
|
+
|
|
|
+1. **No feedback after clicking 开始录音.** `handleStartRecording` awaits `getUserMedia` + `AudioContext` setup synchronously. First-time mic permission can take 1–3 seconds; users see no UI change and assume the button is broken.
|
|
|
+2. **Cancel is destructive in the wrong way.** `handleCancelRecording` calls `streamCtl.abortStream()`, which marks the just-pushed student/AI placeholders as `error` with text "已取消". The user sees what looks like a failure when they themselves chose to abort.
|
|
|
+3. **Placeholder push happens too early.** `beginStudentStream` pushes `studentMsg` and `aiMsg` (both `loading`) the moment the WebSocket opens — before the user has even started speaking. Two consequences: an empty student row (the voice-bar is hidden while `status==='loading'` with no content) and a misleading AI typing-bubble that animates while the student is mid-recording.
|
|
|
+4. **Failure handling is one-size-fits-all.** Every error gets a generic "重试" button that re-sends the same audio. For "No speech detected" this is futile; for session-expiry it is wrong; for ambiguous network errors there is no escape from the failed turn except restarting the entire session.
|
|
|
+5. **Network-flake double-billing.** If the WebSocket / HTTP path completes server-side but the `done` event never reaches the client, the user clicks 重试 and the server processes a second turn — running STT + LLM again, incrementing `current_round` again. There is no idempotency key on any write endpoint.
|
|
|
+
|
|
|
+We additionally observed a sixth issue while diagnosing the above:
|
|
|
+
|
|
|
+6. **Last round runs an unnecessary LLM round-trip.** On the final student turn, the server still calls LLM + TTS for a closing AI reply that the user barely hears before the report transition. This wastes ~¥0.07–0.25 per session (LLM + Azure TTS), adds 3–5 s of latency, and creates a visual mismatch between the round indicator (3/3) and the actual number of AI utterances (4 — one greeting + three replies).
|
|
|
+
|
|
|
+This spec addresses all six in one pass because they share the same files (engine + service + dialogue API + schema) and the fixes for 1–4 depend on the placeholder-timing fix in 3, which depends on the engine API split for 2.
|
|
|
+
|
|
|
+## Goals
|
|
|
+
|
|
|
+1. Show a `starting` state with cancel affordance during mic acquisition.
|
|
|
+2. Make 取消 a no-op on chat history (not a fake error).
|
|
|
+3. Push student / AI placeholders only after the user clicks 完成 录音, eliminating the premature typing-bubble.
|
|
|
+4. Classify failures into `retry | rerecord | restart` and render the correct button(s) per error kind.
|
|
|
+5. Add weak idempotency via `client_turn_id` so retries cannot double-bill or double-advance the round counter.
|
|
|
+6. Skip the LLM call on the final student turn and replace it with a "finalizing" transition card that masks the report-fetch latency.
|
|
|
+
|
|
|
+## Non-goals
|
|
|
+
|
|
|
+- **Cancellation during STT / `ai_thinking`** (after 完成 has been clicked). Once the user commits the turn, they wait. Adding a "cancel server-side processing" affordance is out of scope; if needed later it can be a separate spec (and requires server cooperation to release locks / abort the LLM stream).
|
|
|
+- **Generic / cross-endpoint idempotency middleware** (the "textbook A" approach with a separate cache table + TTL + cron cleanup). We use the pragmatic A.1 variant scoped to `dialogue_message`; see D5.
|
|
|
+- **Mic permission UX deep-dive** (better permission-denied recovery, OS-level Settings deep links). The existing `permission-banner` is acceptable.
|
|
|
+- **Streaming TTS for closing remarks.** With D6 we skip the closing reply entirely; no closing TTS is needed.
|
|
|
+- **Refactoring `useDialogueEngine` beyond what these problems require.** No migration to Pinia, no event-bus rework.
|
|
|
+
|
|
|
+## Non-trivial decisions
|
|
|
+
|
|
|
+### D1. Push placeholders at `commit()`, not at `begin()`
|
|
|
+
|
|
|
+`beginStudentStream` currently pushes both placeholders the moment the WebSocket is being opened. The original justification ("立即占位 — 录音按完成的那一刻 UI 就已经显示学生泡泡 + AI placeholder") is hollow because the student voice-bar template is gated on `message.content || message.status !== 'loading'`, so during recording the placeholder renders as an empty row, while the AI placeholder renders an active typing-bubble — exactly the wrong visual ("AI is thinking" while the student is still talking).
|
|
|
+
|
|
|
+Pushing at `commit()` (i.e., when the user clicks 完成) gives the right semantics: the bubble appears at the moment the user "sends" their turn, the typing-bubble corresponds to actual server processing, and 取消 needs no cleanup (nothing was pushed). This change requires splitting the engine API.
|
|
|
+
|
|
|
+### D2. Engine API split: `begin` → `commit | abort`
|
|
|
+
|
|
|
+`beginStudentStream` is restructured (name kept; only its return shape and side-effect contract change):
|
|
|
+
|
|
|
+```ts
|
|
|
+type StreamSession = {
|
|
|
+ turnId: string // generated here, exposed for retry/UI
|
|
|
+ pushChunk: (buf: ArrayBuffer) => void
|
|
|
+ commit: (blob: Blob) => void // push placeholders, attach blob, send 'stop'
|
|
|
+ abort: () => void // close WS; no message-list mutation
|
|
|
+}
|
|
|
+function beginStudentStream(opts): StreamSession | null
|
|
|
+```
|
|
|
+
|
|
|
+`attachStudentBlob` is removed (folded into `commit`). The only externally-observable side effect of `begin` is opening the WebSocket and starting to stream chunks; if `abort` is called before `commit`, the message list is untouched.
|
|
|
+
|
|
|
+Two adjacent helpers ship with this:
|
|
|
+
|
|
|
+```ts
|
|
|
+discardCurrentTurn(messageId: string): void // splice failed student+ai pair, reset currentTurnId
|
|
|
+```
|
|
|
+
|
|
|
+Used by 重录 to cleanly drop a failed turn before the user starts over.
|
|
|
+
|
|
|
+### D3. Error classification: 3 kinds, encoded on the message
|
|
|
+
|
|
|
+`PreviewChatMessage` gains two fields:
|
|
|
+
|
|
|
+```ts
|
|
|
+turnId?: string // set when the message is created via begin/commit
|
|
|
+recovery?: 'retry' | 'rerecord' | 'restart' // set on error; drives the button matrix
|
|
|
+```
|
|
|
+
|
|
|
+`turnId` is the same value across the student / ai pair of one turn (so `regenerateAiMessage` can read it from the AI message and `retryMessage` from the student message). The pre-existing `unrecoverable?: boolean` is removed (subsumed by `recovery === 'restart'`). A pure function `classifyError(rawText: string, status?: number, msgRole: 'student' | 'ai'): Recovery` lives in the engine and is called by every error path:
|
|
|
+
|
|
|
+```ts
|
|
|
+function classifyError(raw, status, role) {
|
|
|
+ if (status === 404 || status === 409) return 'restart'
|
|
|
+ if (raw === 'Session not found' || raw === 'Session is not active') return 'restart'
|
|
|
+ if (raw === 'No speech detected' && role === 'student') return 'rerecord'
|
|
|
+ return 'retry'
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`role === 'ai'` cannot legitimately be `rerecord` (transcript was received → audio is fine; rerecording would discard a known-good utterance). The classifier enforces this.
|
|
|
+
|
|
|
+### D4. Button matrix in the error card
|
|
|
+
|
|
|
+Rendered solely from `recovery` and `role`:
|
|
|
+
|
|
|
+| role | recovery | Buttons | On click |
|
|
|
+| -------- | ---------- | ------------------------ | ----------------------------------------------------- |
|
|
|
+| student | `retry` | **重试** + **重录** | retry: `retryMessage(id)` (HTTP, same `turnId`) |
|
|
|
+| | | | rerecord: drop turn, reset `currentTurnId`, idle |
|
|
|
+| student | `rerecord` | **重录** | drop turn, reset `currentTurnId`, idle |
|
|
|
+| student | `restart` | **返回重开** | emit `'restart'` to parent |
|
|
|
+| ai | `retry` | **重试** | `regenerateAiMessage(id)` (HTTP, same `turnId`) |
|
|
|
+| ai | `restart` | **返回重开** | emit `'restart'` |
|
|
|
+
|
|
|
+The `student / retry` row is the only case with two buttons. Reasoning: when classification is ambiguous (generic 5xx, network blip), the user is the best judge of whether to wait/retry the network or to re-record. For the unambiguous cases, a single button gives the unambiguously correct affordance.
|
|
|
+
|
|
|
+### D5. Idempotency: pragmatic A.1, scoped to `dialogue_message`
|
|
|
+
|
|
|
+Rejected: textbook idempotency-key middleware + dedicated cache table + TTL cleanup. Reasoning: the project has three write endpoints (`/greeting`, `/speak`, `/regenerate-ai`) all in the same domain; building generic infrastructure carries no payoff and introduces Redis or a cleanup job we currently don't need.
|
|
|
+
|
|
|
+Adopted: a single column `client_turn_id VARCHAR(36) NULL` on `dialogue_message`, indexed by `(session_id, client_turn_id, role)` UNIQUE. The handler lookup distinguishes three states:
|
|
|
+
|
|
|
+| Existing rows | Action |
|
|
|
+| ------------------------------- | --------------------------------------------------------------------- |
|
|
|
+| 0 (full miss) | Run STT → INSERT student → run LLM → INSERT ai → `current_round++` |
|
|
|
+| 1 (student only — partial hit) | Skip STT, replay stored transcript, run LLM, INSERT ai, `round++` |
|
|
|
+| 2 (full hit) | Replay transcript + tokens + done from existing rows; no side effects |
|
|
|
+
|
|
|
+The "1 row" branch is what makes A.1 strictly better than A.2 (atomic-only): a partial server failure (STT succeeded, LLM crashed) does **not** force the client to pay for STT a second time on retry. For Azure STT pricing this is a real win and also reduces retry latency by ~1–2 s.
|
|
|
+
|
|
|
+**TTL:** none (intentional). The `client_turn_id` column is business data — it records which retry attempt produced this turn. The data lives and dies with the session itself; session expiry (`Session is not active`) blocks future retries at the validation layer before the idempotency lookup runs.
|
|
|
+
|
|
|
+**Concurrency (defense in depth):** in normal use — single user, single tab, frontend disabling the retry button while a request is in flight — two concurrent requests with the same `turnId` cannot occur. The UNIQUE index on `(session_id, client_turn_id, role)` exists as a zero-cost backstop in case the frontend disable is ever bypassed (multi-tab, browser bug, manual API call). If that pathological race ever happens, the second INSERT raises `IntegrityError`; the handler catches it and falls through to the SELECT-based hit branch. We do not add a server-side mutex; the index alone is sufficient.
|
|
|
+
|
|
|
+### D6. Skip LLM on the final student turn
|
|
|
+
|
|
|
+The closing AI reply on the last round is removed. The student speaks their final sentence, the server runs STT + INSERTs the student row, then immediately yields `done` with `isComplete: true` — no LLM call, no AI row.
|
|
|
+
|
|
|
+For the engine to know "this commit is the last turn", `attachSession` is extended to accept `totalRounds`:
|
|
|
+
|
|
|
+```ts
|
|
|
+attachSession(info: { sessionId; expiresAt?; totalRounds })
|
|
|
+```
|
|
|
+
|
|
|
+The engine stores `totalRounds` and computes `isFinalRound = currentRound.value >= totalRounds`. `commit()` reads this flag:
|
|
|
+- If `isFinalRound`: push only `studentMsg` (no `aiMsg` placeholder).
|
|
|
+- Otherwise: push both, as today.
|
|
|
+
|
|
|
+`state` adds a `finalizing` value, displayed as a "正在生成你的本次对话报告..." card layered into `state-stack`. The existing `watch(isComplete)` continues to call `fetchReportSafe` and emit `complete`. The `state === 'finalizing'` derivation: `engine.isComplete.value === true && reportFetchInflight.value === true` — see D7.
|
|
|
+
|
|
|
+This makes the round indicator semantically clean (3/3 = three student turns, three AI replies, plus one greeting) and saves one LLM + one TTS call per session.
|
|
|
+
|
|
|
+### D7. State derivation for new states
|
|
|
+
|
|
|
+Both `starting` and `finalizing` need flags the existing `state` computed doesn't have. Adding one ref each in `DialogueChatView.vue`:
|
|
|
+
|
|
|
+```ts
|
|
|
+const isStarting = ref(false) // true between handleStartRecording entry and recorder.startRecording resolution
|
|
|
+const reportFetchInflight = ref(false) // true while fetchReportSafe is awaiting
|
|
|
+
|
|
|
+const state = computed(() => {
|
|
|
+ if (isStarting.value) return 'starting'
|
|
|
+ if (recorder.isRecording.value) return 'recording'
|
|
|
+ if (engine.isComplete.value) return reportFetchInflight.value ? 'finalizing' : 'done'
|
|
|
+ // ...rest unchanged
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+`isStarting` is local view state because cancellation is also local (the AbortController is held in the view); putting it in the recorder composable would create cross-layer coupling without benefit.
|
|
|
+
|
|
|
+## Architecture
|
|
|
+
|
|
|
+### State machine
|
|
|
+
|
|
|
+```
|
|
|
+ ┌──────────────────── 取消 ─────────────────────────────┐
|
|
|
+ ▼ │
|
|
|
+ idle ─→ starting ─→ recording ─→ (commit) ─→ stt ─→ ai_thinking ─→ done
|
|
|
+ │ │ │ │ │
|
|
|
+ │ cancel │ cancel │ │ │
|
|
|
+ ▼ ▼ ▼ ▼ ▼
|
|
|
+ idle idle error error error
|
|
|
+ ↘ ↘ ↘
|
|
|
+ {retry, rerecord, restart}
|
|
|
+
|
|
|
+ done ──(if isComplete)──▶ finalizing ──▶ (parent emits, transitions away)
|
|
|
+```
|
|
|
+
|
|
|
+`stt` and `ai_thinking` are unchanged from current code (derived from `engine.messages` state). The new states are `starting` (between mic-tap and recorder ready) and `finalizing` (between session-complete-flag-flip and parent transition).
|
|
|
+
|
|
|
+### Component / module map
|
|
|
+
|
|
|
+```
|
|
|
+DialogueChatView.vue
|
|
|
+├── computes state ∈ {idle, starting, recording, stt, ai_thinking, finalizing, error, done}
|
|
|
+├── handlers handleStartRecording / handleCancelRecording / handleFinishRecording
|
|
|
+│ handleRetry (per recovery kind), handleRerecord, handleRestart
|
|
|
+├── renders state-stack with new layers: state-starting, state-finalizing
|
|
|
+└── consumes useDialogueEngine, useAudioRecorder, useAudioPlayer
|
|
|
+
|
|
|
+useDialogueEngine.ts (engine)
|
|
|
+├── state messages, currentTurnId, currentRound, totalRounds, isComplete
|
|
|
+├── exports attachSession({ sessionId, expiresAt?, totalRounds })
|
|
|
+│ beginStudentStream() → { turnId, pushChunk, commit(blob), abort() }
|
|
|
+│ retryMessage(id), regenerateAiMessage(id) // both pass turnId from message
|
|
|
+│ discardCurrentTurn(id) // 重录 helper
|
|
|
+│ classifyError(raw, status, role)
|
|
|
+└── api sends turnId in WS 'start' frame and HTTP /speak body
|
|
|
+
|
|
|
+useAudioRecorder.ts (recorder)
|
|
|
+└── new startRecording accepts AbortSignal so handleCancelRecording can
|
|
|
+ abort getUserMedia in the starting state
|
|
|
+
|
|
|
+llmService.ts
|
|
|
+└── speak(sessionId, blob, signal, turnId) // body adds turnId
|
|
|
+ generateGreeting(sessionId, signal, turnId) // body adds turnId
|
|
|
+```
|
|
|
+
|
|
|
+### Backend
|
|
|
+
|
|
|
+```
|
|
|
+app/api/dialogue.py
|
|
|
+├── POST /greeting body: { sessionId, turnId }
|
|
|
+├── POST /speak body: { sessionId, turnId, audio }
|
|
|
+└── WS /speak-stream start frame: { ..., turnId }
|
|
|
+
|
|
|
+app/service/speaking/dialogue_service.py
|
|
|
+└── speak()
|
|
|
+ ├── lookup_existing(session_id, turn_id) → 0 | student-only | full
|
|
|
+ ├── full hit → replay (transcript, tokens, done)
|
|
|
+ ├── partial hit → reuse transcript, run LLM, insert ai, round++
|
|
|
+ └── full miss → original flow, write turn_id on every INSERT
|
|
|
+
|
|
|
+ On final round (current_round >= total_rounds):
|
|
|
+ └── after student INSERT, yield done(isComplete=True), do NOT call LLM
|
|
|
+
|
|
|
+migrations/004_add_client_turn_id.sql
|
|
|
+├── ALTER TABLE dialogue_message ADD COLUMN client_turn_id VARCHAR(36) NULL
|
|
|
+├── ADD UNIQUE INDEX uk_session_turn_role (session_id, client_turn_id, role)
|
|
|
+└── (no backfill — existing rows leave it NULL; SELECT WHERE client_turn_id=?
|
|
|
+ never returns NULL rows because we only query with non-null keys)
|
|
|
+```
|
|
|
+
|
|
|
+## Detailed flows
|
|
|
+
|
|
|
+### Flow 1: Happy path with WebSocket
|
|
|
+
|
|
|
+```
|
|
|
+1. user clicks 开始录音
|
|
|
+2. state → starting
|
|
|
+3. recorder.startRecording() acquires mic (~0.5–3 s, cancellable)
|
|
|
+4. engine.beginStudentStream({ sampleRate, ... })
|
|
|
+ - generates turnId (UUID v4)
|
|
|
+ - opens WS, sends 'start' { sessionId, turnId, sampleRate, bits, channels }
|
|
|
+ - returns { turnId, pushChunk, commit, abort }
|
|
|
+5. state → recording
|
|
|
+6. recorder.onChunk = streamSession.pushChunk
|
|
|
+7. user clicks 完成
|
|
|
+8. blob = recorder.stopRecording()
|
|
|
+9. streamSession.commit(blob)
|
|
|
+ - pushes studentMsg (loading, audioBlob=blob, turnId) + aiMsg (loading, turnId)
|
|
|
+ - sends WS 'stop' frame
|
|
|
+10. server STT → emits transcript → studentMsg.content/.status
|
|
|
+11. state derives stt → ai_thinking from message states
|
|
|
+12. server LLM → emits tokens → aiMsg.content (incremental)
|
|
|
+13. server emits done { isComplete: false } → aiMsg.status='done', round++
|
|
|
+ or { isComplete: true } → state → finalizing → fetchReport
|
|
|
+```
|
|
|
+
|
|
|
+### Flow 2: Cancel during starting
|
|
|
+
|
|
|
+```
|
|
|
+1. user clicks 开始录音
|
|
|
+2. state → starting; recorder.startRecording(signal) in flight
|
|
|
+3. user clicks 取消
|
|
|
+4. signal.abort() → getUserMedia rejects → recorder cleanup
|
|
|
+5. (engine.beginStudentStream was never called → no WS, no messages pushed)
|
|
|
+6. state → idle
|
|
|
+```
|
|
|
+
|
|
|
+### Flow 3: Cancel during recording
|
|
|
+
|
|
|
+```
|
|
|
+1. ... (flow 1 step 1–6 complete; user is recording)
|
|
|
+2. user clicks 取消
|
|
|
+3. recorder.onChunk = null
|
|
|
+4. streamSession.abort() → ws.close()
|
|
|
+5. recorder.stopRecording().catch(() => {}) (we discard the blob)
|
|
|
+6. recorder.cleanup()
|
|
|
+7. (no messages were pushed during recording — nothing to splice)
|
|
|
+8. state → idle
|
|
|
+```
|
|
|
+
|
|
|
+### Flow 4: Network drop after server commit (idempotency in action)
|
|
|
+
|
|
|
+```
|
|
|
+1. ... (flow 1 through step 12 — server has emitted all tokens)
|
|
|
+2. server INSERTs ai row, round++, but ws.close fires before 'done' frame arrives
|
|
|
+3. client sees ws.onclose with aiMsg.status === 'loading'
|
|
|
+4. finalizeError → classifyError('Connection closed') → 'retry' → aiMsg.status='error'
|
|
|
+5. user clicks 重试
|
|
|
+6. handleRetry → engine.regenerateAiMessage(aiMsg.id)
|
|
|
+ - HTTP POST /speak with prevStudent.audioBlob and turnId = prevStudent.turnId
|
|
|
+7. server SELECT (session_id, turnId) → 2 rows (full hit)
|
|
|
+8. server replays transcript / tokens / done from DB
|
|
|
+9. client receives the same content; round NOT incremented again
|
|
|
+10. Azure: 0 calls. Cost saved. Round counter accurate.
|
|
|
+```
|
|
|
+
|
|
|
+### Flow 5: STT succeeds, LLM dies, retry (partial-hit branch)
|
|
|
+
|
|
|
+```
|
|
|
+1. server STT done → INSERT student row (turn=T1, content="...")
|
|
|
+2. LLM throws → exception bubbles, no ai INSERT, no round++
|
|
|
+3. client sees aiMsg.status='error' (or studentMsg if exception happened before transcript yield)
|
|
|
+4. user clicks 重试
|
|
|
+5. server SELECT → 1 row (student only)
|
|
|
+6. server reads stored transcript, runs LLM, INSERTs ai, round++, yields done
|
|
|
+7. Azure STT: 0 calls (saved). Azure LLM: 1 call (necessary).
|
|
|
+```
|
|
|
+
|
|
|
+### Flow 6: User chooses 重录 instead of 重试
|
|
|
+
|
|
|
+```
|
|
|
+1. (any error state)
|
|
|
+2. user clicks 重录
|
|
|
+3. engine.discardCurrentTurn(messageId)
|
|
|
+ - splices the failed student + ai pair from messages
|
|
|
+ - resets currentTurnId = null
|
|
|
+4. state → idle
|
|
|
+5. user clicks 开始录音 again
|
|
|
+6. begin generates a NEW turnId — the failed turn's row in DB becomes an orphan
|
|
|
+ (SELECT by new turnId returns 0 rows; existing turnId is never queried again
|
|
|
+ because the client has discarded it)
|
|
|
+7. flow 1 from step 2
|
|
|
+```
|
|
|
+
|
|
|
+DB state after step 6: `[student, T1]` (orphan, never queried) + the new flow's rows. The orphan row is acceptable: it represents an utterance the user chose to discard, and report aggregation groups by `round`, not by `client_turn_id`, so it does not pollute reports. (See "Out-of-scope follow-ups" → orphan row cleanup if storage growth becomes a concern.)
|
|
|
+
|
|
|
+### Flow 7: Final round with `isComplete=true`
|
|
|
+
|
|
|
+```
|
|
|
+1. user is on round N, total_rounds = N (last round)
|
|
|
+2. user records and clicks 完成
|
|
|
+3. commit pushes ONLY studentMsg (no aiMsg — totalRounds reached)
|
|
|
+4. server STT → INSERT student (turnId, round=N)
|
|
|
+5. server detects current_round >= total_rounds:
|
|
|
+ - skip LLM
|
|
|
+ - session.current_round = N + 1
|
|
|
+ - yield ('transcript', ...)
|
|
|
+ - yield ('done', { isComplete: true })
|
|
|
+6. client: studentMsg.content/.status = 'done', isComplete = true
|
|
|
+7. state → finalizing; "正在生成你的本次对话报告..." card displayed
|
|
|
+8. existing watcher fetches report → emit('complete')
|
|
|
+9. parent transitions to report view
|
|
|
+```
|
|
|
+
|
|
|
+## Schema & API contract
|
|
|
+
|
|
|
+### Schema migration: `migrations/004_add_client_turn_id.sql`
|
|
|
+
|
|
|
+```sql
|
|
|
+ALTER TABLE dialogue_message
|
|
|
+ ADD COLUMN client_turn_id VARCHAR(36) NULL;
|
|
|
+
|
|
|
+CREATE UNIQUE INDEX uk_session_turn_role
|
|
|
+ ON dialogue_message (session_id, client_turn_id, role);
|
|
|
+```
|
|
|
+
|
|
|
+The unique index includes `role` because student + ai rows for the same turn share `client_turn_id`; we want both rows insertable but not duplicates of either. `NULL` values in unique indexes do not collide in MySQL, so legacy rows (pre-migration) are unaffected.
|
|
|
+
|
|
|
+### HTTP contract changes
|
|
|
+
|
|
|
+- `POST /api/speaking/dialogue/greeting` — body adds `turnId: string` (UUID).
|
|
|
+- `POST /api/speaking/dialogue/speak` — body adds `turnId: string`.
|
|
|
+- (Existing 404 / 409 / error response shapes unchanged; clients already handle them.)
|
|
|
+
|
|
|
+### WebSocket contract change
|
|
|
+
|
|
|
+- `/api/speaking/dialogue/speak-stream` — `start` frame schema:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "type": "start",
|
|
|
+ "sessionId": "...",
|
|
|
+ "turnId": "...", // NEW
|
|
|
+ "sampleRate": 48000,
|
|
|
+ "bits": 16,
|
|
|
+ "channels": 1
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Backend validation: `turnId` is required and must be a non-empty string. If missing, reject with `{type: 'error', message: 'turnId required'}`. Backwards compatibility is **not** required (no production deployments yet); the migration coordinates frontend + backend ship together.
|
|
|
+
|
|
|
+## Error handling
|
|
|
+
|
|
|
+Every error finalization point in the engine (HTTP catch, WS `onerror`, WS `onclose` while loading, WS `error` frame) goes through one helper:
|
|
|
+
|
|
|
+```ts
|
|
|
+function finalizeError(
|
|
|
+ msg: PreviewChatMessage,
|
|
|
+ raw: string,
|
|
|
+ status?: number,
|
|
|
+) {
|
|
|
+ msg.status = 'error'
|
|
|
+ msg.error = friendlyErrorMessage(raw)
|
|
|
+ msg.recovery = classifyError(raw, status, msg.role)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`unrecoverable` is removed; `retryGreeting` becomes `recovery === 'restart' ? emit('restart') : retryGreeting()` at the call site (already handled by the existing `retryAiMessage` shape — adapt to the new field name).
|
|
|
+
|
|
|
+## UI
|
|
|
+
|
|
|
+### `state-starting` layer
|
|
|
+
|
|
|
+A new `state-layer` in `state-stack`, between idle and recording in source order. Reuses the `record-capsule` shell so the visual transition from `starting` to `recording` is a fade-in of the wave + counter rather than a layout swap:
|
|
|
+
|
|
|
+```vue
|
|
|
+<div class="state-layer state-starting" :style="stateStyle('starting')">
|
|
|
+ <div class="record-capsule record-capsule-starting">
|
|
|
+ <button class="cancel-btn" @click="handleCancelStarting">取消</button>
|
|
|
+ <div class="record-meter">
|
|
|
+ <svg class="spinner" .../>
|
|
|
+ <span class="record-time">准备录音中...</span>
|
|
|
+ </div>
|
|
|
+ <button class="finish-btn" disabled>完成</button>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+```
|
|
|
+
|
|
|
+Visually: same capsule, same buttons, but the wave is replaced by a small spinner and the counter reads "准备录音中..." instead of `0:00`. `finish-btn` is disabled (no audio yet).
|
|
|
+
|
|
|
+### `state-finalizing` layer
|
|
|
+
|
|
|
+```vue
|
|
|
+<div class="state-layer state-finalizing" :style="stateStyle('finalizing')">
|
|
|
+ <svg class="spinner" .../>
|
|
|
+ <span class="center-text">正在生成你的本次对话报告...</span>
|
|
|
+</div>
|
|
|
+```
|
|
|
+
|
|
|
+Same shape as `state-center` (used for `stt` / `ai_thinking`). Live for ~0.5–3 s during `fetchReportSafe`.
|
|
|
+
|
|
|
+### Error card with recovery-aware buttons
|
|
|
+
|
|
|
+```vue
|
|
|
+<div v-if="message.status === 'error'" class="error-card">
|
|
|
+ <span class="error-text">{{ message.error }}</span>
|
|
|
+ <button v-if="hasRetryButton(message)" class="retry-btn" @click="handleRetry(message)">
|
|
|
+ {{ message.recovery === 'restart' ? '返回重开' : '重试' }}
|
|
|
+ </button>
|
|
|
+ <button v-if="hasRerecordButton(message)" class="rerecord-btn" @click="handleRerecord(message)">
|
|
|
+ 重录
|
|
|
+ </button>
|
|
|
+</div>
|
|
|
+```
|
|
|
+
|
|
|
+Helpers (in `<script setup>`):
|
|
|
+
|
|
|
+```ts
|
|
|
+function hasRetryButton(m) {
|
|
|
+ return m.recovery === 'retry' || m.recovery === 'restart'
|
|
|
+}
|
|
|
+function hasRerecordButton(m) {
|
|
|
+ return m.role === 'student' && (m.recovery === 'retry' || m.recovery === 'rerecord')
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+The 重录 button has secondary styling (outlined or text-link) when alongside 重试; primary styling when standalone (rerecord-only case).
|
|
|
+
|
|
|
+## Testing strategy
|
|
|
+
|
|
|
+### Frontend unit tests (Vitest)
|
|
|
+
|
|
|
+- `classifyError` — table-driven across each known raw string + status + role combo.
|
|
|
+- `useDialogueEngine.beginStudentStream` — abort before commit pushes nothing; commit pushes both placeholders; error finalization sets recovery correctly.
|
|
|
+- `useDialogueEngine.retryMessage` / `regenerateAiMessage` — both pass `turnId` (mock fetch, assert body shape).
|
|
|
+
|
|
|
+### Backend unit tests (pytest)
|
|
|
+
|
|
|
+- `dialogue_service.speak` happy path — full-miss branch unchanged from current behavior.
|
|
|
+- `dialogue_service.speak` partial-hit branch — pre-insert a student row with `client_turn_id=T`, call speak with same `T`, assert no STT call, assert ai row inserted, assert round incremented exactly once.
|
|
|
+- `dialogue_service.speak` full-hit branch — pre-insert student + ai with `T`, call speak, assert no STT/LLM calls, assert events replayed, assert `current_round` unchanged.
|
|
|
+- Concurrency — two parallel coroutines with same `(session, turnId)`; assert one wins, the other returns the same content via `IntegrityError` recovery.
|
|
|
+- Final-round skip — `current_round = total_rounds`, call speak, assert no LLM call, assert `isComplete: true` in done frame, assert no ai row inserted.
|
|
|
+
|
|
|
+### Integration tests
|
|
|
+
|
|
|
+- WS happy path with `turnId` in start frame; client receives transcript + tokens + done.
|
|
|
+- WS network drop after final ai INSERT → client retry via HTTP /speak with same `turnId` → full-hit replay.
|
|
|
+- Recording cancellation during `starting` (mock `getUserMedia` to delay) — UI returns to idle, no messages pushed, no WS opened.
|
|
|
+
|
|
|
+### Manual QA checklist
|
|
|
+
|
|
|
+- [ ] Click 开始录音 with mic permission already granted → `starting` flicker (~50 ms) → `recording`.
|
|
|
+- [ ] Click 开始录音 first time (permission prompt) → `starting` visible for ~1–2 s with cancel affordance → cancel works.
|
|
|
+- [ ] Cancel during recording → no error bubbles in chat, return to idle.
|
|
|
+- [ ] Cancel during `starting` → no error bubbles in chat, return to idle, mic released.
|
|
|
+- [ ] Throttle network in DevTools, complete a turn, kill connection just after `done` would have arrived → 重试 yields cached content, round counter stays correct.
|
|
|
+- [ ] Throw "No speech detected" by recording silence → only 重录 button.
|
|
|
+- [ ] Throw 404 by manually deleting session in DB → only 返回重开 button.
|
|
|
+- [ ] Reach last round, click 完成 → no AI bubble, finalizing card → report.
|
|
|
+
|
|
|
+## Out-of-scope follow-ups
|
|
|
+
|
|
|
+1. **Cancel during `stt` / `ai_thinking`.** Need server cooperation (cancel WS / abort LLM). Track separately if user research surfaces demand.
|
|
|
+2. **Replay-fidelity guarantee on full hits.** Currently the `tokens` event is replayed as a single chunk during full-hit; original streaming is lost. If consumers depend on streaming behavior we can store token boundaries; not urgent.
|
|
|
+3. **Orphan row cleanup.** A 重录 leaves a student row in DB with no matching ai row. Reports already group by `round`, but if storage growth becomes a concern a periodic `DELETE FROM dialogue_message WHERE ai_row_missing` can be added.
|
|
|
+4. **Generic idempotency middleware.** When a fourth or fifth write endpoint surfaces, revisit textbook A.
|
|
|
+
|
|
|
+## Ship plan
|
|
|
+
|
|
|
+Single coordinated PR pair (frontend + backend) since the WS contract changes lockstep. Order of commits within the backend PR:
|
|
|
+
|
|
|
+1. Migration `004_add_client_turn_id.sql`.
|
|
|
+2. Service-layer lookup helper + three-branch routing (full miss / partial hit / full hit).
|
|
|
+3. API-layer `turnId` validation in HTTP body and WS `start` frame.
|
|
|
+4. Final-round skip-LLM branch.
|
|
|
+
|
|
|
+Frontend ships in the same release window as the backend. No feature flag — the migration is additive (NULL column, no backfill) and the API change is mandatory once the backend ships.
|