Explorar o código

docs(speaking): record-start course-work entry impl plan

Implementation plan for the spec at
docs/superpowers/specs/2026-05-08-speaking-record-start-work-design.md.
Three tasks: (1) provide recordSpeakingStart in Student/index.vue,
(2) inject + call from startDialogue() only in TopicDiscussionPreview,
(3) manual verification of fresh-start / resume / teacher / 500 paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee hai 17 horas
pai
achega
d14729cfb4
Modificáronse 1 ficheiros con 262 adicións e 0 borrados
  1. 262 0
      docs/superpowers/plans/2026-05-08-speaking-record-start-work.md

+ 262 - 0
docs/superpowers/plans/2026-05-08-speaking-record-start-work.md

@@ -0,0 +1,262 @@
+# Speaking — Record Start as Course-Work Entry — 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:** When a student clicks "开始对话" on a tool-77 (English-Speaking) frame and a fresh `sessionId` is created, fire `addCourseWorks_workPage` once with `content = sessionId` so the teacher dashboard sees the student has begun this slide.
+
+**Architecture:** Add a new `recordSpeakingStart(sessionId)` provider in `Student/index.vue` (which has access to `props.userid`, `props.courseid`, and `slideIndex`). `TopicDiscussionPreview.vue` injects it and calls it from the fresh-start path inside `startDialogue()` only — never on resume, completion, or in teacher mode. Failure is fire-and-forget; idempotency is enforced purely by the call-site rule.
+
+**Tech Stack:** Vue 3 (Composition API, `<script setup>`), TypeScript, Pinia, existing `api.submitWork` wrapper at `src/services/course.ts:45`.
+
+**Spec:** `docs/superpowers/specs/2026-05-08-speaking-record-start-work-design.md`
+
+---
+
+## File Map
+
+| File | Change |
+|---|---|
+| `src/views/Student/index.vue` | Add `provide('recordSpeakingStart', ...)` next to existing `provide('notifySpeakingProgress', ...)` (around line 583). |
+| `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` | Add `inject('recordSpeakingStart', ...)` next to existing `inject('notifySpeakingProgress', ...)` (around line 114), then call it inside `startDialogue()` immediately after the existing `notifySpeakingProgress('active', ...)` (around line 333). |
+
+`src/views/Student/index2.vue` does **not** need changes — verified that it does not provide `notifySpeakingProgress` and the speaking flow does not route through it.
+
+No new files, no test files (this codebase has no unit-test infrastructure for the affected Vue components — verification is manual; see Task 3).
+
+---
+
+## Task 1: Provide `recordSpeakingStart` in `Student/index.vue`
+
+**Files:**
+- Modify: `src/views/Student/index.vue` (insert a new `provide(...)` block immediately after the existing `notifySpeakingProgress` block at lines 583-593)
+
+**Context for the engineer:**
+- `api` is the default import from `@/services/course` (already imported at line 422). It exposes `api.submitWork({uid, cid, stage, task, tool, atool, content, type})` which POSTs to `addCourseWorks_workPage`.
+- `slideIndex` is from `storeToRefs(slidesStore)` (already destructured at line 485). Use `slideIndex.value` to read the current value.
+- `props.userid`, `props.courseid`, and `props.type` exist on this component. `props.type === '2'` means student client (`type === '1'` is teacher).
+- The existing pattern at `Student/index.vue:1883` (`submitWork(slideIndex, atool, content, type)`) shows the convention: `stage='0'`, `tool='0'`, `task=String(slideIndex)`. Follow it exactly.
+
+- [ ] **Step 1: Insert the new provider**
+
+Find the block at `src/views/Student/index.vue:583-593`:
+
+```ts
+provide('notifySpeakingProgress', (status: 'active' | 'completed', payload: { configId: string; sessionId: string }) => {
+  if (props.type !== '2') return // 只有学生客户端发广播
+  sendMessage({
+    type: 'speaking_session_updated',
+    courseid: props.courseid,
+    slideIndex: slideIndex.value,
+    userid: props.userid,
+    status,
+    ...payload,
+  })
+})
+```
+
+Insert this **immediately after** that block (still at the top level of `<script setup>`, same indentation):
+
+```ts
+provide('recordSpeakingStart', async (sessionId: string) => {
+  if (props.type !== '2') return // 只有学生客户端写作业记录
+  if (!sessionId || !props.courseid || !props.userid) return
+  try {
+    await api.submitWork({
+      uid: props.userid as string,
+      cid: props.courseid as string,
+      stage: '0',
+      task: String(slideIndex.value),
+      tool: '0',
+      atool: '77',
+      content: sessionId,
+      type: '21',
+    })
+  }
+  catch (err) {
+    console.error('[speaking] recordSpeakingStart failed:', err)
+  }
+})
+```
+
+- [ ] **Step 2: Type-check**
+
+Run: `cd /Users/buoy/Development/gitrepo/PPT && npm run type-check`
+Expected: PASS (no new TS errors). If the codebase already has pre-existing errors, confirm none of them are inside `Student/index.vue` and none reference `recordSpeakingStart` or `submitWork`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git -C /Users/buoy/Development/gitrepo/PPT add src/views/Student/index.vue
+git -C /Users/buoy/Development/gitrepo/PPT commit -m "$(cat <<'EOF'
+feat(speaking): provide recordSpeakingStart for course-work entry
+
+Adds a provider that posts addCourseWorks_workPage once with
+content=sessionId, atool='77', type='21' for the student client only.
+Failure is logged and swallowed; the dialogue is not blocked.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
+EOF
+)"
+```
+
+---
+
+## Task 2: Inject and call from `startDialogue()` in `TopicDiscussionPreview.vue`
+
+**Files:**
+- Modify: `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` (add inject around line 114; add call inside `startDialogue()` around line 333)
+
+**Context for the engineer:**
+- This file already has `const notifySpeakingProgress = inject<SpeakingNotify>('notifySpeakingProgress', () => {})` at line 114 — model the new inject after it.
+- The fresh-start success path is `startDialogue()` at line 299. After `createSession` returns, `preparedSession.value` is set with `{ sessionId, expiresAt, currentRound }` (line 327-331), then `dialogueState.value = 'chatting'` (line 332), then `notifySpeakingProgress('active', { configId, sessionId })` (line 333-336).
+- The resume path is `loadLatestStudentSession()` at line 375. It also fires `notifySpeakingProgress('active', ...)` at line 426. **DO NOT** call `recordSpeakingStart` from this path — that would write a duplicate work record on every page refresh.
+- The completion path `handleDialogueComplete` at line 442 fires `notifySpeakingProgress('completed', ...)`. **DO NOT** call `recordSpeakingStart` from this path either.
+
+- [ ] **Step 1: Add the inject**
+
+Find the existing line at `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue:114`:
+
+```ts
+const notifySpeakingProgress = inject<SpeakingNotify>('notifySpeakingProgress', () => {})
+```
+
+Insert **immediately after** it:
+
+```ts
+const recordSpeakingStart = inject<(sessionId: string) => void>('recordSpeakingStart', () => {})
+```
+
+- [ ] **Step 2: Add the call inside `startDialogue()` only**
+
+Find the block at `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue:332-336`:
+
+```ts
+    dialogueState.value = 'chatting'
+    notifySpeakingProgress('active', {
+      configId: props.configId || '',
+      sessionId: preparedSession.value?.sessionId || '',
+    })
+```
+
+Add a new line **immediately after** the closing `})` of `notifySpeakingProgress`, still inside the `try` block, still at the same indentation as `notifySpeakingProgress`:
+
+```ts
+    recordSpeakingStart(preparedSession.value?.sessionId || '')
+```
+
+After the edit, that block should read:
+
+```ts
+    dialogueState.value = 'chatting'
+    notifySpeakingProgress('active', {
+      configId: props.configId || '',
+      sessionId: preparedSession.value?.sessionId || '',
+    })
+    recordSpeakingStart(preparedSession.value?.sessionId || '')
+```
+
+- [ ] **Step 3: Verify resume and completion paths were NOT touched**
+
+Run: `grep -n "recordSpeakingStart" /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`
+Expected output: exactly **2** lines — one `inject(...)` line near line 115, one `recordSpeakingStart(...)` call inside `startDialogue` near line 337. **Not** 3 lines, **not** any line near 426 (resume) or 452 (completion).
+
+If grep shows more than 2 lines, remove the extras.
+
+- [ ] **Step 4: Type-check**
+
+Run: `cd /Users/buoy/Development/gitrepo/PPT && npm run type-check`
+Expected: PASS (no new TS errors).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git -C /Users/buoy/Development/gitrepo/PPT add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
+git -C /Users/buoy/Development/gitrepo/PPT commit -m "$(cat <<'EOF'
+feat(speaking): record start work entry on fresh "开始对话" click
+
+Injects recordSpeakingStart and fires it once inside startDialogue(),
+right after the existing notifySpeakingProgress('active', ...) broadcast.
+The resume path (loadLatestStudentSession) and completion path
+(handleDialogueComplete) deliberately do NOT call it — idempotency is
+enforced purely at the call site.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
+EOF
+)"
+```
+
+---
+
+## Task 3: Manual verification
+
+**Context for the engineer:**
+- This codebase has no unit tests for the affected Vue components. The four scenarios below are the verification gate per the spec.
+- You need student-mode access to a course slide containing a tool-77 (English-Speaking) frame. The student-mode URL has `?mode=student&userid=<id>` query params, and the parent `Student/index.vue` must have been loaded with `props.courseid` and `props.userid` set. If you don't have such a course set up, ask the user for a test URL before proceeding.
+
+- [ ] **Step 1: Start the dev server**
+
+Run: `cd /Users/buoy/Development/gitrepo/PPT && npm run dev`
+Expected: Vite dev server starts, prints a local URL.
+
+- [ ] **Step 2: Happy path — fresh start fires the API**
+
+1. Open the student-mode URL for a course slide that contains a tool-77 frame.
+2. Open DevTools → Network. Filter on `addCourseWorks_workPage`.
+3. Click **开始对话**.
+4. Observe a `POST .../addCourseWorks_workPage` request. Inspect the request body — the single-element array should contain:
+   - `uid` = the student id (matches `userid` query param)
+   - `cid` = the course id
+   - `stage` = `"0"`
+   - `task` = the current slide index as a string
+   - `tool` = `"0"`
+   - `atool` = `"77"`
+   - `content` = the same `sessionId` returned by the immediately preceding `createSession` call
+   - `type` = `"21"`
+5. Confirm the dialogue UI advances to `'chatting'` (the chat view appears).
+
+Expected: all of the above. If any field mismatches, fix the implementation before continuing.
+
+- [ ] **Step 3: Resume path — refresh does NOT fire the API**
+
+1. With the dialogue still in `'chatting'` (do not finish), refresh the page.
+2. The view should auto-resume into `'chatting'` (via `loadLatestStudentSession` finding an active session).
+3. **No** `POST .../addCourseWorks_workPage` request should appear in the Network tab during this resume.
+
+Expected: zero new `addCourseWorks_workPage` calls. If one fires, the call accidentally landed in the resume path — review Task 2 Step 3.
+
+- [ ] **Step 4: Teacher mode — clicking "开始对话" does NOT fire the API**
+
+1. Open the same slide as a teacher (no `mode=student` query param, or `mode=teacher` — whatever sets `props.type === '1'` on `Student/index.vue`).
+2. If "开始对话" is reachable in this mode, click it. Otherwise, confirm the button is not reachable, which is also a valid pass.
+3. **No** `POST .../addCourseWorks_workPage` request should appear.
+
+Expected: zero `addCourseWorks_workPage` calls.
+
+- [ ] **Step 5: Failure path — API 500 does NOT block the dialogue**
+
+1. In DevTools Network tab, right-click `addCourseWorks_workPage` → Block request URL (or use Override > "Failure response").
+2. Refresh, click **开始对话** again on a slide that does NOT have an existing active session (so the fresh-start path runs).
+3. Observe: the request fails (red in network tab), `console.error` shows `[speaking] recordSpeakingStart failed: ...`, and the dialogue still proceeds to `'chatting'` normally.
+
+Expected: dialogue UI is uninterrupted; only a console error.
+
+- [ ] **Step 6: If any check fails, stop and report**
+
+If any of Steps 2-5 fails, do not commit further. Report which step failed, the actual observed behavior, and the diff between expected and actual. The user will decide whether to adjust the spec or fix the implementation.
+
+- [ ] **Step 7: Commit nothing (verification only)**
+
+This task produces no code changes. If all checks passed, mark the plan complete and report back to the user.
+
+---
+
+## Self-Review Notes
+
+- Spec coverage: each requirement from `docs/superpowers/specs/2026-05-08-speaking-record-start-work-design.md` is covered:
+  - Provider in `Student/index.vue` → Task 1
+  - Inject + fresh-start-only call in `TopicDiscussionPreview` → Task 2
+  - All four behavior gates (student-only, courseid+userid required, fire-and-forget, no resume/completion call) → encoded in Task 1 Step 1 (the function body) and Task 2 Step 3 (grep guard)
+  - Manual verification of all 4 scenarios from the spec → Task 3 Steps 2-5
+  - `index2.vue` open question → resolved during planning (no change needed)
+- Placeholder scan: no TBD/TODO/vague items.
+- Type consistency: `recordSpeakingStart` signature is `(sessionId: string) => void` in both the provide (returns `Promise<void>`, callers use `void` because they don't await) and the inject. The mismatch (provide returns `Promise`, inject types as `void`) is intentional — the call site fire-and-forgets.