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