5 Commits 187787aae8 ... 70f86bd9a4

Author SHA1 Message Date
  jimmylee 70f86bd9a4 feat(speaking): allow teacher mode to fire recordSpeakingStart too 4 months ago
  jimmylee 1ecc645de2 feat(speaking): record start work entry on fresh "开始对话" click 4 months ago
  jimmylee f933414469 feat(speaking): provide recordSpeakingStart for course-work entry 4 months ago
  jimmylee d14729cfb4 docs(speaking): record-start course-work entry impl plan 4 months ago
  jimmylee 8300b3b6f7 docs(speaking): record-start course-work entry design 4 months ago

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

+ 139 - 0
docs/superpowers/specs/2026-05-08-speaking-record-start-work-design.md

@@ -0,0 +1,139 @@
+---
+name: Speaking — record start as a course-work entry
+description: After clicking "开始对话" on the English-Speaking tool (toolType=77), call addCourseWorks_workPage once with content=sessionId so the teacher dashboard sees the student has begun this slide.
+type: design
+date: 2026-05-08
+---
+
+# Speaking — record start as a course-work entry
+
+## Goal
+
+After a student clicks **开始对话** on the English-Speaking tool (`toolType=77`), call the existing pbl-api endpoint `addCourseWorks_workPage` exactly once, with `content = sessionId`, so the teacher-side course-works query can see that the student has begun this slide.
+
+## Non-goals
+
+- Do **not** update the work record on dialogue completion. The `content` field stays as the sessionId for the lifetime of the record. The dialogue report is fetched/displayed by the existing `createSession` / `getReport` flow and is not pushed back into `addCourseWorks_workPage`.
+- Do **not** add server-side idempotency. Idempotency is enforced purely by the client call-site (only fresh start fires it).
+- Do **not** move the call into the speaking-api backend in this iteration. Considered and rejected for now: the existing pattern in this codebase (tools 15, 45, 72, 73) is "frontend calls pbl-api directly", and matching that pattern keeps consistency. A future server-side migration is left as possible tech debt.
+- Do **not** retry on failure. Retrying without server idempotency could duplicate records.
+
+## Background
+
+### Existing pieces
+
+- **`api.submitWork(params)`** — wrapper at `src/services/course.ts:45`, posts to `${API_URL}addCourseWorks_workPage`. Required fields: `{uid, cid, stage, task, tool, atool, content, type}`.
+- **`Student/index.vue:1883`** — `submitWork(slideIndex, atool, content, type)` helper for tools 72/73 already follows this pattern with `stage='0'`, `tool='0'`, `task=String(slideIndex)`.
+- **`TopicDiscussionPreview.vue:299`** — `startDialogue()` calls `api.createSession(...)` then sets `dialogueState='chatting'` (which mounts `DialogueChatView.vue`).
+- **`TopicDiscussionPreview.vue:375`** — `loadLatestStudentSession(...)` is the resume path: when an unfinished `active` session exists, the user is dropped straight into `'chatting'` without going through `startDialogue()`.
+- **`Student/index.vue:583`** — already provides `notifySpeakingProgress` (Yjs broadcast only, no HTTP).
+
+### Reference (parallel implementation)
+
+`pbl-teacher-table` calls `addCourseWorks_workPage` only on submit (Q&A type 15, MC type 45) — not on start. The "call on start" pattern is new for tool 77.
+
+### Architectural constraint
+
+`TopicDiscussionPreview` does **not** know `cid` (courseid) or `task` (slideIndex). Those live in `Student/index.vue`. So the call cannot live entirely inside `TopicDiscussionPreview` — the cleanest seam is `provide` from `Student/index.vue`, `inject` in `TopicDiscussionPreview`.
+
+## Design
+
+### Approach: dedicated provider `recordSpeakingStart(sessionId)`
+
+Three approaches were considered:
+
+- **A.** Extend the existing `notifySpeakingProgress` provider with a `fresh: boolean` flag. Rejected: overloads one provider with two responsibilities (Yjs broadcast + HTTP) and the name doesn't hint at the HTTP side-effect.
+- **B. (chosen)** Add a new dedicated provider `recordSpeakingStart(sessionId)`. The fresh-vs-resume distinction lives at the call site (only `startDialogue()` calls it; the resume path doesn't). Single responsibility, clear name.
+- **C.** Inject `cid` + `slideIndex` into `TopicDiscussionPreview` and call `api.submitWork` directly there. Rejected: more inject churn for no benefit; pushes pbl-api IO into a preview component.
+
+### File changes
+
+**1. `src/views/Student/index.vue`** — provide a new function alongside `notifySpeakingProgress`:
+
+```ts
+provide('recordSpeakingStart', async (sessionId: string) => {
+  // both teacher (type=1) and student (type=2) write a record — teacher self-tests, student submits
+  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)
+  }
+})
+```
+
+The same `provide` should also be added to `src/views/Student/index2.vue` (mirror file with the same `submitWork` helper at line 1654).
+
+**2. `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`** — inject and call from `startDialogue()` only:
+
+```ts
+const recordSpeakingStart = inject<(sessionId: string) => void>(
+  'recordSpeakingStart',
+  () => {},
+)
+```
+
+Inside `startDialogue()`, after `preparedSession.value` is set and immediately after the existing `notifySpeakingProgress('active', ...)` call (line 333) — so the Yjs broadcast fires first, then the HTTP record-start kicks off without blocking it:
+
+```ts
+recordSpeakingStart(preparedSession.value.sessionId)
+```
+
+The resume path (`loadLatestStudentSession`, around line 425) **must not** call `recordSpeakingStart`.
+
+### Payload
+
+| Field    | Value                          | Source                                     |
+|----------|--------------------------------|--------------------------------------------|
+| `uid`    | current student id             | `Student/index.vue` `props.userid`         |
+| `cid`    | course id                      | `Student/index.vue` `props.courseid`       |
+| `stage`  | `'0'`                          | fixed (existing convention)                |
+| `task`   | `String(slideIndex)`           | `slideIndex` from `slidesStore`            |
+| `tool`   | `'0'`                          | fixed (existing convention)                |
+| `atool`  | `'77'`                         | new — speaking tool                        |
+| `content`| sessionId                      | `preparedSession.sessionId`                |
+| `type`   | `'21'`                         | new — speaking tool                        |
+
+### Behavior
+
+- **Triggers** when, and only when:
+  1. `startDialogue()` succeeded — `createSession()` returned and `preparedSession.value.sessionId` is set
+  2. `props.courseid` and `props.userid` are non-empty
+  3. Either teacher (`props.type === '1'`) or student (`props.type === '2'`) — both modes fire (teacher mode is allowed for self-testing)
+- **Does NOT trigger** in any of these cases:
+  - Resume path (`loadLatestStudentSession` finds an active session and jumps to `'chatting'`)
+  - Completion path (`handleDialogueComplete`)
+  - Any precondition missing (no sessionId, no courseid, no userid)
+- **Failure handling**: fire-and-forget; failures are `console.error`-logged and never block the dialogue. The speaking session on speaking-api is already created at this point and continues normally.
+- **Idempotency**: enforced purely by the call-site rule above. The pbl-api endpoint has no client-visible idempotency, so a second call with the same payload would create a duplicate record. The fresh-start-only rule is the only guard.
+
+## Verification
+
+- **Manual happy-path (student mode)**:
+  1. Open a course slide that contains a tool-77 frame as a student (`mode=student&userid=...` query params).
+  2. Click **开始对话**, observe a successful `createSession` followed by a `POST .../addCourseWorks_workPage` in DevTools network tab with `content` = the new sessionId, `atool='77'`, `type='21'`, `task` = current slide index.
+  3. Confirm dialogue UI proceeds to `'chatting'` regardless.
+- **Manual resume path**:
+  1. Start a dialogue, do not finish, refresh the page.
+  2. The view should auto-resume into `'chatting'` (via `loadLatestStudentSession`).
+  3. **No** `addCourseWorks_workPage` call should be observed in this case.
+- **Manual teacher mode**:
+  1. Open the same slide as a teacher (`props.type === '1'`).
+  2. Clicking "开始对话" SHOULD fire `addCourseWorks_workPage` (teacher mode is allowed; useful for self-testing).
+- **Manual failure handling**:
+  1. Block `addCourseWorks_workPage` in DevTools (override response with 500).
+  2. Click **开始对话**, observe the dialogue still proceeds to `'chatting'` and `console.error` is logged.
+
+## Open questions / risks
+
+- The same `provide` change needs to be mirrored in `src/views/Student/index2.vue` (parallel file). Confirm whether `index2.vue` is reachable in the speaking flow before merging.
+- `atool='77'` and `type='21'` are agreed on with the user; if the teacher-side dashboard uses an existing filter that does not include these values, the work record may not surface. Worth a quick smoke check on the teacher view after deploy.

+ 2 - 0
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -112,6 +112,7 @@ type SpeakingNotify = (
   payload: { configId: string; sessionId: string },
 ) => void
 const notifySpeakingProgress = inject<SpeakingNotify>('notifySpeakingProgress', () => {})
+const recordSpeakingStart = inject<(sessionId: string) => void>('recordSpeakingStart', () => {})
 
 const speakingStore = useSpeakingStore()
 
@@ -334,6 +335,7 @@ async function startDialogue() {
       configId: props.configId || '',
       sessionId: preparedSession.value?.sessionId || '',
     })
+    recordSpeakingStart(preparedSession.value?.sessionId || '')
   } catch (err: unknown) {
     if (err instanceof DialogueApiError) {
       sessionError.value = `创建会话失败(${err.status}),请重试`

+ 20 - 0
src/views/Student/index.vue

@@ -592,6 +592,26 @@ provide('notifySpeakingProgress', (status: 'active' | 'completed', payload: { co
   })
 })
 
+provide('recordSpeakingStart', async (sessionId: string) => {
+  // 老师端 (type=1) 与学生端 (type=2) 都允许写作业记录,方便老师自测/学生提交
+  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)
+  }
+})
+
 // 提供给子组件使用
 provide('choiceQuestionDetailDialogOpenList', choiceQuestionDetailDialogOpenList)