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

docs(speaking): record-start course-work entry design

Spec for calling addCourseWorks_workPage once with content=sessionId
right after a fresh "开始对话" click, so the teacher dashboard sees the
student has begun the slide. Resume / completion / teacher mode do not
fire. Failure is fire-and-forget; idempotency is enforced purely by the
fresh-start-only call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 18 часов назад
Родитель
Сommit
8300b3b6f7
1 измененных файлов с 140 добавлено и 0 удалено
  1. 140 0
      docs/superpowers/specs/2026-05-08-speaking-record-start-work-design.md

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

@@ -0,0 +1,140 @@
+---
+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) => {
+  if (props.type !== '2') return                  // student client only
+  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. `props.type === '2'` (student client)
+  2. `startDialogue()` succeeded — `createSession()` returned and `preparedSession.value.sessionId` is set
+  3. `props.courseid` and `props.userid` are non-empty
+- **Does NOT trigger** in any of these cases:
+  - Resume path (`loadLatestStudentSession` finds an active session and jumps to `'chatting'`)
+  - Completion path (`handleDialogueComplete`)
+  - Teacher mode (`props.type !== '2'`)
+  - Any precondition missing
+- **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 !== '2'`).
+  2. No `addCourseWorks_workPage` call should fire even if "开始对话" is clicked.
+- **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.