2026-04-27-speaking-student-session-history.md 29 KB

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:

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

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:

    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:

    user_id VARCHAR(64) NULL,
    config_id VARCHAR(36) NULL,
    topic VARCHAR(255) NOT NULL,

Add this index after UNIQUE INDEX uk_uuid (uuid):

    INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at)

The resulting tail of dialogue_session should have comma placement like:

    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:

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:

    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:

            user_id=user_id,
            config_id=config_id,
            topic=topic,
  • Step 7: Accept configId in create API

In app/api/dialogue.py, update CreateSessionRequest:

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:

    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:

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:

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:

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

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:

    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:

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

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:

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:

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:

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:

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:

export interface SessionStartInfo {
  sessionId: string
  expiresAt: string | null
  currentRound?: number
  messages?: HistoricalDialogueMessage[]
}

Update preview state:

export type PreviewDialogueState = 'checking-history' | 'ready' | 'chatting' | 'completed'

Update DialogueAPI:

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:

      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:

  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:

import type { PreviewChatMessage, DialogueAPI, DialogueReport, HistoricalDialogueMessage } from '@/types/englishSpeaking'

Add helper near the session attach section:

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:

  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:

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:

pnpm vue-tsc --noEmit

Expected: pass. If the repo does not define vue-tsc, run:

pnpm tsc --noEmit

Expected: pass.

  • Step 6: Commit frontend API/engine changes

Run:

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:

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:

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:

    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:

    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:

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

    <div v-if="dialogueState === 'ready'" class="ready-stage">

to:

    <div v-else-if="dialogueState === 'ready'" class="ready-stage">
  • Step 5: Trigger history lookup after config load

Update loadConfigFromBackend:

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:

watch(() => props.configId, (id) => {
  historyChecked.value = false
  loadConfigFromBackend(id)
})
  • Step 6: Run frontend typecheck

Run:

pnpm vue-tsc --noEmit

Expected: pass. If unavailable, run:

pnpm tsc --noEmit

Expected: pass.

  • Step 7: Commit student history flow

Run:

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:

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:

pnpm vue-tsc --noEmit

Expected: pass. If unavailable, run:

pnpm tsc --noEmit

Expected: pass.

  • Step 3: Manual API smoke test for latest lookup

With the backend running, create a session:

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:

curl -s 'http://localhost:8000/api/speaking/dialogue/sessions/latest?configId=smoke-config&userId=smoke-user'

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

/?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.

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

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.