jimmylee 1 неделя назад
Родитель
Сommit
130b71f3b4

+ 1 - 0
.env

@@ -1,2 +1,3 @@
 VITE_AZURE_SPEECH_KEY=5b401f52c9064cec9247f4190cf99ea4
 VITE_AZURE_SPEECH_REGION=eastasia
+# VITE_SPEAKING_API_HOST=http://localhost:8000

+ 1 - 0
.env.example

@@ -1,2 +1,3 @@
 VITE_AZURE_SPEECH_KEY=
 VITE_AZURE_SPEECH_REGION=
+VITE_SPEAKING_API_HOST=https://ppt-english-speaking-api.cocorobo.cn

+ 2178 - 0
docs/superpowers/plans/2026-04-26-dialogue-recording-resilience.md

@@ -0,0 +1,2178 @@
+# Dialogue Recording Resilience & Idempotency Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix six interlocking gaps in `DialogueChatView`'s recording flow — loading state on 开始录音, no-op cancel, late placeholder push, recovery-aware error buttons, `client_turn_id` idempotency on `dialogue_message`, and skipping LLM on the final student turn.
+
+**Architecture:** Backend gains a `client_turn_id` column on `dialogue_message` plus a three-branch lookup in `speak()` (full miss / partial hit / full hit) and a final-round skip-LLM branch. Frontend's `useDialogueEngine` is restructured: `beginStudentStream` returns `{turnId, pushChunk, commit, abort}` where `commit` is what pushes message placeholders (so 取消 needs no cleanup); errors are classified to `retry | rerecord | restart` and rendered as recovery-aware buttons. `DialogueChatView` adds `starting` and `finalizing` states with cancel affordance during mic acquisition and a transition card during report fetch.
+
+**Tech Stack:** Backend — Python 3, FastAPI, SQLAlchemy async, MySQL (SQLite for tests), pytest + pytest-asyncio. Frontend — Vue 3 `<script setup>` + TypeScript, Vite, native WebSocket / fetch / `AbortController`.
+
+---
+
+## File Structure
+
+Two repos involved. Repo roots:
+- Frontend: `/Users/buoy/Development/gitrepo/PPT`
+- Backend: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`
+
+### Backend changes
+
+- Create: `migrations/004_add_client_turn_id.sql` — schema migration adding the column + UNIQUE index.
+- Modify: `app/models/dialogue.py` — add `client_turn_id: Mapped[str | None]` to `DialogueMessage`.
+- Modify: `app/service/speaking/dialogue_service.py` — add `_lookup_turn_state()` + `_replay_turn()` helpers; refactor `speak()` to branch on turn state and skip LLM on the final round; thread `client_turn_id` through.
+- Modify: `app/api/dialogue.py` — accept `turnId` in `POST /speak` Form, `POST /session/{id}/greeting` body, and WS `start` frame.
+- Modify: `tests/conftest.py` — extend in-memory DB schema (already created via `Base.metadata.create_all` so the new column is automatic; no change needed in conftest itself, but new tests reference it).
+- Create: `tests/service/speaking/test_dialogue_service_idempotency.py` — TDD tests for the three branches + final-round skip + concurrency.
+- Modify: `tests/api/test_dialogue_greeting.py` — adapt existing tests if they break on the new required `turnId`.
+
+### Frontend changes
+
+- Modify: `src/types/englishSpeaking.ts` — add `turnId?: string` and `recovery?: 'retry' | 'rerecord' | 'restart'` to `PreviewChatMessage`; remove `unrecoverable?: boolean` if unused after refactor.
+- Modify: `src/views/Editor/EnglishSpeaking/services/llmService.ts` — `speak()` and `generateGreeting()` accept `turnId` and include it in the request body.
+- Modify: `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` — biggest change: `attachSession` takes `totalRounds`; `classifyError()` helper; `beginStudentStream` returns the new `{turnId, pushChunk, commit, abort}` shape; placeholders pushed at commit; final-round skips aiMsg push; `discardCurrentTurn`; `retryMessage` / `regenerateAiMessage` pass `turnId`; greeting generates a `turnId` too.
+- Modify: `src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts` — `startRecording(signal?: AbortSignal)` plumbs through to `getUserMedia`.
+- Modify: `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` — `state` computed adds `starting` / `finalizing`; new `isStarting` and `reportFetchInflight` refs; new `state-starting` / `state-finalizing` layers; `handleStartRecording` opens an `AbortController`; `handleCancelRecording` uses new no-op semantics; error card renders recovery-aware buttons; new `handleRetry` / `handleRerecord` handlers; greeting passes a `turnId`.
+
+Spec reference: `docs/superpowers/specs/2026-04-26-dialogue-recording-resilience-design.md`
+
+Package managers: backend uses `uv`; frontend uses `npm`.
+
+---
+
+## Implementation order
+
+Backend ships first (so the frontend can integrate against a live, idempotency-aware backend during dev), then frontend. Within backend, tests drive each new branch.
+
+---
+
+### Task 1: Schema migration
+
+Adds the column and the UNIQUE index that the entire idempotency story rests on. Standalone, reversible.
+
+**Files:**
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/migrations/004_add_client_turn_id.sql`
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/models/dialogue.py`
+
+- [ ] **Step 1: Write the migration SQL**
+
+Create `migrations/004_add_client_turn_id.sql`:
+
+```sql
+-- 004_add_client_turn_id.sql
+-- Adds idempotency key column to dialogue_message, scoped per-session.
+-- See docs/superpowers/specs/2026-04-26-dialogue-recording-resilience-design.md (D5).
+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);
+```
+
+- [ ] **Step 2: Add the column to the SQLAlchemy model**
+
+Open `app/models/dialogue.py` and locate the `DialogueMessage` class. Add the new field next to `audio_url`:
+
+```python
+client_turn_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+```
+
+(Use the same `Mapped` / `mapped_column` style as existing fields. Import `String` from `sqlalchemy` if not already imported.)
+
+- [ ] **Step 3: Verify the test SQLite schema picks up the new column**
+
+The test fixture in `tests/api/test_dialogue_greeting.py:test_env` calls `Base.metadata.create_all`, which reflects the model change automatically. Run the existing greeting test suite to confirm nothing broke:
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest tests/api/test_dialogue_greeting.py -v
+```
+
+Expected: same green/red as before this task (no new failures from the column add).
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+git add migrations/004_add_client_turn_id.sql app/models/dialogue.py
+git commit -m "feat(speaking): add client_turn_id column to dialogue_message
+
+Schema-only change; nothing reads or writes it yet. Subsequent commits
+add the lookup helper and refactor speak() to use it."
+```
+
+---
+
+### Task 2: Turn-state lookup helper (TDD)
+
+Pure function: given a `(session_id, client_turn_id)`, return one of `'miss' | 'partial' | 'full'` plus the existing rows. Used by `speak()` to decide which branch to take.
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` — add `_lookup_turn_state` method on `DialogueService`.
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_idempotency.py`
+
+- [ ] **Step 1: Write the failing test for the three states**
+
+Create `tests/service/speaking/test_dialogue_service_idempotency.py`:
+
+```python
+"""Tests for client_turn_id-based idempotency in DialogueService.speak."""
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+from sqlalchemy.pool import StaticPool
+
+from app.models.dialogue import Base, DialogueMessage, DialogueSession
+from app.service.speaking.dialogue_service import DialogueService
+
+
+@pytest_asyncio.fixture
+async def db_session():
+    engine = create_async_engine(
+        "sqlite+aiosqlite:///:memory:",
+        poolclass=StaticPool,
+        connect_args={"check_same_thread": False},
+    )
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+    async with SessionLocal() as s:
+        yield s
+
+
+@pytest.fixture
+def service():
+    return DialogueService(asr=MagicMock(), llm=MagicMock(), assessor=MagicMock(), storage=MagicMock())
+
+
+@pytest_asyncio.fixture
+async def fixture_session(db_session):
+    s = DialogueSession(
+        uuid="sess-1",
+        topic="t",
+        total_rounds=3,
+        current_round=1,
+        status="active",
+        system_prompt="p",
+        created_at=datetime.now(),
+    )
+    db_session.add(s)
+    await db_session.commit()
+    return s
+
+
+@pytest.mark.asyncio
+async def test_lookup_turn_state_miss(service, db_session, fixture_session):
+    state, rows = await service._lookup_turn_state(db_session, fixture_session.id, "T1")
+    assert state == "miss"
+    assert rows == []
+
+
+@pytest.mark.asyncio
+async def test_lookup_turn_state_partial(service, db_session, fixture_session):
+    db_session.add(DialogueMessage(
+        session_id=fixture_session.id, round=1, role="student",
+        content="hi", client_turn_id="T1",
+    ))
+    await db_session.commit()
+    state, rows = await service._lookup_turn_state(db_session, fixture_session.id, "T1")
+    assert state == "partial"
+    assert len(rows) == 1
+    assert rows[0].role == "student"
+
+
+@pytest.mark.asyncio
+async def test_lookup_turn_state_full(service, db_session, fixture_session):
+    db_session.add(DialogueMessage(
+        session_id=fixture_session.id, round=1, role="student",
+        content="hi", client_turn_id="T1",
+    ))
+    db_session.add(DialogueMessage(
+        session_id=fixture_session.id, round=1, role="ai",
+        content="hello", client_turn_id="T1",
+    ))
+    await db_session.commit()
+    state, rows = await service._lookup_turn_state(db_session, fixture_session.id, "T1")
+    assert state == "full"
+    assert len(rows) == 2
+    assert {r.role for r in rows} == {"student", "ai"}
+```
+
+- [ ] **Step 2: Run the tests to confirm they fail**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
+```
+
+Expected: FAIL — `AttributeError: 'DialogueService' object has no attribute '_lookup_turn_state'`.
+
+- [ ] **Step 3: Implement `_lookup_turn_state`**
+
+In `app/service/speaking/dialogue_service.py`, add (place it right after `__init__` or anywhere on the `DialogueService` class):
+
+```python
+async def _lookup_turn_state(
+    self,
+    db: AsyncSession,
+    session_id: int,
+    client_turn_id: str,
+) -> tuple[str, list[DialogueMessage]]:
+    """Returns ('miss'|'partial'|'full', rows) for the given turn key."""
+    result = await db.execute(
+        select(DialogueMessage)
+        .where(DialogueMessage.session_id == session_id)
+        .where(DialogueMessage.client_turn_id == client_turn_id)
+        .order_by(DialogueMessage.id)
+    )
+    rows = list(result.scalars().all())
+    if not rows:
+        return ("miss", [])
+    has_student = any(r.role == "student" for r in rows)
+    has_ai = any(r.role == "ai" for r in rows)
+    if has_student and has_ai:
+        return ("full", rows)
+    if has_student:
+        return ("partial", rows)
+    # Edge: somehow we have only an ai row without student — treat as full miss
+    # (this shouldn't happen with the insert order, but be defensive)
+    return ("miss", rows)
+```
+
+- [ ] **Step 4: Run the tests; expect pass**
+
+```bash
+uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
+```
+
+Expected: 3 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_idempotency.py
+git commit -m "feat(speaking): add _lookup_turn_state helper for idempotency
+
+Pure async query keyed on (session_id, client_turn_id). Returns one of
+'miss' | 'partial' | 'full'. Used in subsequent commit to branch speak()."
+```
+
+---
+
+### Task 3: Replay helper for full-hit branch (TDD)
+
+When `_lookup_turn_state` returns `'full'`, we need to yield the same event sequence (`transcript`, `token` × N as one chunk, `done`) without calling STT/LLM/round-bumping.
+
+**Files:**
+- Modify: `app/service/speaking/dialogue_service.py` — add `_replay_full_turn` async generator.
+- Modify: `tests/service/speaking/test_dialogue_service_idempotency.py` — add replay test.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/service/speaking/test_dialogue_service_idempotency.py`:
+
+```python
+@pytest.mark.asyncio
+async def test_replay_full_turn(service, db_session, fixture_session):
+    db_session.add(DialogueMessage(
+        session_id=fixture_session.id, round=1, role="student",
+        content="cats are cute", client_turn_id="T1",
+    ))
+    db_session.add(DialogueMessage(
+        session_id=fixture_session.id, round=1, role="ai",
+        content="I love cats too!", client_turn_id="T1",
+    ))
+    await db_session.commit()
+
+    state, rows = await service._lookup_turn_state(db_session, fixture_session.id, "T1")
+    assert state == "full"
+
+    events = []
+    async for ev in service._replay_full_turn(rows, total_rounds=3, next_round=2):
+        events.append(ev)
+
+    assert events[0] == ("transcript", {"text": "cats are cute", "round": 1})
+    assert events[1] == ("token", {"content": "I love cats too!"})
+    assert events[2] == ("done", {"isComplete": False, "nextRound": 2})
+```
+
+- [ ] **Step 2: Run; expect FAIL** (`_replay_full_turn` undefined).
+
+```bash
+uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py::test_replay_full_turn -v
+```
+
+- [ ] **Step 3: Implement `_replay_full_turn`**
+
+Add to `DialogueService`:
+
+```python
+async def _replay_full_turn(
+    self,
+    rows: list[DialogueMessage],
+    total_rounds: int,
+    next_round: int,
+) -> AsyncIterator[tuple[str, dict]]:
+    """Yield transcript / token / done from previously-stored rows.
+
+    No DB writes, no STT/LLM, no round bump. Used when the same turnId
+    has been fully processed before (network drop after server commit).
+    """
+    student = next(r for r in rows if r.role == "student")
+    ai = next(r for r in rows if r.role == "ai")
+    yield ("transcript", {"text": student.content, "round": student.round})
+    # Single chunk — see spec out-of-scope: streaming fidelity not required on replay.
+    yield ("token", {"content": ai.content})
+    is_complete = next_round > total_rounds
+    yield ("done", {"isComplete": is_complete, "nextRound": next_round})
+```
+
+- [ ] **Step 4: Run; expect PASS**
+
+```bash
+uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py::test_replay_full_turn -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_idempotency.py
+git commit -m "feat(speaking): add _replay_full_turn for cached idempotent retries
+
+Yields transcript / token / done from existing DialogueMessage rows
+without re-running STT or LLM. Single token chunk is acceptable per
+spec (replay fidelity is out of scope)."
+```
+
+---
+
+### Task 4: Wire idempotency into `speak()` (TDD)
+
+This is the big refactor: `speak()` accepts `client_turn_id`, calls `_lookup_turn_state`, and branches.
+
+**Files:**
+- Modify: `app/service/speaking/dialogue_service.py` — `speak()` signature + body.
+- Modify: `tests/service/speaking/test_dialogue_service_idempotency.py` — full-hit + partial-hit + miss tests on the integrated flow.
+
+- [ ] **Step 1: Write failing tests for the three branches end-to-end**
+
+Append to `tests/service/speaking/test_dialogue_service_idempotency.py`:
+
+```python
+@pytest_asyncio.fixture
+def service_with_mocks():
+    asr = MagicMock()
+    asr.transcribe = AsyncMock(return_value="hello world")
+    llm = MagicMock()
+
+    async def fake_stream(*_a, **_kw):
+        for tok in ["hi ", "there", "!"]:
+            yield tok
+    llm.chat_stream = fake_stream
+
+    storage = MagicMock()
+    storage.upload = AsyncMock(return_value="s3://bucket/audio.webm")
+
+    return DialogueService(asr=asr, llm=llm, assessor=MagicMock(), storage=storage)
+
+
+@pytest.mark.asyncio
+async def test_speak_full_hit_skips_stt_and_llm(service_with_mocks, db_session, fixture_session):
+    # Pre-seed the turn as fully completed
+    db_session.add(DialogueMessage(
+        session_id=fixture_session.id, round=1, role="student",
+        content="cached transcript", client_turn_id="T1",
+    ))
+    db_session.add(DialogueMessage(
+        session_id=fixture_session.id, round=1, role="ai",
+        content="cached reply", client_turn_id="T1",
+    ))
+    await db_session.commit()
+    initial_round = fixture_session.current_round
+
+    events = []
+    async for ev in service_with_mocks.speak(
+        db=db_session, session=fixture_session,
+        audio_bytes=b"x", content_type="audio/webm",
+        client_turn_id="T1",
+    ):
+        events.append(ev)
+
+    # No STT, no LLM, no round bump
+    service_with_mocks.asr.transcribe.assert_not_called()
+    service_with_mocks.storage.upload.assert_not_called()
+    await db_session.refresh(fixture_session)
+    assert fixture_session.current_round == initial_round
+
+    # Cached content replayed
+    assert events[0] == ("transcript", {"text": "cached transcript", "round": 1})
+    assert events[1] == ("token", {"content": "cached reply"})
+    assert events[-1][0] == "done"
+
+
+@pytest.mark.asyncio
+async def test_speak_partial_hit_skips_stt_runs_llm(service_with_mocks, db_session, fixture_session):
+    # Only student row exists (LLM crashed on prior attempt)
+    db_session.add(DialogueMessage(
+        session_id=fixture_session.id, round=1, role="student",
+        content="cached transcript", client_turn_id="T1",
+    ))
+    await db_session.commit()
+
+    events = []
+    async for ev in service_with_mocks.speak(
+        db=db_session, session=fixture_session,
+        audio_bytes=b"x", content_type="audio/webm",
+        client_turn_id="T1",
+    ):
+        events.append(ev)
+
+    # STT skipped; LLM ran
+    service_with_mocks.asr.transcribe.assert_not_called()
+    # Transcript came from DB
+    assert events[0] == ("transcript", {"text": "cached transcript", "round": 1})
+    # Tokens streamed
+    token_events = [e for e in events if e[0] == "token"]
+    assert len(token_events) == 3
+    # Done with round bumped
+    done = events[-1]
+    assert done[0] == "done"
+
+
+@pytest.mark.asyncio
+async def test_speak_full_miss_runs_full_pipeline(service_with_mocks, db_session, fixture_session):
+    events = []
+    async for ev in service_with_mocks.speak(
+        db=db_session, session=fixture_session,
+        audio_bytes=b"x", content_type="audio/webm",
+        client_turn_id="T1",
+    ):
+        events.append(ev)
+
+    service_with_mocks.asr.transcribe.assert_called_once()
+    # transcript + 3 tokens + done = 5
+    assert len([e for e in events if e[0] == "token"]) == 3
+    # client_turn_id propagated to inserted rows
+    from sqlalchemy import select as sa_select
+    res = await db_session.execute(
+        sa_select(DialogueMessage).where(DialogueMessage.client_turn_id == "T1")
+    )
+    rows = list(res.scalars().all())
+    assert len(rows) == 2
+    assert {r.role for r in rows} == {"student", "ai"}
+```
+
+- [ ] **Step 2: Run; expect FAIL** (signature mismatch — `speak()` doesn't take `client_turn_id` yet).
+
+```bash
+uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
+```
+
+- [ ] **Step 3: Refactor `speak()` to branch on turn state**
+
+In `app/service/speaking/dialogue_service.py`, replace the existing `speak` method (around line 338–455). The full new body:
+
+```python
+async def speak(
+    self,
+    db: AsyncSession,
+    session: DialogueSession,
+    audio_bytes: bytes,
+    content_type: str = "audio/webm;codecs=opus",
+    client_turn_id: str | None = None,
+) -> AsyncIterator[tuple[str, dict]]:
+    """录音 → 转录 → LLM 流式回复 → 后台评估。
+
+    With client_turn_id: idempotent. Repeated calls with the same key
+    return the cached result without re-running STT/LLM/round-bump.
+    """
+    current_round = session.current_round
+
+    # ① 检查是否已超时
+    if session.expires_at and datetime.now() > session.expires_at:
+        session.status = "completed"
+        session.completed_at = datetime.now()
+        await db.commit()
+        yield ("done", {"isComplete": True, "nextRound": session.current_round})
+        return
+
+    # ② Idempotency lookup (only if client_turn_id provided)
+    existing_rows: list[DialogueMessage] = []
+    turn_state = "miss"
+    if client_turn_id:
+        turn_state, existing_rows = await self._lookup_turn_state(
+            db, session.id, client_turn_id
+        )
+
+    # ③ Full hit: replay from DB, no side effects
+    if turn_state == "full":
+        # next_round is already in DB state — current_round was bumped on first pass
+        async for ev in self._replay_full_turn(
+            existing_rows,
+            total_rounds=session.total_rounds,
+            next_round=session.current_round,
+        ):
+            yield ev
+        return
+
+    try:
+        if turn_state == "partial":
+            # ④ Partial hit: reuse stored transcript, skip STT
+            student_row = next(r for r in existing_rows if r.role == "student")
+            transcript = student_row.content
+            student_msg = student_row
+            yield ("transcript", {"text": transcript, "round": student_row.round})
+        else:
+            # ④ Full miss: run STT + insert student row
+            filename = f"{session.uuid}/round_{current_round}.webm"
+            logger.info(f"ASR start: session={session.uuid}, round={current_round}, turn={client_turn_id}")
+            audio_url, transcript = await asyncio.gather(
+                self.storage.upload(audio_bytes, filename),
+                self.asr.transcribe(audio_bytes, content_type),
+            )
+            logger.info(f"ASR done: '{transcript[:50]}...'")
+
+            yield ("transcript", {"text": transcript, "round": current_round})
+
+            student_msg = DialogueMessage(
+                session_id=session.id,
+                round=current_round,
+                role="student",
+                content=transcript,
+                audio_url=audio_url,
+                client_turn_id=client_turn_id,
+            )
+            db.add(student_msg)
+            await db.flush()
+
+            # ⑤ 创建评估记录
+            evaluation = PronunciationEvaluation(
+                message_id=student_msg.id,
+                session_id=session.id,
+                round=current_round,
+                status="pending",
+            )
+            db.add(evaluation)
+            await db.flush()
+
+        # ⑥ 构建 LLM prompt
+        history_result = await db.execute(
+            select(DialogueMessage)
+            .where(DialogueMessage.session_id == session.id)
+            .order_by(DialogueMessage.created_at)
+        )
+        history = list(history_result.scalars().all())
+
+        prior_ai_turn = ""
+        for msg in reversed(history):
+            if msg.role == "ai":
+                prior_ai_turn = msg.content
+                break
+
+        llm_messages = [{"role": "system", "content": session.system_prompt}]
+        for msg in history:
+            role = "assistant" if msg.role == "ai" else "user"
+            llm_messages.append({"role": role, "content": msg.content})
+
+        # ⑦ LLM 流式
+        logger.info(f"LLM stream start: {len(llm_messages)} messages")
+        full_response = ""
+        async for token in self.llm.chat_stream(llm_messages, model=""):
+            full_response += token
+            yield ("token", {"content": token})
+
+        # ⑧ 写入 AI 消息
+        ai_msg = DialogueMessage(
+            session_id=session.id,
+            round=_ai_reply_round(current_round, session.total_rounds),
+            role="ai",
+            content=full_response,
+            client_turn_id=client_turn_id,
+        )
+        db.add(ai_msg)
+
+        # ⑨ 推进轮次
+        session.current_round = current_round + 1
+        is_complete = session.current_round > session.total_rounds
+        if not is_complete and session.expires_at:
+            is_complete = datetime.now() > session.expires_at
+        if is_complete:
+            session.status = "completed"
+            session.completed_at = datetime.now()
+
+        await db.commit()
+
+        # ⑩ 后台评估 — only on full miss (partial hit's evaluation was already enqueued)
+        if turn_state == "miss":
+            logger.info(f"Launching background pronunciation assessment: msg={student_msg.id}")
+            asyncio.create_task(
+                self._evaluate_pronunciation(
+                    evaluation_id=evaluation.id,
+                    audio_bytes=audio_bytes,
+                    reference_text=transcript,
+                    prior_ai_turn=prior_ai_turn,
+                    content_type=content_type,
+                )
+            )
+
+        # ⑪ done
+        yield ("done", {"isComplete": is_complete, "nextRound": session.current_round})
+
+    except IntegrityError:
+        # Concurrent retry beat us to an INSERT. Roll back, fall through to lookup again.
+        await db.rollback()
+        logger.warning(f"IntegrityError on speak (concurrent retry?): session={session.uuid}, turn={client_turn_id}")
+        if client_turn_id:
+            _, rows = await self._lookup_turn_state(db, session.id, client_turn_id)
+            async for ev in self._replay_full_turn(
+                rows,
+                total_rounds=session.total_rounds,
+                next_round=session.current_round,
+            ):
+                yield ev
+            return
+        yield ("error", {"message": "Internal server error"})
+    except Exception as e:
+        logger.error(f"Speak failed: session={session.uuid}, round={current_round}, turn={client_turn_id}, error={e}")
+        await db.rollback()
+        yield ("error", {"message": "Internal server error"})
+```
+
+Add the `IntegrityError` import at the top of the file:
+
+```python
+from sqlalchemy.exc import IntegrityError
+```
+
+- [ ] **Step 4: Run the new tests; expect PASS**
+
+```bash
+uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
+```
+
+- [ ] **Step 5: Run the full backend test suite to confirm no regressions**
+
+```bash
+uv run pytest -v
+```
+
+Expected: all green except possibly tests in `test_dialogue_service_content.py` / `test_dialogue_service_greeting.py` if they call `speak()` without `client_turn_id`. Those are fine — `client_turn_id` is optional (defaults to `None`, which means no idempotency lookup).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_idempotency.py
+git commit -m "feat(speaking): add client_turn_id idempotency to speak()
+
+Three-branch lookup (full miss / partial hit / full hit) keyed on
+(session_id, client_turn_id). Full hit replays from DB without
+STT/LLM/round-bump. Partial hit reuses stored transcript and runs
+only the LLM. IntegrityError on concurrent retry rolls back and
+falls through to replay."
+```
+
+---
+
+### Task 5: Skip LLM on the final student turn (TDD)
+
+When `current_round >= total_rounds` and we're in the full-miss branch, write the student row, yield transcript, mark session complete, yield done — no LLM call, no AI row.
+
+**Files:**
+- Modify: `app/service/speaking/dialogue_service.py` — final-round early return.
+- Modify: `tests/service/speaking/test_dialogue_service_idempotency.py` — add the test.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/service/speaking/test_dialogue_service_idempotency.py`:
+
+```python
+@pytest.mark.asyncio
+async def test_speak_final_round_skips_llm(service_with_mocks, db_session, fixture_session):
+    # Set the session to its last round
+    fixture_session.current_round = fixture_session.total_rounds  # 3
+    await db_session.commit()
+
+    events = []
+    async for ev in service_with_mocks.speak(
+        db=db_session, session=fixture_session,
+        audio_bytes=b"x", content_type="audio/webm",
+        client_turn_id="T-final",
+    ):
+        events.append(ev)
+
+    # STT ran (we still need transcript)
+    service_with_mocks.asr.transcribe.assert_called_once()
+    # LLM did NOT run
+    assert all(e[0] != "token" for e in events), f"unexpected token events: {events}"
+
+    # done with isComplete=True
+    done = events[-1]
+    assert done[0] == "done"
+    assert done[1]["isComplete"] is True
+
+    # Only student row inserted
+    from sqlalchemy import select as sa_select
+    res = await db_session.execute(
+        sa_select(DialogueMessage).where(DialogueMessage.client_turn_id == "T-final")
+    )
+    rows = list(res.scalars().all())
+    assert len(rows) == 1
+    assert rows[0].role == "student"
+```
+
+- [ ] **Step 2: Run; expect FAIL** (LLM still runs).
+
+```bash
+uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py::test_speak_final_round_skips_llm -v
+```
+
+- [ ] **Step 3: Add the early-return branch in `speak()`**
+
+In `dialogue_service.py`, after step ⑤ (evaluation insert) and before step ⑥ (LLM prompt build), add:
+
+```python
+        # ⑤b 末轮跳过 LLM:学生说完最后一句 → 直接 done(isComplete=True),不再产 AI 回复
+        if current_round >= session.total_rounds:
+            session.current_round = current_round + 1
+            session.status = "completed"
+            session.completed_at = datetime.now()
+            await db.commit()
+
+            if turn_state == "miss":
+                logger.info(f"Launching background pronunciation assessment: msg={student_msg.id}")
+                asyncio.create_task(
+                    self._evaluate_pronunciation(
+                        evaluation_id=evaluation.id,
+                        audio_bytes=audio_bytes,
+                        reference_text=transcript,
+                        prior_ai_turn="",  # last round: no prior AI to compare against
+                        content_type=content_type,
+                    )
+                )
+
+            yield ("done", {"isComplete": True, "nextRound": session.current_round})
+            return
+```
+
+Note: `evaluation` only exists on the `miss` branch; the `if turn_state == "miss"` guard is critical. On the partial-hit branch (which can also hit "final round"), the evaluation was enqueued on the prior attempt.
+
+- [ ] **Step 4: Run; expect PASS**
+
+```bash
+uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
+```
+
+All idempotency tests including the new `test_speak_final_round_skips_llm`.
+
+- [ ] **Step 5: Run the full suite for regressions**
+
+```bash
+uv run pytest -v
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_idempotency.py
+git commit -m "feat(speaking): skip LLM on the final student turn
+
+Saves one LLM + one TTS per session and aligns the round indicator
+with actual AI utterances (3/3 = 1 greeting + 3 replies). Final-round
+done event carries isComplete=True; client transitions straight to the
+report flow."
+```
+
+---
+
+### Task 6: HTTP `/speak` endpoint accepts `turnId`
+
+**Files:**
+- Modify: `app/api/dialogue.py` — `speak()` route handler.
+
+- [ ] **Step 1: Locate the speak route**
+
+Around line 142–183 in `app/api/dialogue.py`. Current signature uses `Form(...)` for `sessionId`.
+
+- [ ] **Step 2: Add `turnId` Form parameter and pass it through**
+
+```python
+@router.post("/speak")
+async def speak(
+    sessionId: str = Form(...),
+    turnId: str = Form(...),  # NEW: required
+    audio: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+    service: DialogueService = Depends(get_dialogue_service),
+):
+    # ... existing session lookup unchanged ...
+
+    return StreamingResponse(
+        _stream_events(
+            service.speak(
+                db=db,
+                session=session,
+                audio_bytes=audio_bytes,
+                content_type=audio_content_type,
+                client_turn_id=turnId,  # NEW
+            )
+        ),
+        media_type="text/event-stream",
+    )
+```
+
+The exact diff depends on the current code shape — keep all existing logic, only add the new param and forward it.
+
+- [ ] **Step 3: Type-check / smoke run**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run ruff check app/api/dialogue.py
+uv run pytest tests/api -v
+```
+
+If existing API tests for `/speak` exist, they may need updating to pass `turnId`. Update any failing tests by adding `data={"sessionId": "...", "turnId": "any-uuid"}` to the multipart request.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/api/dialogue.py tests/api/
+git commit -m "feat(speaking): require turnId on POST /speak"
+```
+
+---
+
+### Task 7: HTTP `/session/{id}/greeting` accepts `turnId`
+
+**Files:**
+- Modify: `app/api/dialogue.py` — `generate_greeting()` route.
+- Modify: `app/service/speaking/dialogue_service.py` — `generate_greeting()` method to accept and store `client_turn_id` on the AI message it creates.
+- Modify: `tests/api/test_dialogue_greeting.py` — adapt existing tests.
+
+- [ ] **Step 1: Add `turnId` to the request body model**
+
+In `app/api/dialogue.py`, find the greeting route (around line 85–100). It likely takes the session id from path and may or may not have a body model. Add `turnId` as a body field via Pydantic or `Body()`:
+
+```python
+from pydantic import BaseModel
+
+class GreetingRequest(BaseModel):
+    turnId: str
+
+@router.post("/session/{session_id}/greeting")
+async def generate_greeting(
+    session_id: str,
+    body: GreetingRequest,
+    db: AsyncSession = Depends(get_db),
+    service: DialogueService = Depends(get_dialogue_service),
+):
+    logger.info(f"Generating greeting: session={session_id}, turn={body.turnId}")
+    result = await service.generate_greeting(
+        db=db, session_uuid=str(session_id), client_turn_id=body.turnId,
+    )
+    return result
+```
+
+- [ ] **Step 2: Update `generate_greeting` service method**
+
+In `dialogue_service.py`, find `generate_greeting`. Add `client_turn_id` parameter and pass it through to the `DialogueMessage` insert. Also add a lookup-and-replay step at the top:
+
+```python
+async def generate_greeting(
+    self,
+    db: AsyncSession,
+    session_uuid: str,
+    client_turn_id: str | None = None,
+) -> dict:
+    # ... existing session lookup ...
+
+    if client_turn_id:
+        state, rows = await self._lookup_turn_state(db, session.id, client_turn_id)
+        if state == "full" or state == "partial":
+            # Greeting only writes one row, so 'partial' here means the row IS the greeting.
+            ai_row = next((r for r in rows if r.role == "ai"), None)
+            if ai_row:
+                return {"aiMessage": ai_row.content}
+
+    # ... existing LLM call + INSERT ...
+
+    ai_msg = DialogueMessage(
+        # ... existing fields ...
+        client_turn_id=client_turn_id,
+    )
+    db.add(ai_msg)
+    await db.commit()
+    return {"aiMessage": ai_text}
+```
+
+(Adjust the exact field names to match the existing implementation.)
+
+- [ ] **Step 3: Update `tests/api/test_dialogue_greeting.py`**
+
+Existing tests will fail because `turnId` is now required. Update each greeting POST to include it in the JSON body:
+
+```python
+resp = await client.post(
+    f"/session/{session_uuid}/greeting",
+    json={"turnId": "test-turn-greeting-1"},
+)
+```
+
+- [ ] **Step 4: Run greeting tests**
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py -v
+```
+
+Expected: green.
+
+- [ ] **Step 5: Add an idempotency test for greeting**
+
+Append to `tests/api/test_dialogue_greeting.py` a test that calls greeting twice with the same turnId and asserts the LLM was called only once:
+
+```python
+@pytest.mark.asyncio
+async def test_greeting_idempotent_on_retry(test_env):
+    client = test_env  # adapt to whatever the fixture returns
+    # First call
+    r1 = await client.post(
+        f"/session/{SESSION}/greeting",
+        json={"turnId": "T-greet-1"},
+    )
+    # Second call same turnId
+    r2 = await client.post(
+        f"/session/{SESSION}/greeting",
+        json={"turnId": "T-greet-1"},
+    )
+    assert r1.status_code == r2.status_code == 200
+    assert r1.json() == r2.json()
+    # The mock LLM should have been called once
+    # (assert depending on how the mock is exposed; if hard, skip this assertion)
+```
+
+(Match the existing test's fixture / app-client style. If the mock LLM isn't reachable from the test, skip the call-count assertion and rely on equality of responses.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/test_dialogue_greeting.py
+git commit -m "feat(speaking): require turnId on greeting endpoint, idempotent reruns"
+```
+
+---
+
+### Task 8: WS `/speak-stream` start frame validates `turnId`
+
+**Files:**
+- Modify: `app/api/dialogue.py` — WS handler around line 248–490.
+
+- [ ] **Step 1: Read the WS handler to find the start-frame parsing**
+
+```bash
+sed -n '249,310p' /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py
+```
+
+Locate the line that does `start_data = await websocket.receive_json()` and the validation `if not session_uuid: ... 'sessionId required'`.
+
+- [ ] **Step 2: Add `turnId` validation right after `sessionId` validation**
+
+```python
+turn_id = start_data.get("turnId")
+if not turn_id or not isinstance(turn_id, str):
+    await websocket.send_json({"type": "error", "message": "turnId required"})
+    await websocket.close()
+    return
+```
+
+- [ ] **Step 3: Pass `turn_id` into the eventual `service.speak()` call**
+
+The WS handler eventually buffers chunks then calls something like `service.speak(...)`. Pass `client_turn_id=turn_id` there.
+
+- [ ] **Step 4: Smoke check**
+
+There may not be a unit test for the WS path. A manual smoke check via the frontend in Task 16 will validate this.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/api/dialogue.py
+git commit -m "feat(speaking): require turnId in WS speak-stream start frame"
+```
+
+---
+
+### Task 9: Frontend types — `turnId` and `recovery` on `PreviewChatMessage`
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts`
+
+- [ ] **Step 1: Inspect current type**
+
+```bash
+grep -n "PreviewChatMessage\|unrecoverable\|recovery" /Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts
+```
+
+- [ ] **Step 2: Add the two new optional fields, remove `unrecoverable`**
+
+Locate the `PreviewChatMessage` interface and apply:
+
+```ts
+export interface PreviewChatMessage {
+  // ... existing fields ...
+  turnId?: string                                       // NEW — set on creation
+  recovery?: 'retry' | 'rerecord' | 'restart'          // NEW — set on error
+  // unrecoverable?: boolean                            // REMOVED
+}
+```
+
+- [ ] **Step 3: Type-check**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+npx vue-tsc --noEmit 2>&1 | head -40
+```
+
+Expected: errors at every existing reference to `unrecoverable` (in `useDialogueEngine.ts` and `DialogueChatView.vue`). These are fixed in subsequent tasks.
+
+- [ ] **Step 4: Commit (with broken type-check noted in commit body)**
+
+```bash
+git add src/types/englishSpeaking.ts
+git commit -m "types(speaking): add turnId/recovery to PreviewChatMessage
+
+Removes unrecoverable in favor of recovery === 'restart'. Engine and
+view will be migrated in subsequent commits — type-check is
+intentionally broken between this commit and the next two."
+```
+
+---
+
+### Task 10: `llmService.ts` — `speak` and `generateGreeting` accept `turnId`
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`
+
+- [ ] **Step 1: Inspect current signatures**
+
+```bash
+grep -n "speak\|generateGreeting\|FormData\|fetch" /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts | head -30
+```
+
+- [ ] **Step 2: Add `turnId` to the function signatures + bodies**
+
+For `speak` (FormData multipart):
+
+```ts
+async *speak(
+  sessionId: string,
+  audioBlob: Blob,
+  signal: AbortSignal,
+  turnId: string,                          // NEW
+): AsyncGenerator<DialogueEvent> {
+  const form = new FormData()
+  form.append('sessionId', sessionId)
+  form.append('turnId', turnId)            // NEW
+  form.append('audio', audioBlob, 'audio.webm')
+  // ... rest unchanged
+}
+```
+
+For `generateGreeting` (JSON body):
+
+```ts
+async generateGreeting(
+  sessionId: string,
+  signal: AbortSignal,
+  turnId: string,                          // NEW
+): Promise<{ aiMessage: string }> {
+  const res = await fetch(
+    `${API_BASE}/session/${sessionId}/greeting`,
+    {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ turnId }),    // NEW (was empty body or different)
+      signal,
+    },
+  )
+  // ... rest unchanged
+}
+```
+
+Update the corresponding interface definitions (likely in the same file or in `englishSpeaking.ts`) to add `turnId: string` to the method signatures of `DialogueAPI`.
+
+- [ ] **Step 3: Type-check**
+
+```bash
+npx vue-tsc --noEmit 2>&1 | head -40
+```
+
+Expected: errors now move to the engine call sites (`useDialogueEngine.ts`) where `speak()` and `generateGreeting()` are called without `turnId`. To be fixed in next tasks.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/services/llmService.ts src/types/englishSpeaking.ts
+git commit -m "feat(speaking): plumb turnId through llmService
+
+speak() and generateGreeting() now require turnId in the request.
+Engine call sites broken until next commit."
+```
+
+---
+
+### Task 11: Engine — `attachSession` accepts `totalRounds`; add `classifyError`
+
+Two small additive changes that don't break anything yet.
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` — pass `totalRounds` into `attachSession`.
+
+- [ ] **Step 1: Add `totalRounds` ref + extend `attachSession` signature**
+
+In `useDialogueEngine.ts`, add near other refs:
+
+```ts
+const totalRounds = ref<number>(3)
+```
+
+Update `attachSession`:
+
+```ts
+function attachSession(info: {
+  sessionId: string
+  expiresAt?: string | null
+  totalRounds: number
+}) {
+  sessionId.value = info.sessionId
+  expiresAt.value = info.expiresAt ?? null
+  totalRounds.value = info.totalRounds
+  if (info.expiresAt) startCountdown(info.expiresAt)
+}
+```
+
+Export `totalRounds` in the return value. Also expose a computed `isFinalRound`:
+
+```ts
+const isFinalRound = computed(() => currentRound.value >= totalRounds.value)
+```
+
+Add it to the returned object.
+
+- [ ] **Step 2: Add `classifyError` helper**
+
+Below `friendlyErrorMessage` at the bottom of the file:
+
+```ts
+export type Recovery = 'retry' | 'rerecord' | 'restart'
+
+export function classifyError(
+  raw: string | undefined,
+  status: number | undefined,
+  role: 'student' | 'ai',
+): Recovery {
+  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'
+}
+```
+
+Export from the engine's return for use by `finalizeError` (added later) — or keep it as a module-private helper used by the engine internals. Both work; a module-level export is cleaner for testing.
+
+- [ ] **Step 3: Update `DialogueChatView.vue` `onMounted` to pass `totalRounds`**
+
+```ts
+onMounted(() => {
+  if (props.sessionInfo) {
+    engine.attachSession({
+      sessionId: props.sessionInfo.sessionId,
+      expiresAt: props.sessionInfo.expiresAt,
+      totalRounds: props.totalRounds,    // NEW
+    })
+    engine.generateGreeting()
+  } else {
+    console.warn(...)
+  }
+})
+```
+
+(Adapt the `attachSession` call site to whatever shape currently exists.)
+
+- [ ] **Step 4: Type-check**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+npx vue-tsc --noEmit 2>&1 | head -40
+```
+
+Errors should now only be: (a) places that still reference `unrecoverable`; (b) `generateGreeting()` call sites missing `turnId`; (c) `speak()` call sites missing `turnId`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "feat(speaking): attachSession takes totalRounds; add classifyError helper"
+```
+
+---
+
+### Task 12: Engine — restructure `beginStudentStream` (push placeholders at commit, support final-round skip-aiMsg)
+
+This is the biggest engine change. The new contract: `beginStudentStream` opens the WS but pushes nothing; `commit` pushes placeholders and sends `stop`; `abort` closes the WS without touching messages.
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`
+
+- [ ] **Step 1: Replace the `beginStudentStream` function**
+
+Locate the existing `beginStudentStream` in `useDialogueEngine.ts` (around line 270–386). Replace its body with the new contract:
+
+```ts
+function beginStudentStream(opts: {
+  sampleRate: number
+  bits?: number
+  channels?: number
+}) {
+  if (!sessionId.value || isProcessing.value) return null
+
+  // Generate the turnId for this turn. Caller will use it for retry payloads.
+  const turnId = crypto.randomUUID()
+
+  // No messages pushed yet. They are pushed in commit().
+  let studentMsg: PreviewChatMessage | null = null
+  let aiMsg: PreviewChatMessage | null = null
+
+  const wsUrl = buildWsUrl('/speak-stream')
+  const ws = new WebSocket(wsUrl)
+  ws.binaryType = 'arraybuffer'
+
+  let aborted = false
+  let committed = false
+  let chunkQueue: ArrayBuffer[] = []
+  let open = false
+
+  const finalizeError = (raw: string) => {
+    const text = friendlyErrorMessage(raw)
+    if (studentMsg && studentMsg.status === 'loading') {
+      studentMsg.status = 'error'
+      studentMsg.error = text
+      studentMsg.recovery = classifyError(raw, undefined, 'student')
+    } else if (aiMsg && aiMsg.status === 'loading') {
+      aiMsg.status = 'error'
+      aiMsg.error = text
+      aiMsg.recovery = classifyError(raw, undefined, 'ai')
+    }
+  }
+
+  ws.onopen = () => {
+    open = true
+    ws.send(JSON.stringify({
+      type: 'start',
+      sessionId: sessionId.value,
+      turnId,
+      sampleRate: opts.sampleRate,
+      bits: opts.bits ?? 16,
+      channels: opts.channels ?? 1,
+    }))
+    for (const c of chunkQueue) ws.send(c)
+    chunkQueue = []
+  }
+
+  ws.onmessage = (e: MessageEvent) => {
+    if (!committed) return  // tokens shouldn't arrive before commit, but defensive
+    try {
+      const data = JSON.parse(e.data)
+      if (data.type === 'transcript' && studentMsg) {
+        studentMsg.content = data.text
+        studentMsg.status = 'done'
+      }
+      else if (data.type === 'token' && aiMsg) {
+        aiMsg.content += data.content
+      }
+      else if (data.type === 'done') {
+        if (aiMsg) aiMsg.status = 'done'
+        else if (studentMsg && studentMsg.status === 'loading') studentMsg.status = 'done'
+        isComplete.value = !!data.isComplete
+        if (!data.isComplete) currentRound.value++
+        ws.close()
+      }
+      else if (data.type === 'error') {
+        finalizeError(data.message)
+        ws.close()
+      }
+    } catch { /* ignore */ }
+  }
+
+  ws.onerror = () => {
+    if (!aborted && committed) finalizeError('WebSocket error')
+  }
+  ws.onclose = () => {
+    if (!committed || aborted) return
+    if (studentMsg?.status === 'loading') finalizeError('Connection closed')
+    else if (aiMsg?.status === 'loading') finalizeError('Connection closed')
+  }
+
+  const pushChunk = (chunk: ArrayBuffer) => {
+    if (aborted) return
+    if (open && ws.readyState === WebSocket.OPEN) ws.send(chunk)
+    else chunkQueue.push(chunk)
+  }
+
+  const commit = (blob: Blob) => {
+    if (committed || aborted) return
+    committed = true
+
+    studentMsg = reactive<PreviewChatMessage>({
+      id: crypto.randomUUID(),
+      role: 'student',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+      audioBlob: blob,
+      turnId,
+    })
+    messages.value.push(studentMsg)
+
+    if (!isFinalRound.value) {
+      aiMsg = reactive<PreviewChatMessage>({
+        id: crypto.randomUUID(),
+        role: 'ai',
+        content: '',
+        timestamp: new Date(),
+        status: 'loading',
+        turnId,
+      })
+      messages.value.push(aiMsg)
+    }
+
+    if (open && ws.readyState === WebSocket.OPEN) {
+      ws.send(JSON.stringify({ type: 'stop' }))
+    } else {
+      // not open yet — close once it opens (rare edge)
+      ws.close()
+    }
+  }
+
+  const abort = () => {
+    aborted = true
+    try { ws.close() } catch { /* ignore */ }
+    // No messages were pushed (commit not called) → nothing to clean up.
+  }
+
+  return { turnId, pushChunk, commit, abort }
+}
+```
+
+- [ ] **Step 2: Remove `attachStudentBlob`**
+
+Delete the `attachStudentBlob` function and remove it from the return object. The blob is now attached inside `commit()`.
+
+- [ ] **Step 3: Remove the old finalizeError module-level / placeholders that the previous version had**
+
+The old version pushed `studentMsg` and `aiMsg` at the top of `beginStudentStream`. Make sure those lines are removed (they should be in the replaced block above).
+
+- [ ] **Step 4: Type-check**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+npx vue-tsc --noEmit 2>&1 | head -50
+```
+
+Errors should be:
+- `DialogueChatView.vue` calling `engine.attachStudentBlob` (it doesn't exist anymore) → fix in next task.
+- `DialogueChatView.vue` calling `streamCtl.finish()` instead of `streamCtl.commit()` → fix in next task.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
+git commit -m "feat(speaking): split beginStudentStream into begin → commit/abort
+
+Placeholders are pushed at commit() (when user clicks 完成), not at
+begin() (which now only opens the WS). abort() is a pure no-op on
+the message list. Final-round commits push only studentMsg.
+attachStudentBlob is removed (folded into commit)."
+```
+
+---
+
+### Task 13: Engine — `finalizeError` for HTTP path uses `classifyError`; add `discardCurrentTurn`; retry/regenerate pass `turnId`
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`
+
+- [ ] **Step 1: Update HTTP-path error labeling in `sendStudentMessage`**
+
+Inside the catch block of `sendStudentMessage` (around line 131–141), replace direct `.error = ...` assignments with the classifier-aware version:
+
+```ts
+} catch (err: any) {
+  if (err.name === 'AbortError') return
+  const status: number | undefined = err instanceof DialogueApiError ? err.status : undefined
+  if (studentMsg.status === 'loading') {
+    studentMsg.status = 'error'
+    studentMsg.error = friendlyErrorMessage(err.message)
+    studentMsg.recovery = classifyError(err.message, status, 'student')
+  } else if (aiMsg.status === 'loading') {
+    aiMsg.status = 'error'
+    aiMsg.error = friendlyErrorMessage(err.message)
+    aiMsg.recovery = classifyError(err.message, status, 'ai')
+  }
+}
+```
+
+Apply the same pattern in `regenerateAiMessage`'s catch.
+
+- [ ] **Step 2: Update `generateGreeting` error labeling**
+
+```ts
+} catch (err: unknown) {
+  if (err instanceof Error && err.name === 'AbortError') return
+  aiMsg.status = 'error'
+  const raw = err instanceof Error ? err.message : undefined
+  aiMsg.error = friendlyErrorMessage(raw)
+  const status = err instanceof DialogueApiError ? err.status : undefined
+  aiMsg.recovery = classifyError(raw, status, 'ai')
+}
+```
+
+(Remove the `unrecoverable = status === 404 || status === 409` line — `recovery === 'restart'` covers it now.)
+
+- [ ] **Step 3: Add `discardCurrentTurn` for the 重录 button**
+
+Add to the engine:
+
+```ts
+function discardCurrentTurn(messageId: string) {
+  const msg = messages.value.find(m => m.id === messageId)
+  if (!msg) return
+  const turnId = msg.turnId
+  if (!turnId) {
+    // Defensive: messages from greeting / pre-turnId code path have no turnId.
+    messages.value = messages.value.filter(m => m.id !== messageId)
+    return
+  }
+  // Splice out both rows of the turn (student + ai) sharing this turnId.
+  messages.value = messages.value.filter(m => m.turnId !== turnId)
+}
+```
+
+Export it.
+
+- [ ] **Step 4: Update `retryMessage` to use `turnId` (HTTP path)**
+
+The existing `retryMessage` in `useDialogueEngine.ts` calls `sendStudentMessage(blob)` which goes through `api.speak()`. We need that path to include the failing message's `turnId`:
+
+```ts
+async function retryMessage(messageId: string) {
+  const msg = messages.value.find(m => m.id === messageId)
+  if (!msg || msg.status !== 'error') return
+  if (msg.role === 'student' && msg.audioBlob && msg.turnId) {
+    const idx = messages.value.indexOf(msg)
+    messages.value.splice(idx)
+    await sendStudentMessage(msg.audioBlob, msg.turnId)
+  }
+}
+```
+
+And `sendStudentMessage` accepts `turnId`:
+
+```ts
+async function sendStudentMessage(audioBlob: Blob, turnId: string) {
+  // ... existing body ...
+  // When pushing the new studentMsg / aiMsg, set their turnId field.
+  // When calling api.speak(), pass turnId.
+  const generator = api.speak(sessionId.value, audioBlob, currentAbortController.signal, turnId)
+  // ...
+}
+```
+
+Update both `studentMsg` and `aiMsg` construction in `sendStudentMessage` to include `turnId`.
+
+- [ ] **Step 5: Update `regenerateAiMessage` to use the previous student message's `turnId`**
+
+```ts
+async function regenerateAiMessage(messageId: string) {
+  const msg = messages.value.find(m => m.id === messageId)
+  if (!msg || msg.role !== 'ai' || msg.status !== 'error') return
+
+  const idx = messages.value.indexOf(msg)
+  const prevStudent = messages.value.slice(0, idx).reverse().find(m => m.role === 'student')
+  if (!prevStudent?.audioBlob || !sessionId.value || !prevStudent.turnId) return
+
+  messages.value.splice(idx, 1)
+
+  const aiMsg = reactive<PreviewChatMessage>({
+    id: crypto.randomUUID(),
+    role: 'ai',
+    content: '',
+    timestamp: new Date(),
+    status: 'loading',
+    turnId: prevStudent.turnId,
+  })
+  messages.value.push(aiMsg)
+
+  currentAbortController = new AbortController()
+  try {
+    const generator = api.speak(
+      sessionId.value, prevStudent.audioBlob,
+      currentAbortController.signal, prevStudent.turnId,
+    )
+    // ... existing event handling ...
+  } catch (err: any) {
+    if (err.name === 'AbortError') return
+    aiMsg.status = 'error'
+    aiMsg.error = friendlyErrorMessage(err.message)
+    const status = err instanceof DialogueApiError ? err.status : undefined
+    aiMsg.recovery = classifyError(err.message, status, 'ai')
+  } finally {
+    currentAbortController = null
+  }
+}
+```
+
+- [ ] **Step 6: Type-check**
+
+```bash
+npx vue-tsc --noEmit 2>&1 | head -40
+```
+
+Should be down to view-side errors (`unrecoverable` in the template, `attachStudentBlob` call, etc.).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
+git commit -m "feat(speaking): plumb turnId + recovery through retry/regenerate paths
+
+retryMessage/regenerateAiMessage read turnId from the failed message
+and forward it to api.speak (HTTP path). Add discardCurrentTurn for
+重录. All error finalization paths now set message.recovery via
+classifyError."
+```
+
+---
+
+### Task 14: Engine — `generateGreeting` allocates a `turnId`
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`
+
+- [ ] **Step 1: Extract greeting body into a turnId-aware helper, then provide two public callers**
+
+Replace the existing `generateGreeting` and `retryGreeting` functions with this structure:
+
+```ts
+async function _runGreeting(turnId: string) {
+  if (!sessionId.value || greetingInflight.value) return
+  greetingInflight.value = true
+  greetingAbortController = new AbortController()
+
+  const aiMsg = reactive<PreviewChatMessage>({
+    id: crypto.randomUUID(),
+    role: 'ai',
+    content: '',
+    timestamp: new Date(),
+    status: 'loading',
+    turnId,
+  })
+  messages.value.push(aiMsg)
+
+  try {
+    const { aiMessage } = await api.generateGreeting(
+      sessionId.value, greetingAbortController.signal, turnId,
+    )
+    aiMsg.content = aiMessage
+    aiMsg.status = 'done'
+  } catch (err: unknown) {
+    if (err instanceof Error && err.name === 'AbortError') return
+    aiMsg.status = 'error'
+    const raw = err instanceof Error ? err.message : undefined
+    aiMsg.error = friendlyErrorMessage(raw)
+    const status = err instanceof DialogueApiError ? err.status : undefined
+    aiMsg.recovery = classifyError(raw, status, 'ai')
+  } finally {
+    greetingInflight.value = false
+    greetingAbortController = null
+  }
+}
+
+async function generateGreeting() {
+  await _runGreeting(crypto.randomUUID())
+}
+
+async function retryGreeting() {
+  if (greetingInflight.value) return
+  const firstAi = messages.value.find(m => m.role === 'ai')
+  if (firstAi?.status !== 'error' || firstAi.recovery === 'restart') return
+  const reuseTurnId = firstAi.turnId ?? crypto.randomUUID()
+  messages.value = messages.value.filter(m => m.id !== firstAi.id)
+  await _runGreeting(reuseTurnId)
+}
+```
+
+The same `turnId` is reused on retry so the backend's idempotency lookup hits and the user doesn't pay for two LLM calls if the first greeting succeeded server-side but the response was lost in transit. Fallback to a fresh UUID handles the legacy case where a pre-migration greeting message has no `turnId` field.
+
+- [ ] **Step 3: Type-check**
+
+```bash
+npx vue-tsc --noEmit 2>&1 | head -40
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
+git commit -m "feat(speaking): greeting carries turnId, retryGreeting reuses it"
+```
+
+---
+
+### Task 15: Recorder — accept `AbortSignal`
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts`
+
+- [ ] **Step 1: Update `startRecording` signature**
+
+Around line 77, change:
+
+```ts
+async function startRecording(signal?: AbortSignal): Promise<void> {
+  pcmChunks = []
+  silenceDetected.value = false
+
+  if (signal?.aborted) {
+    throw new DOMException('Aborted', 'AbortError')
+  }
+
+  try {
+    mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+    permissionState.value = 'granted'
+  } catch (err: any) {
+    if (err.name === 'NotAllowedError') {
+      permissionState.value = 'denied'
+    }
+    throw err
+  }
+
+  if (signal?.aborted) {
+    // User cancelled while permission prompt was open. Release the track.
+    mediaStream.getTracks().forEach(t => t.stop())
+    mediaStream = null
+    throw new DOMException('Aborted', 'AbortError')
+  }
+
+  // ... rest of existing setup unchanged ...
+}
+```
+
+(Two abort checks: pre-getUserMedia and post-getUserMedia. The first is fast; the second is the realistic cancel point.)
+
+- [ ] **Step 2: Type-check**
+
+```bash
+npx vue-tsc --noEmit 2>&1 | head -20
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts
+git commit -m "feat(speaking): startRecording accepts AbortSignal for cancel during mic acquisition"
+```
+
+---
+
+### Task 16: View — add `isStarting` / `reportFetchInflight` refs and update `state` computed
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+
+- [ ] **Step 1: Add the refs and abort controller**
+
+In `<script setup>` near other UI state refs:
+
+```ts
+const isStarting = ref(false)
+const reportFetchInflight = ref(false)
+let startAbortController: AbortController | null = null
+```
+
+- [ ] **Step 2: Update the `state` type and computed**
+
+```ts
+const state = computed<
+  'idle' | 'starting' | 'recording' | 'stt' | 'ai_thinking' | 'finalizing' | 'error' | 'done'
+>(() => {
+  if (isStarting.value) return 'starting'
+  if (recorder.isRecording.value) return 'recording'
+  if (engine.isComplete.value) return reportFetchInflight.value ? 'finalizing' : 'done'
+
+  const msgs = engine.messages.value
+  const last = msgs[msgs.length - 1]
+  if (last?.status === 'error') return 'error'
+  if (last?.role === 'student' && last.status === 'loading' && !last.content) return 'stt'
+  if (engine.isProcessing.value) return 'ai_thinking'
+  return 'idle'
+})
+```
+
+- [ ] **Step 3: Update `fetchReportSafe` to manage `reportFetchInflight`**
+
+```ts
+async function fetchReportSafe(): Promise<DialogueReport | null> {
+  reportFetchInflight.value = true
+  try {
+    await engine.completeSession()
+    return await engine.getReport()
+  } catch (err) {
+    console.warn('[speaking] getReport failed:', err)
+    return null
+  } finally {
+    reportFetchInflight.value = false
+  }
+}
+```
+
+- [ ] **Step 4: Type-check + dev server smoke**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+npx vue-tsc --noEmit 2>&1 | head -20
+```
+
+The view template still references `attachStudentBlob`-related code paths and `streamCtl.finish()` from the old API. Those compile errors are addressed in Task 17.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "feat(speaking): wire isStarting/reportFetchInflight refs into state computed"
+```
+
+---
+
+### Task 17: View — rewrite `handleStartRecording` / `handleCancelRecording` / `handleFinishRecording`
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+
+- [ ] **Step 1: Rewrite `handleStartRecording`**
+
+```ts
+let streamCtl: ReturnType<typeof engine.beginStudentStream> | null = null
+
+async function handleStartRecording() {
+  if (!engine.canRecord.value || recorder.isRecording.value || isStarting.value) return
+  player.stop()
+
+  isStarting.value = true
+  startAbortController = new AbortController()
+
+  try {
+    await recorder.startRecording(startAbortController.signal)
+    // Mic acquired — open the WS now (no placeholders pushed yet).
+    streamCtl = engine.beginStudentStream({
+      sampleRate: recorder.sampleRate.value,
+      bits: 16,
+      channels: 1,
+    })
+    if (streamCtl) {
+      recorder.onChunk.value = streamCtl.pushChunk
+    }
+  } catch (err: any) {
+    if (err.name === 'AbortError') {
+      // User cancelled during getUserMedia. State naturally returns to idle.
+    } else {
+      console.error('Failed to start recording:', err)
+    }
+  } finally {
+    isStarting.value = false
+    startAbortController = null
+  }
+}
+```
+
+- [ ] **Step 2: Add `handleCancelStarting` (cancel during `starting`)**
+
+```ts
+function handleCancelStarting() {
+  startAbortController?.abort()
+  // recorder.startRecording will reject with AbortError; no further cleanup needed.
+}
+```
+
+- [ ] **Step 3: Rewrite `handleCancelRecording` (cancel during `recording`)**
+
+```ts
+function handleCancelRecording() {
+  recorder.onChunk.value = null
+  if (streamCtl) {
+    streamCtl.abort()  // close WS; nothing on the message list to clean
+    streamCtl = null
+  }
+  if (recorder.isRecording.value) {
+    recorder.stopRecording().catch(() => {})
+  }
+  recorder.cleanup()
+}
+```
+
+(Note the rename: `streamCtl.abortStream()` → `streamCtl.abort()` to match the new engine API.)
+
+- [ ] **Step 4: Rewrite `handleFinishRecording`**
+
+```ts
+async function handleFinishRecording() {
+  if (!recorder.isRecording.value) return
+  const ctl = streamCtl
+  streamCtl = null
+  try {
+    const blob = await recorder.stopRecording()
+    recorder.onChunk.value = null
+    if (ctl) {
+      ctl.commit(blob)  // pushes placeholders, attaches blob, sends 'stop'
+    } else {
+      await engine.sendStudentMessage(blob, crypto.randomUUID())
+    }
+  } catch (err) {
+    console.error('Recording/send failed:', err)
+  }
+}
+```
+
+The fallback `sendStudentMessage` path now requires a `turnId`; we generate one fresh since this branch is "no streaming session was open" (rare error case).
+
+- [ ] **Step 5: Type-check**
+
+```bash
+npx vue-tsc --noEmit 2>&1 | head -30
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "feat(speaking): rewrite start/cancel/finish handlers for new engine API
+
+Cancel during starting aborts getUserMedia. Cancel during recording
+closes the WS but leaves the (empty) message list alone. Finish calls
+streamCtl.commit(blob) which is what now pushes placeholders."
+```
+
+---
+
+### Task 18: View — render `state-starting` and `state-finalizing` layers
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+
+- [ ] **Step 1: Add the `state-starting` layer in the `state-stack`**
+
+Insert after the `state-idle` block in the template (~line 304–331):
+
+```vue
+<!-- starting -->
+<div class="state-layer state-starting" :style="stateStyle('starting')">
+  <div class="record-capsule">
+    <button class="cancel-btn" @click="handleCancelStarting">
+      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+        stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+      </svg>
+      取消
+    </button>
+    <div class="record-meter">
+      <svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+        stroke-width="2" stroke-linecap="round">
+        <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+      </svg>
+      <span class="record-time">准备录音中...</span>
+    </div>
+    <button class="finish-btn" disabled>
+      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+        stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+        <polyline points="22 4 12 14.01 9 11.01" />
+      </svg>
+      完成
+    </button>
+  </div>
+</div>
+```
+
+- [ ] **Step 2: Add the `state-finalizing` layer**
+
+Insert after the `state-error` block:
+
+```vue
+<!-- finalizing -->
+<div class="state-layer state-center" :style="stateStyle('finalizing')">
+  <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+    stroke-width="2" stroke-linecap="round">
+    <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+  </svg>
+  <span class="center-text">正在生成你的本次对话报告...</span>
+</div>
+```
+
+- [ ] **Step 3: Verify in the dev server**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+npm run dev
+```
+
+Open the preview, start a session. Click 开始录音 — you should see the capsule with spinner + "准备录音中..." for the brief mic-acquisition window (longer on first-permission grant). Cancel during that window; UI returns to idle.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "feat(speaking): render starting/finalizing layers in state-stack"
+```
+
+---
+
+### Task 19: View — error card with recovery-aware buttons + handlers
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+
+- [ ] **Step 1: Replace the error-card buttons (student & AI sides)**
+
+Locate the two `error-card` blocks (one in the AI message branch ~line 117–124, one in the student message branch ~line 202–205) and the `state-error` retry pill ~line 388–394.
+
+Add these helpers in `<script setup>`:
+
+```ts
+function hasRetryButton(m: PreviewChatMessage): boolean {
+  return m.recovery === 'retry' || m.recovery === 'restart'
+}
+function hasRerecordButton(m: PreviewChatMessage): boolean {
+  return m.role === 'student' && (m.recovery === 'retry' || m.recovery === 'rerecord')
+}
+function retryButtonLabel(m: PreviewChatMessage): string {
+  if (m.recovery === 'restart') return '返回重开'
+  return '重试'
+}
+async function handleRetry(m: PreviewChatMessage) {
+  if (m.recovery === 'restart') {
+    emit('restart')
+    return
+  }
+  if (m.role === 'student') {
+    await engine.retryMessage(m.id)
+  } else {
+    // greeting case
+    const isGreeting = !engine.messages.value
+      .slice(0, engine.messages.value.indexOf(m))
+      .some(x => x.role === 'student')
+    if (isGreeting) {
+      await engine.retryGreeting()
+    } else {
+      await engine.regenerateAiMessage(m.id)
+    }
+  }
+}
+function handleRerecord(m: PreviewChatMessage) {
+  engine.discardCurrentTurn(m.id)
+}
+```
+
+Replace the AI-side error card (around line 117):
+
+```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"
+    :disabled="engine.greetingInflight.value"
+    @click="handleRetry(message)"
+  >{{ retryButtonLabel(message) }}</button>
+</div>
+```
+
+The `disabled` simply prevents double-clicking retry on a greeting while a greeting attempt is in flight; the engine's `retryGreeting()` already guards via `if (greetingInflight.value) return`, so this is purely a UX nicety. For non-greeting AI errors, `greetingInflight` is always false, so the disable has no effect.
+
+Replace the student-side error card (around line 202):
+
+```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)"
+  >{{ retryButtonLabel(message) }}</button>
+  <button
+    v-if="hasRerecordButton(message)"
+    class="rerecord-btn"
+    @click="handleRerecord(message)"
+  >重录</button>
+</div>
+```
+
+Add a small CSS rule for `.rerecord-btn` after the existing `.retry-btn` style (around line 1252):
+
+```scss
+.rerecord-btn {
+  padding: 3px 10px;
+  background: transparent;
+  border: 1px solid #d1d5db;
+  border-radius: 999px;
+  font-size: 11px;
+  color: #6b7280;
+  cursor: pointer;
+  margin-left: 4px;
+  &:hover { background: #f9fafb; border-color: #9ca3af; color: #374151; }
+}
+```
+
+- [ ] **Step 2: Replace the `state-error` retry pill (control-zone error layer)**
+
+Around line 388, replace `handleRetry` for the control-zone with the same recovery-aware logic. Reuse the new `handleRetry(message)` by finding the latest errored message:
+
+```vue
+<div class="state-layer state-error" :style="stateStyle('error')">
+  <div class="error-info">
+    <span class="warn-icon">⚠️</span>
+    <span class="warn-text">{{ lastErrorText }}</span>
+  </div>
+  <button
+    v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
+    class="retry-pill"
+    @click="handleRetry(lastErroredMessage)"
+  >{{ retryButtonLabel(lastErroredMessage) }}</button>
+</div>
+```
+
+Add a helper computed:
+
+```ts
+const lastErroredMessage = computed<PreviewChatMessage | null>(() => {
+  const msgs = engine.messages.value
+  for (let i = msgs.length - 1; i >= 0; i--) {
+    if (msgs[i].status === 'error') return msgs[i]
+  }
+  return null
+})
+```
+
+- [ ] **Step 3: Remove the old `retryAiMessage` if it's no longer referenced**
+
+The previous `retryAiMessage` function may have logic for greeting unrecoverable. The new `handleRetry` covers it (`recovery === 'restart'` → emit restart). Delete `retryAiMessage` if its only callers were the old error-card buttons.
+
+- [ ] **Step 4: Type-check**
+
+```bash
+npx vue-tsc --noEmit 2>&1 | head -20
+```
+
+- [ ] **Step 5: Manual smoke**
+
+```bash
+npm run dev
+```
+
+- Force a "No speech detected" by recording silence — student error should show only 重录.
+- Toggle DevTools → throttle to "Offline" momentarily during a real recording → student error should show 重试 + 重录.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "feat(speaking): recovery-aware error card with 重试/重录/返回重开 buttons"
+```
+
+---
+
+### Task 20: View — pass `turnId` for greeting on mount + final manual QA
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` — already touched in Task 11 for `attachSession`. Ensure the `engine.generateGreeting()` call doesn't take a `turnId` from the view (the engine generates its own).
+
+This is the final task — pure verification.
+
+- [ ] **Step 1: Confirm greeting still works**
+
+```bash
+npm run dev
+```
+
+Open a fresh session. Greeting AI message should appear normally.
+
+- [ ] **Step 2: Run the full backend test suite once more**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest -v
+```
+
+All green.
+
+- [ ] **Step 3: Walk through the manual QA checklist from the spec**
+
+For each item, manually verify and check off:
+
+- [ ] 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 (mic released, no error bubbles).
+- [ ] Cancel during recording → no error bubbles in chat, return to idle.
+- [ ] Cancel during `starting` → no error bubbles in chat, return to idle, mic released (verify in DevTools → about:mediainternals or Stop indicator).
+- [ ] Throttle network in DevTools → Offline, complete a turn, switch back online → 重试 yields the cached content (transcript/AI reply), `currentRound` does NOT double-increment. Verify `current_round` in the DB matches expected.
+- [ ] Trigger "No speech detected" by recording silence → student error card shows ONLY 重录 button.
+- [ ] Trigger 404 by manually setting `dialogue_session.status = 'completed'` in the DB then sending a turn → error card shows ONLY 返回重开.
+- [ ] Reach the final round, click 完成 → no AI bubble appears; `state-finalizing` card shows briefly; transition to report view.
+
+- [ ] **Step 4: Commit (only if there were any final fix-ups)**
+
+```bash
+# Only if you had to tweak anything
+git add -A
+git commit -m "chore(speaking): final QA pass on recording resilience flow"
+```
+
+---
+
+## Self-Review
+
+After implementation, before declaring complete, run through this checklist against the spec (`docs/superpowers/specs/2026-04-26-dialogue-recording-resilience-design.md`):
+
+**Spec coverage:**
+
+- ✅ Goal 1 (starting state with cancel) — Tasks 15, 16, 17, 18
+- ✅ Goal 2 (取消 is no-op) — Tasks 12, 17 (commit pushes, abort doesn't)
+- ✅ Goal 3 (placeholders pushed at commit) — Task 12
+- ✅ Goal 4 (recovery-aware errors) — Tasks 11, 13, 19
+- ✅ Goal 5 (idempotency) — Tasks 1–8, 13
+- ✅ Goal 6 (skip LLM on final round) — Task 5 (backend), Task 12 (frontend)
+
+**Type consistency:**
+- `recovery` field uses `'retry' | 'rerecord' | 'restart'` everywhere (engine, view, types).
+- `turnId` is `string` (UUID) everywhere.
+- `client_turn_id` (snake_case) on the backend SQL/model; `turnId` (camelCase) on API + frontend.
+
+**Placeholder scan:**
+- Every step has executable code or commands.
+- All file paths are absolute.
+- `Task 6` mentions "exact diff depends on the current code shape" — acceptable because the surrounding code is shown in `grep` output during the step.
+
+**Atomic commits:**
+- Each task ends with one commit; backend and frontend commits are scoped to their respective repos via working-directory-aware `git add` paths.
+
+---
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-04-26-dialogue-recording-resilience.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for this plan because backend tests + frontend type-check are concrete pass/fail signals each subagent can self-verify.
+
+**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
+
+Which approach?

+ 4 - 0
env.d.ts

@@ -1,2 +1,6 @@
 // eslint-disable-next-line spaced-comment
 /// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  readonly VITE_SPEAKING_API_HOST?: string
+}

+ 1 - 0
package.json

@@ -7,6 +7,7 @@
     "dev": "vite",
     "build": "run-p type-check \"build-only {@}\" --",
     "preview": "vite preview",
+    "test:speaking-api-config": "node scripts/test-speaking-api-config.mjs",
     "build-only": "vite build",
     "type-check": "vue-tsc --build --force",
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",

+ 34 - 0
scripts/test-speaking-api-config.mjs

@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/services/speakingApiConfig.ts', import.meta.url)
+const source = await readFile(sourceUrl, 'utf8')
+const compiled = ts.transpileModule(source, {
+  compilerOptions: {
+    module: ts.ModuleKind.ESNext,
+    target: ts.ScriptTarget.ES2020,
+  },
+}).outputText
+
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+assert.equal(
+  mod.getSpeakingApiHost({}),
+  'https://ppt-english-speaking-api.cocorobo.cn',
+)
+
+assert.equal(
+  mod.getSpeakingApiBaseUrl({}),
+  'https://ppt-english-speaking-api.cocorobo.cn/api/speaking/dialogue',
+)
+
+assert.equal(
+  mod.getSpeakingApiBaseUrl({ VITE_SPEAKING_API_HOST: 'https://example.com/' }),
+  'https://example.com/api/speaking/dialogue',
+)
+
+assert.equal(
+  mod.buildSpeakingWsUrl('/speak-stream'),
+  'wss://ppt-english-speaking-api.cocorobo.cn/api/speaking/dialogue/speak-stream',
+)

+ 2 - 8
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -1,6 +1,7 @@
 import { ref, reactive, computed, onUnmounted } from 'vue'
 import type { PreviewChatMessage, DialogueAPI, DialogueReport } from '@/types/englishSpeaking'
 import { createDialogueApi, DialogueApiError } from '../services/llmService'
+import { buildSpeakingWsUrl } from '../services/speakingApiConfig'
 
 export function useDialogueEngine() {
   const messages = ref<PreviewChatMessage[]>([])
@@ -307,7 +308,7 @@ export function useDialogueEngine() {
     let studentMsg: PreviewChatMessage | null = null
     let aiMsg: PreviewChatMessage | null = null
 
-    const wsUrl = buildWsUrl('/speak-stream')
+    const wsUrl = buildSpeakingWsUrl('/speak-stream')
     const ws = new WebSocket(wsUrl)
     ws.binaryType = 'arraybuffer'
 
@@ -491,13 +492,6 @@ export function useDialogueEngine() {
 
 // ==================== Helpers ====================
 
-/** 把 /api/speaking/dialogue/... 的 HTTP base URL 转成 ws/wss URL */
-function buildWsUrl(path: string): string {
-  const API_BASE = 'http://localhost:8000/api/speaking/dialogue'
-  const wsBase = API_BASE.replace(/^http/, 'ws')
-  return wsBase + path
-}
-
 /** 把后端英文错误转成面向用户的中文友好文案 */
 function friendlyErrorMessage(raw: string | undefined): string {
   const map: Record<string, string> = {

+ 2 - 1
src/views/Editor/EnglishSpeaking/services/llmService.ts

@@ -8,8 +8,9 @@ import type {
   DialogueReport,
   SentenceEvaluation,
 } from '@/types/englishSpeaking'
+import { getSpeakingApiBaseUrl } from './speakingApiConfig'
 
-const API_BASE = 'http://localhost:8000/api/speaking/dialogue'
+const API_BASE = getSpeakingApiBaseUrl()
 
 export class DialogueApiError extends Error {
   status: number

+ 25 - 0
src/views/Editor/EnglishSpeaking/services/speakingApiConfig.ts

@@ -0,0 +1,25 @@
+export const DEFAULT_SPEAKING_API_HOST = 'https://ppt-english-speaking-api.cocorobo.cn'
+export const SPEAKING_API_DIALOGUE_PATH = '/api/speaking/dialogue'
+
+type SpeakingApiEnv = {
+  readonly [key: string]: string | boolean | undefined
+  VITE_SPEAKING_API_HOST?: string
+}
+
+function normalizeHost(host: string): string {
+  return host.replace(/\/+$/, '')
+}
+
+export function getSpeakingApiHost(env: SpeakingApiEnv | undefined = import.meta.env): string {
+  const configuredHost = env?.VITE_SPEAKING_API_HOST?.trim()
+  return normalizeHost(configuredHost || DEFAULT_SPEAKING_API_HOST)
+}
+
+export function getSpeakingApiBaseUrl(env: SpeakingApiEnv | undefined = import.meta.env): string {
+  return `${getSpeakingApiHost(env)}${SPEAKING_API_DIALOGUE_PATH}`
+}
+
+export function buildSpeakingWsUrl(path: string, env: SpeakingApiEnv | undefined = import.meta.env): string {
+  const normalizedPath = path.startsWith('/') ? path : `/${path}`
+  return getSpeakingApiBaseUrl(env).replace(/^http/, 'ws') + normalizedPath
+}