2026-04-26-dialogue-recording-resilience.md 74 KB

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:

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

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:

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

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

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
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v

Expected: 3 passed.

  • Step 5: Commit
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:

@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).
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:

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
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py::test_replay_full_turn -v
  • Step 5: Commit
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:

@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).
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:

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:

from sqlalchemy.exc import IntegrityError
  • Step 4: Run the new tests; expect PASS
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
  • Step 5: Run the full backend test suite to confirm no regressions
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
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:

@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).
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:

        # ⑤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
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
uv run pytest -v
  • Step 6: Commit
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
@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
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
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():

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:

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:

resp = await client.post(
    f"/session/{session_uuid}/greeting",
    json={"turnId": "test-turn-greeting-1"},
)
  • Step 4: Run greeting tests
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:

@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
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

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

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:

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
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)
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

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

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

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

const totalRounds = ref<number>(3)

Update attachSession:

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:

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:

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

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

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:

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

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:

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:

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

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
npx vue-tsc --noEmit 2>&1 | head -40
  • Step 4: Commit
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:

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
npx vue-tsc --noEmit 2>&1 | head -20
  • Step 3: Commit
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:

const isStarting = ref(false)
const reportFetchInflight = ref(false)
let startAbortController: AbortController | null = null
  • Step 2: Update the state type and computed
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
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
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
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

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)
function handleCancelStarting() {
  startAbortController?.abort()
  // recorder.startRecording will reject with AbortError; no further cleanup needed.
}
  • Step 3: Rewrite handleCancelRecording (cancel during recording)
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
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
npx vue-tsc --noEmit 2>&1 | head -30
  • Step 6: Commit
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):

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

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

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

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

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

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

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

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
npx vue-tsc --noEmit 2>&1 | head -20
  • Step 5: Manual smoke
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

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
npm run dev

Open a fresh session. Greeting AI message should appear normally.

  • Step 2: Run the full backend test suite once more
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)

# 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?