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