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 |
|---|---|
id | Self id; FE tham chiếu là snapshotId trên mọi mutation |
ideaCards: LinearCard[] | Đồ thị card (DAG) — đổi tên từ cards |
rootCardId | Id của root QUESTION card |
totalCard, totalAnswerCard, totalDescriptionCard, totalCauseCard, totalEffectCard | Aggregates, recompute mỗi update |
fullText | Text 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 |
fullConceptualizedText | conceptualizedText các non-QUESTION card join ". " |
context | 1 đ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, verdict | Scoring per-snapshot |
status | EDITING (mặc định) → PUBLISHED (khi coaching LIVE trả về GOOD) |
originalSnapshotId | Id snapshot nguồn nếu là clone; null cho bản gốc |
createdAt, lastModifiedAt | Timestamps |
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 mangsnapshotId; 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 & path | Body | Response | Mô tả |
|---|---|---|---|
PATCH /progress/{progressId}/canvas | SaveCanvasRequestDTO (questionId, snapshotId, ideaCards, rootCardId) | AiUserExerciseProgressDTO | Ghi 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-correction | UpdateSpeechCorrectionRequestDTO (questionId, snapshotId, correctPercentage, speechCorrections, cardId?) | AiUserExerciseProgressDTO | Lưu kết quả chấm phát âm vào snapshot — 2 mức (xem dưới). |
POST /progress/{progressId}/snapshot/clone | CloneSnapshotRequestDTO (questionId, snapshotId) | LinearCanvasSnapshot | Clone 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}/snapshot | DeleteSnapshotRequestDTO (questionId, snapshotId) | — | Xoá snapshot; chỉ khi status=EDITING, ngược lại SNAPSHOT_DELETE_NOT_ALLOWED (EAID012). |
PATCH /progress/{progressId}/coaching | LinearCoachingRequestDTO (mode, questionId, snapshotId, question, selectedCardId?, ideaCards, coachVariant?) | LinearCoachingResponseDTO | Synchronous AI coaching (blocks on AITextService.sendSync). 2 mode: MENU / LIVE (xem dưới). |
PATCH /progress/{progressId}/apply-speaking-practice | ChangeLinearModeRequestDTO (questionId, currentSnapshotId) | — (200 empty) | Toggle snapshot.linearMode giữa IDEA_IMPLEMENTATION ↔ SPEAKING_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 sangANSWERED. - Theo card (có
cardId) — “đọc riêng từng card”: set scoring lên card đó trong snapshot (findCard); không đổi state câu.cardIdsai →CARD_NOT_FOUND(EAID007).
verdict suy từ correctPercentage (>= 70 → GOOD, 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)trongai_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
selectedCardId→SELECTED_CARD_REQUIRED(EAID009); card không có trongideaCards→CARD_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=ideaCardskèm per-card transientenhance:{situationType, why}. Card AI không nhắc → default KEEP.enhancekhông persist.variant(GOOD|NEED_IMPROVE) do backend tự suy từmarkedCards(mọi non-QUESTION card KEEP → GOOD), không tin fieldvariantcủa AI.
GOOD (mọi card KEEP) — persist + publish:
- Backend clone
ideaCards→conceptualizedCards, injectconceptualizedText(AI triển khai từtext, match theocardId). ThiếuconceptualizedTextcho 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+contextvào snapshot; recompute;status=PUBLISHED; setcurrentSnapshotId. - LIVE-GOOD persist canvas — FE không cần
saveCanvasriê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 trongGET /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} và 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ácSpeechVerdict).
Error codes
| Code | Nghĩa |
|---|---|
EAID007 | CARD_NOT_FOUND |
EAID009 | SELECTED_CARD_REQUIRED (MENU thiếu selectedCardId) |
EAID010 | COACHING_FAILED (AI null/parse fail, hoặc GOOD nhưng thiếu conceptualizedText) |
EAID011 | SNAPSHOT_NOT_FOUND |
EAID012 | SNAPSHOT_DELETE_NOT_ALLOWED (snapshot không ở EDITING) |
EAID013 | COACHING_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)