DraftDT-4662Exercise AISRDAPIRESTLinearToolCanvasSnapshotCoaching

Do-Test API — Linear Tool

Áp dụng cho LINEAR_TOOL. Học viên dựng canvas (sơ đồ ý phân nhánh — DAG), luyện nói, và được AI coaching; chấm qua speech-correction.

Quy ước chung + Common API: xem Tổng quan. Luật canvas / DAG / flatten / tổng kết submit (deep-dive): xem Linear Tool.

Snapshot model (đổi từ single-canvas)

Một LinearQuestionProgress không còn giữ 1 canvas duy nhất mà giữ:

  • snapshots: LinearCanvasSnapshot[] — các bản canvas có version.
  • currentSnapshotId — trỏ bản đang active (bản được PUBLISHED gần nhất).

Canvas đã flatten thẳng vào snapshot (bỏ lớp canvas). Mỗi LinearCanvasSnapshot:

FieldÝ nghĩa
idSelf id; FE tham chiếu là snapshotId trên mọi mutation
ideaCards: LinearCard[]Đồ thị card (DAG) — đổi tên từ cards
rootCardIdId của root QUESTION card
totalCard, totalAnswerCard, totalDescriptionCard, totalCauseCard, totalEffectCardAggregates, recompute mỗi update
fullTextText các non-QUESTION card join ". "
conceptualizedCards: LinearCard[]Bản AI triển khai (mỗi card có conceptualizedText) — chỉ set khi coaching LIVE = GOOD
fullConceptualizedTextconceptualizedText các non-QUESTION card join ". "
context1 đoạn bối cảnh tổng thể bài nói (AI tổng hợp từ conceptualizedCards) — set khi LIVE = GOOD
correctPercentage, speechCorrections, verdictScoring per-snapshot
statusEDITING (mặc định) → PUBLISHED (khi coaching LIVE trả về GOOD)
originalSnapshotIdId snapshot nguồn nếu là clone; null cho bản gốc
createdAt, lastModifiedAtTimestamps

Khi start, mỗi câu tạo 1 snapshot gốc EDITING (originalSnapshotId=null) chứa một root QUESTION card mang text câu hỏi gốc. Mọi mutation mang snapshotId; id sai → SNAPSHOT_NOT_FOUND (EAID011).

LinearQuestionProgressDTO còn có 2 field read-only (không lưu trên progress doc): totalDoCoachingMenu, totalDoCoachingLive — xem Coaching tally.

Endpoint riêng

Method & pathBodyResponseMô tả
PATCH /progress/{progressId}/canvasSaveCanvasRequestDTO (questionId, snapshotId, ideaCards, rootCardId)AiUserExerciseProgressDTOGhi canvas vào snapshot chỉ định (payload phẳng, không còn wrapper canvas); recompute aggregates. Không đổi state câu.
PATCH /progress/{progressId}/speech-correctionUpdateSpeechCorrectionRequestDTO (questionId, snapshotId, correctPercentage, speechCorrections, cardId?)AiUserExerciseProgressDTOLưu kết quả chấm phát âm vào snapshot — 2 mức (xem dưới).
POST /progress/{progressId}/snapshot/cloneCloneSnapshotRequestDTO (questionId, snapshotId)LinearCanvasSnapshotClone 1 snapshot → bản mới EDITING (copy content, reset scoring, originalSnapshotId=nguồn); không đổi currentSnapshotId. Nếu source snapshot linearMode=SPEAKING_PRACTICE, clone luôn set lại IDEA_IMPLEMENTATION.
DELETE /progress/{progressId}/snapshotDeleteSnapshotRequestDTO (questionId, snapshotId)Xoá snapshot; chỉ khi status=EDITING, ngược lại SNAPSHOT_DELETE_NOT_ALLOWED (EAID012).
PATCH /progress/{progressId}/coachingLinearCoachingRequestDTO (mode, questionId, snapshotId, question, selectedCardId?, ideaCards, coachVariant?)LinearCoachingResponseDTOSynchronous AI coaching (blocks on AITextService.sendSync). 2 mode: MENU / LIVE (xem dưới).
PATCH /progress/{progressId}/apply-speaking-practiceChangeLinearModeRequestDTO (questionId, currentSnapshotId)— (200 empty)Toggle snapshot.linearMode giữa IDEA_IMPLEMENTATIONSPEAKING_PRACTICE. Extends BaseCommonProgressRequestDTO → có spendTimeInSeconds.

Chấm phát âm — 2 mức (phân theo cardId)

  • Cả câu (không cardId) — “Luyện nói cả bài”: set scoring lên snapshot và chuyển câu sang ANSWERED.
  • Theo card (có cardId) — “đọc riêng từng card”: set scoring lên card đó trong snapshot (findCard); không đổi state câu. cardId sai → CARD_NOT_FOUND (EAID007).

verdict suy từ correctPercentage (>= 70GOOD, ngược lại NEED_IMPROVE).

Coaching — MENU

Gợi ý card mới nhánh ra từ selectedCardId.

  • Response: { mode:"MENU", suggestions:[{type, text, why}] } (tối đa 3, mỗi type DESCRIPTION/CAUSE/EFFECT).
  • Cache theo (progressId, questionId, cardId, cardText, coachVariant) trong ai_exercise_coaching_menu_cache: cùng card + cùng text → cache hit (không gọi AI); đổi text → 1 call mới (xoá row cũ).
  • Thiếu selectedCardIdSELECTED_CARD_REQUIRED (EAID009); card không có trong ideaCardsCARD_NOT_FOUND (EAID007).
  • Không persist canvas.

Coaching — LIVE

Chấm lại toàn bộ card + (khi đạt) triển khai bài nói. FE gửi full graph trong ideaCards.

Response: { mode:"LIVE", variant, markedCards, conceptualizedCards, context }.

  • markedCards = ideaCards kèm per-card transient enhance:{situationType, why}. Card AI không nhắc → default KEEP. enhance không persist.
  • variant (GOOD|NEED_IMPROVE) do backend tự suy từ markedCards (mọi non-QUESTION card KEEP → GOOD), không tin field variant của AI.

GOOD (mọi card KEEP) — persist + publish:

  • Backend clone ideaCardsconceptualizedCards, inject conceptualizedText (AI triển khai từ text, match theo cardId). Thiếu conceptualizedText cho non-QUESTION card → COACHING_FAILED (EAID010).
  • context = 1 đoạn bối cảnh tổng thể (AI tổng hợp từ conceptualizedCards, viết bằng teacher language).
  • Đè ideaCards + conceptualizedCards + context vào snapshot; recompute; status=PUBLISHED; set currentSnapshotId.
  • LIVE-GOOD persist canvas — FE không cần saveCanvas riêng trước.

NEED_IMPROVE (≥1 card không KEEP) — không persist gì: chỉ trả markedCards + variant, conceptualizedCards=[], context=null; snapshot giữ EDITING, currentSnapshotId không đổi.

AI null / parse fail → COACHING_FAILED (EAID010).

Coaching quota (LIVE-GOOD limit)

Mỗi question giới hạn 10 lần coaching LIVE-GOOD (persisted snapshot). Khi GOOD count >= COACHING_LIVE_GOOD_LIMIT (10), endpoint coaching sẽ reject với error COACHING_GOOD_LIMIT_REACHED (EAID013) trước khi gọi AI.

Field remainingDoCoachingLive (max(0, 10 - goodCount)) được populate trên:

  • LinearQuestionProgressDTO (mỗi question trong GET /progress/{progressId})
  • LinearCoachingResponseDTO (trả sau mọi coaching call — MENU hoặc LIVE — giá trị after-increment nếu thành công)

Coaching tally

Mỗi call thành công trọn vẹn bump 1 counter atomic ($inc) trong collection dùng chung base_tracking_seq (TrackingSequenceGenerator.getNextSequenceByIdAndFutureName):

  • MENU: _id = progressId:questionId:AI_EXERCISE_COACHING_MENU, tính cả cache hit.
  • LIVE-GOOD: _id = progressId:questionId:AI_EXERCISE_COACHING_LIVE_WITH_GOOD, bump sau khi persist snapshot thành công.
  • LIVE-NEED_IMPROVE: _id = progressId:questionId:AI_EXERCISE_COACHING_LIVE_WITH_NEED_IMPROVE, bump sau khi trả variant NEED_IMPROVE.
  • Call fail (throw trước bump) không tính.

Đọc lại qua CoachingCountReader → merge vào totalDoCoachingMenu / totalDoCoachingLive trên LinearQuestionProgressDTO (trong GET /{progressId}DoTestStateDTO). totalDoCoachingLive = số lần GOOD (variant GOOD).

Enums liên quan

  • LinearCardType: QUESTION, ANSWER, DESCRIPTION, CAUSE, EFFECT.
  • LinearCardState: EMPTY, FILLED, ERROR.
  • SpeechVerdict: GOOD (≥70%), NEED_IMPROVE — verdict chấm phát âm.
  • DocumentStatus (snapshot): EDITING, PUBLISHED.
  • CoachingMode: MENU, LIVE.
  • CoachBandVariant: BAND6 (hardcode; target band).
  • CoachingSituationType: KEEP, DIGRESS, TOO_SHORT, VAGUE, SHOULD_BE_CAUSE, SHOULD_BE_DESCRIPTION, EFFECT_NO_RESULT.
  • CoachingVariant: GOOD, NEED_IMPROVE — verdict tổng của coaching LIVE (khác SpeechVerdict).

Error codes

CodeNghĩa
EAID007CARD_NOT_FOUND
EAID009SELECTED_CARD_REQUIRED (MENU thiếu selectedCardId)
EAID010COACHING_FAILED (AI null/parse fail, hoặc GOOD nhưng thiếu conceptualizedText)
EAID011SNAPSHOT_NOT_FOUND
EAID012SNAPSHOT_DELETE_NOT_ALLOWED (snapshot không ở EDITING)
EAID013COACHING_GOOD_LIMIT_REACHED (LIVE-GOOD count >= 10)

Ví dụ

PATCH /{progressId}/canvas

// Request — SaveCanvasRequestDTO (payload phẳng, mang snapshotId)
{
  "questionId": "665fae11b2c3d4e5f6071900",
  "snapshotId": "6700b1c2d3e4f5061728394a",
  "rootCardId": "card-root-1",
  "ideaCards": [
    { "cardId": "card-root-1", "type": "QUESTION", "text": "Why do you like your city?",
      "links": ["card-ans-1"], "order": 0, "state": "FILLED", "root": true, "col": 0, "row": 0 },
    { "cardId": "card-ans-1", "type": "ANSWER", "text": "Because it is peaceful and green.",
      "links": [], "order": 1, "state": "FILLED", "root": false, "col": 1, "row": 0 }
  ]
}

PATCH /{progressId}/coaching — LIVE = GOOD

// Request — LinearCoachingRequestDTO
{
  "mode": "LIVE",
  "questionId": "665fae11b2c3d4e5f6071900",
  "snapshotId": "6700b1c2d3e4f5061728394a",
  "question": "Why do you like your city?",
  "ideaCards": [ /* full graph */ ]
}
// Response 200 — mọi card KEEP → GOOD; snapshot đã PUBLISHED
{
  "mode": "LIVE",
  "variant": "GOOD",
  "markedCards": [
    { "cardId": "card-ans-1", "type": "ANSWER", "text": "...", "enhance": { "situationType": "KEEP", "why": "" } }
  ],
  "conceptualizedCards": [
    { "cardId": "card-ans-1", "type": "ANSWER", "text": "Because it is peaceful and green.",
      "conceptualizedText": "One reason I love my city is that it feels genuinely peaceful and green..." }
  ],
  "context": "Bài nói về việc người dân thành phố đi xem phim vào cuối tuần để giải tỏa căng thẳng và tận hưởng không gian hiện đại của rạp chiếu phim."
}

PATCH /{progressId}/coaching — LIVE = NEED_IMPROVE

// Response 200 — có card chưa đạt → không persist gì
{
  "mode": "LIVE",
  "variant": "NEED_IMPROVE",
  "markedCards": [
    { "cardId": "card-ans-1", "enhance": { "situationType": "KEEP" } },
    { "cardId": "card-desc-2", "enhance": { "situationType": "TOO_SHORT", "why": "Cần thêm chi tiết để dựng câu tự nhiên." } }
  ],
  "conceptualizedCards": [],
  "context": null
}

PATCH /{progressId}/coaching — MENU

// Request
{ "mode": "MENU", "questionId": "665f...", "snapshotId": "6700...", "selectedCardId": "card-ans-1", "ideaCards": [ /* graph */ ] }
// Response 200
{
  "mode": "MENU",
  "suggestions": [
    { "type": "DESCRIPTION", "text": "The parks are full of old trees.", "why": "Thêm chi tiết mô tả để câu giàu hình ảnh hơn?" },
    { "type": "CAUSE", "text": "Rapid planning kept green space.", "why": "Vì sao thành phố vẫn xanh?" },
    { "type": "EFFECT", "text": "So residents feel less stressed.", "why": "Kết quả của không gian xanh là gì?" }
  ]
}

Lỗi

{ "errorCode": "EAID011", "message": "6700-unknown-snapshot" }   // SNAPSHOT_NOT_FOUND
{ "errorCode": "EAID010", "message": "card-ans-1" }              // COACHING_FAILED (GOOD nhưng thiếu conceptualizedText)