Просмотр исходного кода

docs: plan student speaking session history

jimmylee 1 неделя назад
Родитель
Сommit
60fb62cbe9
1 измененных файлов с 982 добавлено и 0 удалено
  1. 982 0
      docs/superpowers/plans/2026-04-27-speaking-student-session-history.md

+ 982 - 0
docs/superpowers/plans/2026-04-27-speaking-student-session-history.md

@@ -0,0 +1,982 @@
+# Speaking Student Session History 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:** Formal student mode restores the latest speaking session for the same `configId + userId`, while editor preview modes keep creating isolated preview sessions.
+
+**Architecture:** Backend persists `config_id` on `dialogue_session`, keeps `POST /session` as always-create, and adds a latest lookup endpoint that returns session metadata plus message history. Frontend reads URL `mode` and `userid` inside `TopicDiscussionPreview`, only calls latest lookup for `mode=student`, and initializes `DialogueChatView` with historical messages for active sessions.
+
+**Tech Stack:** Vue 3 + TypeScript + Pinia frontend in `/Users/buoy/Development/gitrepo/PPT`; FastAPI + SQLAlchemy async + pytest backend in `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`.
+
+---
+
+## File Structure
+
+Backend repository: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`
+
+- Modify: `app/models/dialogue.py`
+  - Add nullable `DialogueSession.config_id`.
+- Modify: `init.sql`
+  - Add `config_id` to fresh installs and add lookup index.
+- Create: `migrations/2026-04-27-add-dialogue-session-config-id.sql`
+  - Manual migration for existing deployments.
+- Modify: `app/service/speaking/dialogue_service.py`
+  - Accept/store `config_id`.
+  - Add helper to adapt `DialogueMessage` history for latest response.
+- Modify: `app/api/dialogue.py`
+  - Accept `configId` in create request.
+  - Add `GET /sessions/latest` route.
+- Modify: `tests/api/test_dialogue_greeting.py`
+  - Cover create storage and latest lookup behavior using the existing ASGI test harness.
+
+Frontend repository: `/Users/buoy/Development/gitrepo/PPT`
+
+- Modify: `src/types/englishSpeaking.ts`
+  - Add `configId`, `userId`, historical message/session types, and `checking-history` preview state.
+- Modify: `src/views/Editor/EnglishSpeaking/services/llmService.ts`
+  - Send `configId/userId` on create.
+  - Add `getLatestSession(configId, userId)`.
+- Modify: `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`
+  - Let `attachSession` seed initial messages and current round.
+- Modify: `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+  - Pass historical messages into the engine.
+  - Skip greeting generation when history already includes messages.
+- Modify: `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`
+  - Read URL `mode/userid`.
+  - Query latest only in `mode=student`.
+  - Open active history in chat and completed history in report.
+
+---
+
+## Task 1: Backend Session Ownership Schema
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/models/dialogue.py`
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/init.sql`
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/migrations/2026-04-27-add-dialogue-session-config-id.sql`
+- Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py`
+
+- [ ] **Step 1: Add failing API test for create storing `config_id` and `user_id`**
+
+Append this test to `tests/api/test_dialogue_greeting.py`:
+
+```python
+@pytest.mark.asyncio
+async def test_post_session_stores_config_and_user(test_env):
+    client, SessionLocal = test_env
+    r = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Animals",
+        "grade": "grade5-1",
+        "totalRounds": 3,
+        "configId": "config-abc",
+        "userId": "student-001",
+    })
+    assert r.status_code == 200
+
+    async with SessionLocal() as db:
+        result = await db.execute(select(DialogueSession))
+        session = result.scalar_one()
+
+    assert session.config_id == "config-abc"
+    assert session.user_id == "student-001"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py::test_post_session_stores_config_and_user -q
+```
+
+Expected: fail with an attribute error or assertion failure because `DialogueSession.config_id` does not exist or is not populated.
+
+- [ ] **Step 3: Add ORM field**
+
+In `app/models/dialogue.py`, add `config_id` directly after `user_id`:
+
+```python
+    user_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
+    config_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True)
+    topic: Mapped[str] = mapped_column(String(255))
+```
+
+- [ ] **Step 4: Update fresh install schema**
+
+In `init.sql`, change `dialogue_session` to include `config_id` after `user_id`, and add the lookup index:
+
+```sql
+    user_id VARCHAR(64) NULL,
+    config_id VARCHAR(36) NULL,
+    topic VARCHAR(255) NOT NULL,
+```
+
+Add this index after `UNIQUE INDEX uk_uuid (uuid)`:
+
+```sql
+    INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at)
+```
+
+The resulting tail of `dialogue_session` should have comma placement like:
+
+```sql
+    completed_at DATETIME NULL,
+    UNIQUE INDEX uk_uuid (uuid),
+    INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at)
+);
+```
+
+- [ ] **Step 5: Add existing deployment migration**
+
+Create `migrations/2026-04-27-add-dialogue-session-config-id.sql`:
+
+```sql
+USE speaking;
+
+ALTER TABLE dialogue_session
+ADD COLUMN config_id VARCHAR(36) NULL AFTER user_id;
+
+CREATE INDEX idx_dialogue_session_config_user_created
+ON dialogue_session (config_id, user_id, created_at);
+```
+
+- [ ] **Step 6: Thread `config_id` through service**
+
+In `app/service/speaking/dialogue_service.py`, update the `create_session_only` signature:
+
+```python
+    async def create_session_only(
+        self,
+        db: AsyncSession,
+        topic: str,
+        grade: str,
+        vocabulary: list[str],
+        sentences: list[str],
+        total_rounds: int = 3,
+        duration_seconds: int | None = None,
+        role_config: dict | None = None,
+        user_id: str | None = None,
+        config_id: str | None = None,
+    ) -> dict:
+```
+
+In the `DialogueSession(...)` constructor, add:
+
+```python
+            user_id=user_id,
+            config_id=config_id,
+            topic=topic,
+```
+
+- [ ] **Step 7: Accept `configId` in create API**
+
+In `app/api/dialogue.py`, update `CreateSessionRequest`:
+
+```python
+class CreateSessionRequest(BaseModel):
+    topic: str
+    grade: str
+    vocabulary: list[str] = []
+    sentences: list[str] = []
+    totalRounds: int = 3
+    # 时长(分钟)。与 totalRounds 是并存的两个结束条件,先满足谁就结束。
+    # 后端据此计算并持久化 expires_at,一经写入不会被修改 —— 关页面/刷新/换设备重进同一 session 都接续同一截止时刻。
+    durationMinutes: int | None = None
+    roleId: str | None = None
+    userId: str | None = None
+    configId: str | None = None
+```
+
+In `create_session`, pass `config_id=req.configId`:
+
+```python
+    result = await service.create_session_only(
+        db=db,
+        topic=req.topic,
+        grade=req.grade,
+        vocabulary=req.vocabulary,
+        sentences=req.sentences,
+        total_rounds=req.totalRounds,
+        duration_seconds=duration_seconds,
+        user_id=req.userId,
+        config_id=req.configId,
+    )
+```
+
+- [ ] **Step 8: Run the targeted backend test**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py::test_post_session_stores_config_and_user -q
+```
+
+Expected: pass.
+
+- [ ] **Step 9: Commit backend schema/create changes**
+
+Run:
+
+```bash
+git add app/models/dialogue.py app/service/speaking/dialogue_service.py app/api/dialogue.py init.sql migrations/2026-04-27-add-dialogue-session-config-id.sql tests/api/test_dialogue_greeting.py
+git commit -m "feat: store speaking config on dialogue sessions"
+```
+
+---
+
+## Task 2: Backend Latest Session Lookup
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py`
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`
+- Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py`
+
+- [ ] **Step 1: Add failing latest lookup tests**
+
+Append these tests to `tests/api/test_dialogue_greeting.py`:
+
+```python
+@pytest.mark.asyncio
+async def test_latest_session_returns_null_when_missing(test_env):
+    client, _ = test_env
+    r = await client.get(
+        "/api/speaking/dialogue/sessions/latest",
+        params={"configId": "missing-config", "userId": "student-001"},
+    )
+    assert r.status_code == 200
+    assert r.json() == {"session": None}
+
+
+@pytest.mark.asyncio
+async def test_latest_session_returns_newest_exact_config_user_pair(test_env):
+    client, SessionLocal = test_env
+
+    older = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Older",
+        "grade": "grade5-1",
+        "totalRounds": 2,
+        "configId": "config-abc",
+        "userId": "student-001",
+    })
+    newer = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Newer",
+        "grade": "grade5-1",
+        "totalRounds": 4,
+        "configId": "config-abc",
+        "userId": "student-001",
+    })
+    other_user = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Other user",
+        "grade": "grade5-1",
+        "totalRounds": 9,
+        "configId": "config-abc",
+        "userId": "student-002",
+    })
+    other_config = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Other config",
+        "grade": "grade5-1",
+        "totalRounds": 8,
+        "configId": "config-other",
+        "userId": "student-001",
+    })
+
+    assert older.status_code == newer.status_code == other_user.status_code == other_config.status_code == 200
+    expected_session_id = newer.json()["sessionId"]
+
+    r = await client.get(
+        "/api/speaking/dialogue/sessions/latest",
+        params={"configId": "config-abc", "userId": "student-001"},
+    )
+    assert r.status_code == 200
+    body = r.json()
+    assert body["session"]["sessionId"] == expected_session_id
+    assert body["session"]["status"] == "active"
+    assert body["session"]["totalRounds"] == 4
+    assert body["session"]["currentRound"] == 1
+    assert body["session"]["messages"] == []
+
+
+@pytest.mark.asyncio
+async def test_latest_session_includes_message_history(test_env):
+    client, _ = test_env
+    created = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "History",
+        "grade": "grade5-1",
+        "totalRounds": 3,
+        "configId": "config-history",
+        "userId": "student-001",
+    })
+    session_id = created.json()["sessionId"]
+
+    greeting = await client.post(
+        f"/api/speaking/dialogue/session/{session_id}/greeting",
+        json={"turnId": "history-greeting-turn"},
+    )
+    assert greeting.status_code == 200
+
+    latest = await client.get(
+        "/api/speaking/dialogue/sessions/latest",
+        params={"configId": "config-history", "userId": "student-001"},
+    )
+    assert latest.status_code == 200
+    messages = latest.json()["session"]["messages"]
+    assert messages == [
+        {
+            "id": messages[0]["id"],
+            "round": 1,
+            "role": "ai",
+            "content": "Hi! Ready to talk?",
+            "audioUrl": None,
+            "clientTurnId": "history-greeting-turn",
+        }
+    ]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py::test_latest_session_returns_null_when_missing tests/api/test_dialogue_greeting.py::test_latest_session_returns_newest_exact_config_user_pair tests/api/test_dialogue_greeting.py::test_latest_session_includes_message_history -q
+```
+
+Expected: fail with 404 because `/api/speaking/dialogue/sessions/latest` does not exist.
+
+- [ ] **Step 3: Add message/session adapter helper**
+
+In `app/service/speaking/dialogue_service.py`, add this method inside `DialogueService`, immediately before `get_report`:
+
+```python
+    async def get_latest_session_for_config_user(
+        self,
+        db: AsyncSession,
+        config_id: str,
+        user_id: str,
+    ) -> dict:
+        result = await db.execute(
+            select(DialogueSession)
+            .where(DialogueSession.config_id == config_id)
+            .where(DialogueSession.user_id == user_id)
+            .order_by(DialogueSession.created_at.desc(), DialogueSession.id.desc())
+            .limit(1)
+        )
+        session = result.scalar_one_or_none()
+        if session is None:
+            return {"session": None}
+
+        messages_result = await db.execute(
+            select(DialogueMessage)
+            .where(DialogueMessage.session_id == session.id)
+            .order_by(DialogueMessage.created_at, DialogueMessage.id)
+        )
+        messages = messages_result.scalars().all()
+
+        return {
+            "session": {
+                "sessionId": session.uuid,
+                "status": session.status,
+                "totalRounds": session.total_rounds,
+                "currentRound": session.current_round,
+                "expiresAt": session.expires_at.isoformat() if session.expires_at else None,
+                "createdAt": session.created_at.isoformat() if session.created_at else None,
+                "completedAt": session.completed_at.isoformat() if session.completed_at else None,
+                "messages": [
+                    {
+                        "id": msg.uuid,
+                        "round": msg.round,
+                        "role": msg.role,
+                        "content": msg.content,
+                        "audioUrl": msg.audio_url,
+                        "clientTurnId": msg.client_turn_id,
+                    }
+                    for msg in messages
+                ],
+            }
+        }
+```
+
+- [ ] **Step 4: Add latest route**
+
+In `app/api/dialogue.py`, add this route after `create_session` and before `/session/{session_id}/greeting` so fixed paths remain unambiguous:
+
+```python
+@router.get("/sessions/latest")
+async def get_latest_session(
+    configId: str,
+    userId: str,
+    db: AsyncSession = Depends(get_db),
+    service: DialogueService = Depends(get_dialogue_service),
+):
+    """Return the latest session for one speaking config and one student."""
+    if not configId.strip():
+        raise HTTPException(status_code=400, detail="configId is required")
+    if not userId.strip():
+        raise HTTPException(status_code=400, detail="userId is required")
+    return await service.get_latest_session_for_config_user(
+        db=db,
+        config_id=configId,
+        user_id=userId,
+    )
+```
+
+- [ ] **Step 5: Run latest lookup tests**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py::test_latest_session_returns_null_when_missing tests/api/test_dialogue_greeting.py::test_latest_session_returns_newest_exact_config_user_pair tests/api/test_dialogue_greeting.py::test_latest_session_includes_message_history -q
+```
+
+Expected: pass.
+
+- [ ] **Step 6: Run all backend API tests touched by this feature**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py -q
+```
+
+Expected: pass.
+
+- [ ] **Step 7: Commit latest lookup**
+
+Run:
+
+```bash
+git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/test_dialogue_greeting.py
+git commit -m "feat: add latest speaking session lookup"
+```
+
+---
+
+## Task 3: Frontend API Types and Engine History Seeding
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts`
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`
+- 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`
+
+- [ ] **Step 1: Extend frontend types**
+
+In `src/types/englishSpeaking.ts`, update `SessionConfig`:
+
+```ts
+export interface SessionConfig {
+  topic: string
+  grade: string
+  roleId: string
+  totalRounds: number
+  /** 时长(分钟)。与 totalRounds 是并存的两个结束条件,后端据此计算 expiresAt */
+  durationMinutes: number
+  vocabulary?: string[]
+  sentences?: string[]
+  configId?: string | null
+  userId?: string | null
+}
+```
+
+Add these interfaces after `SessionStartInfo`:
+
+```ts
+export interface HistoricalDialogueMessage {
+  id: string
+  round: number
+  role: 'ai' | 'student'
+  content: string
+  audioUrl?: string | null
+  clientTurnId?: string | null
+}
+
+export interface LatestSessionInfo extends SessionStartInfo {
+  status: 'active' | 'completed' | 'abandoned'
+  totalRounds: number
+  currentRound: number
+  createdAt: string | null
+  completedAt: string | null
+  messages: HistoricalDialogueMessage[]
+}
+
+export interface LatestSessionResponse {
+  session: LatestSessionInfo | null
+}
+```
+
+Update `SessionStartInfo`:
+
+```ts
+export interface SessionStartInfo {
+  sessionId: string
+  expiresAt: string | null
+  currentRound?: number
+  messages?: HistoricalDialogueMessage[]
+}
+```
+
+Update preview state:
+
+```ts
+export type PreviewDialogueState = 'checking-history' | 'ready' | 'chatting' | 'completed'
+```
+
+Update `DialogueAPI`:
+
+```ts
+export interface DialogueAPI {
+  createSession(config: SessionConfig): Promise<SessionInfo>
+  getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse>
+  completeSession(sessionId: string): Promise<void>
+  /** Throws DOMException('AbortError') on signal abort; throws DialogueApiError on non-OK HTTP. */
+  generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise<GreetingInfo>
+  generateTaskHint(sessionId: string): Promise<TaskHint>
+  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal, turnId: string): AsyncGenerator<SSEEvent>
+  getReport(sessionId: string): Promise<DialogueReport>
+}
+```
+
+- [ ] **Step 2: Extend real API client**
+
+In `src/views/Editor/EnglishSpeaking/services/llmService.ts`, add `LatestSessionResponse` to the type import list.
+
+In `RealDialogueAPI.createSession`, include `configId` and `userId` in the JSON body:
+
+```ts
+      body: JSON.stringify({
+        topic: config.topic,
+        grade: config.grade,
+        vocabulary: config.vocabulary ?? [],
+        sentences: config.sentences ?? [],
+        totalRounds: config.totalRounds,
+        durationMinutes: config.durationMinutes,
+        roleId: config.roleId,
+        configId: config.configId ?? null,
+        userId: config.userId ?? null,
+      }),
+```
+
+Add this method in `RealDialogueAPI` after `createSession`:
+
+```ts
+  async getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse> {
+    const params = new URLSearchParams({ configId, userId })
+    const res = await fetch(`${API_BASE}/sessions/latest?${params.toString()}`, {
+      method: 'GET',
+      credentials: 'include',
+    })
+    if (!res.ok) {
+      throw new DialogueApiError(`getLatestSession failed: ${res.status}`, res.status)
+    }
+    return res.json()
+  }
+```
+
+- [ ] **Step 3: Seed engine from historical messages**
+
+In `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`, update imports:
+
+```ts
+import type { PreviewChatMessage, DialogueAPI, DialogueReport, HistoricalDialogueMessage } from '@/types/englishSpeaking'
+```
+
+Add helper near the session attach section:
+
+```ts
+function toPreviewMessage(message: HistoricalDialogueMessage): PreviewChatMessage {
+  return {
+    id: message.id,
+    role: message.role,
+    content: message.content,
+    timestamp: new Date(),
+    status: 'done',
+    turnId: message.clientTurnId ?? undefined,
+    audioUrl: message.audioUrl ?? undefined,
+  }
+}
+```
+
+Update `attachSession`:
+
+```ts
+  function attachSession(info: {
+    sessionId: string
+    expiresAt?: string | null
+    totalRounds: number
+    currentRound?: number
+    messages?: HistoricalDialogueMessage[]
+  }) {
+    sessionId.value = info.sessionId
+    expiresAt.value = info.expiresAt ?? null
+    totalRounds.value = info.totalRounds
+    currentRound.value = info.currentRound ?? 1
+    if (info.messages?.length) {
+      messages.value = info.messages.map(toPreviewMessage)
+    }
+    if (info.expiresAt) startCountdown(info.expiresAt)
+  }
+```
+
+- [ ] **Step 4: Pass history into chat view and skip greeting when restored**
+
+In `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`, update the `onMounted` block:
+
+```ts
+onMounted(() => {
+  if (props.sessionInfo) {
+    const hasHistory = !!props.sessionInfo.messages?.length
+    engine.attachSession({
+      sessionId: props.sessionInfo.sessionId,
+      expiresAt: props.sessionInfo.expiresAt,
+      totalRounds: props.totalRounds,
+      currentRound: props.sessionInfo.currentRound,
+      messages: props.sessionInfo.messages,
+    })
+    if (!hasHistory) engine.generateGreeting()
+  } else {
+    console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.')
+  }
+  // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)
+})
+```
+
+- [ ] **Step 5: Run frontend typecheck**
+
+Run:
+
+```bash
+pnpm vue-tsc --noEmit
+```
+
+Expected: pass. If the repo does not define `vue-tsc`, run:
+
+```bash
+pnpm tsc --noEmit
+```
+
+Expected: pass.
+
+- [ ] **Step 6: Commit frontend API/engine changes**
+
+Run:
+
+```bash
+git add src/types/englishSpeaking.ts src/views/Editor/EnglishSpeaking/services/llmService.ts src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "feat: seed speaking chat from historical session"
+```
+
+---
+
+## Task 4: Frontend Student History Flow
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`
+
+- [ ] **Step 1: Add runtime context helpers**
+
+In `TopicDiscussionPreview.vue`, after `preparedSession`, add:
+
+```ts
+const historyChecked = ref(false)
+
+const runtimeParams = computed(() => {
+  const params = new URLSearchParams(window.location.search)
+  return {
+    mode: params.get('mode'),
+    userId: params.get('userid'),
+  }
+})
+
+const isStudentRuntime = computed(() => runtimeParams.value.mode === 'student')
+const runtimeUserId = computed(() => runtimeParams.value.userId || '')
+```
+
+- [ ] **Step 2: Add history loader**
+
+Add this function after `startDialogue`:
+
+```ts
+async function loadLatestStudentSession() {
+  if (historyChecked.value) return
+  historyChecked.value = true
+  if (!isStudentRuntime.value) return
+
+  if (!props.configId) {
+    sessionError.value = '口语工具配置缺失,请联系老师重新发布。'
+    return
+  }
+  if (!runtimeUserId.value) {
+    sessionError.value = '学生身份缺失,请从课程入口重新进入。'
+    return
+  }
+
+  dialogueState.value = 'checking-history'
+  sessionError.value = null
+  try {
+    const api = createDialogueApi()
+    const { session } = await api.getLatestSession(props.configId, runtimeUserId.value)
+    if (!session) {
+      dialogueState.value = 'ready'
+      return
+    }
+
+    preparedSession.value = {
+      sessionId: session.sessionId,
+      expiresAt: session.expiresAt,
+      currentRound: session.currentRound,
+      messages: session.messages,
+    }
+
+    if (session.status === 'completed') {
+      const report = await api.getReport(session.sessionId)
+      handleDialogueComplete(report)
+      return
+    }
+
+    dialogueState.value = 'chatting'
+  } catch (err: unknown) {
+    console.error('[speaking] load latest session failed:', err)
+    dialogueState.value = 'ready'
+    if (err instanceof DialogueApiError) {
+      sessionError.value = `读取历史会话失败(${err.status}),请刷新重试`
+    } else {
+      sessionError.value = '读取历史会话失败,请刷新重试'
+    }
+  }
+}
+```
+
+- [ ] **Step 3: Pass identity when creating sessions**
+
+In `startDialogue`, add `configId` and `userId` to the `api.createSession` payload:
+
+```ts
+    const info = await api.createSession({
+      topic: speakingStore.config.topic || props.topic,
+      grade: speakingStore.config.grade,
+      totalRounds: speakingStore.config.practice.rounds ?? props.totalRounds,
+      durationMinutes: speakingStore.config.practice.duration,
+      roleId: mockRole.id,
+      vocabulary: speakingStore.config.learningGoals.vocabulary,
+      sentences: speakingStore.config.learningGoals.sentences,
+      configId: props.configId || null,
+      userId: isStudentRuntime.value ? runtimeUserId.value : null,
+    })
+```
+
+Update `preparedSession.value`:
+
+```ts
+    preparedSession.value = {
+      sessionId: info.sessionId,
+      expiresAt: info.expiresAt,
+      currentRound: info.currentRound,
+    }
+```
+
+- [ ] **Step 4: Render checking state**
+
+In the template, above the ready-stage block, add:
+
+```vue
+    <div v-if="dialogueState === 'checking-history'" class="ready-stage">
+      <div class="ready-header">
+        <h1 class="ready-title">
+          <span class="ready-title-icon">💬</span>
+          Topic Discussion
+        </h1>
+        <p class="ready-subtitle">正在读取你的练习记录...</p>
+      </div>
+      <div class="ready-body">
+        <span class="start-btn-spinner" />
+      </div>
+    </div>
+```
+
+Then change the existing ready block from:
+
+```vue
+    <div v-if="dialogueState === 'ready'" class="ready-stage">
+```
+
+to:
+
+```vue
+    <div v-else-if="dialogueState === 'ready'" class="ready-stage">
+```
+
+- [ ] **Step 5: Trigger history lookup after config load**
+
+Update `loadConfigFromBackend`:
+
+```ts
+async function loadConfigFromBackend(id: string) {
+  if (!id) {
+    await loadLatestStudentSession()
+    return
+  }
+  try {
+    const { config } = await getSpeakingConfig(id)
+    speakingStore.$patch({ config })
+  } catch (err) {
+    console.error('[speaking] load config failed:', err)
+  } finally {
+    await loadLatestStudentSession()
+  }
+}
+```
+
+Update the `props.configId` watcher so a new config can reset the history check:
+
+```ts
+watch(() => props.configId, (id) => {
+  historyChecked.value = false
+  loadConfigFromBackend(id)
+})
+```
+
+- [ ] **Step 6: Run frontend typecheck**
+
+Run:
+
+```bash
+pnpm vue-tsc --noEmit
+```
+
+Expected: pass. If unavailable, run:
+
+```bash
+pnpm tsc --noEmit
+```
+
+Expected: pass.
+
+- [ ] **Step 7: Commit student history flow**
+
+Run:
+
+```bash
+git add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
+git commit -m "feat: restore student speaking sessions"
+```
+
+---
+
+## Task 5: End-to-End Verification
+
+**Files:**
+- Verify backend repository: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`
+- Verify frontend repository: `/Users/buoy/Development/gitrepo/PPT`
+
+- [ ] **Step 1: Run backend tests**
+
+Run in backend repo:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py tests/service/speaking/test_dialogue_service_greeting.py tests/service/speaking/test_dialogue_service_report.py -q
+```
+
+Expected: pass.
+
+- [ ] **Step 2: Run frontend typecheck**
+
+Run in frontend repo:
+
+```bash
+pnpm vue-tsc --noEmit
+```
+
+Expected: pass. If unavailable, run:
+
+```bash
+pnpm tsc --noEmit
+```
+
+Expected: pass.
+
+- [ ] **Step 3: Manual API smoke test for latest lookup**
+
+With the backend running, create a session:
+
+```bash
+curl -s -X POST http://localhost:8000/api/speaking/dialogue/session \
+  -H 'Content-Type: application/json' \
+  -d '{"topic":"Smoke","grade":"grade5-1","totalRounds":2,"configId":"smoke-config","userId":"smoke-user"}'
+```
+
+Expected JSON includes `"sessionId"`.
+
+Then query latest:
+
+```bash
+curl -s 'http://localhost:8000/api/speaking/dialogue/sessions/latest?configId=smoke-config&userId=smoke-user'
+```
+
+Expected JSON:
+
+```json
+{
+  "session": {
+    "sessionId": "...",
+    "status": "active",
+    "totalRounds": 2,
+    "currentRound": 1,
+    "expiresAt": null,
+    "createdAt": "...",
+    "completedAt": null,
+    "messages": []
+  }
+}
+```
+
+- [ ] **Step 4: Manual frontend runtime checks**
+
+Use browser URLs that match existing app behavior:
+
+```text
+/?mode=editor3&courseid=smoke-course&userid=smoke-user
+```
+
+Expected: preview does not call `/sessions/latest`; start creates a new preview session only when clicked. Use a real course id in place of `smoke-course` when running against non-seeded data.
+
+```text
+/?mode=student&courseid=smoke-course&userid=smoke-user
+```
+
+Expected: formal student mode calls `/sessions/latest?configId=<the type 77 frame url value>&userId=smoke-user` before showing the start button. If the endpoint returns a session, the first-time start button is skipped.
+
+- [ ] **Step 5: Commit verification notes if any test fixtures changed**
+
+If verification required code or fixture changes, commit them:
+
+```bash
+git status --short
+git add tests/api/test_dialogue_greeting.py src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
+git commit -m "test: verify student speaking session history"
+```
+
+If no files changed, do not create an empty commit.
+
+---
+
+## Self-Review
+
+Spec coverage:
+
+- `mode=student` versus `mode=editor3` runtime behavior is covered in Task 4 and Task 5.
+- `config_id` persistence and migration are covered in Task 1.
+- latest lookup with message history is covered in Task 2.
+- frontend `configId + userId` create and history lookup are covered in Tasks 3 and 4.
+- active history message restoration is covered in Task 3.
+- completed history routing to report is covered in Task 4.
+- "practice again" is intentionally out of scope and documented as not implemented.
+
+Placeholder scan:
+
+- The plan contains no placeholder markers or undefined placeholder steps.
+
+Type consistency:
+
+- Backend API field names use `configId` and `userId`.
+- Database and ORM field names use `config_id` and `user_id`.
+- Frontend reads URL `userid` and maps it to backend `userId`.
+- latest route uses `/api/speaking/dialogue/sessions/latest` consistently.