2026-05-08-speaking-record-start-work-design.md 8.4 KB


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:

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:

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:

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.