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