# ĐẶC TẢ KỸ THUẬT: LUỒNG CHẤM LẠI & PHÚC KHẢO WRITING (SKILL 15) - MODAL V3

> **Phiên bản:** 3.0  
> **Áp dụng cho:** Kỹ năng Writing (`skill_id = 15`) trên hệ thống EMS / LMS Testsite  
> **Mã nguồn liên quan:**  
> - Frontend Controller/Routing: [exam-detail-modal-v3.js](file:///var/www/html/hocmai_testsite/public/js/exam-history/exam-detail-modal-v3.js)  
> - Form Chấm lại & Xử lý Điểm: [exam-detail-modal.edit-v3.js](file:///var/www/html/hocmai_testsite/public/js/exam-history/exam-detail-modal.edit-v3.js)  
> - Giao diện Xem chi tiết (Read-only): [exam-detail-modal.detail-v3.js](file:///var/www/html/hocmai_testsite/public/js/exam-history/exam-detail-modal.detail-v3.js)  

---

## MỤC LỤC
1. [Tổng quan Kiến trúc](#1-tổng-quan-kiến-trúc)
2. [Cấu trúc Dữ liệu Đầu vào & Chuẩn hóa AI](#2-cấu-trúc-dữ-liệu-đầu-vào--chuẩn-hóa-ai)
3. [Cấu trúc State Nháp (`pending.taskEdits`) & Cơ chế Key](#3-cấu-trúc-state-nháp-pendingtaskedits--cơ-chế-key)
4. [Vòng đời Tương tác Tiêu chí (Criterion Lifecycle)](#4-vòng-đời-tương-tác-tiêu-chí-criterion-lifecycle)
5. [Động cơ Tính điểm (`calculateWritingOverallScore`)](#5-động-cơ-tính-điểm-calculatewritingoverallscore)
6. [Cơ chế Báo cáo Gian lận (Cheating Reports)](#6-cơ-chế-báo-cáo-gian-lận-cheating-reports)
7. [Quy trình Đóng gói Dữ liệu Khi Lưu (`processWritingSaveData`)](#7-quy-trình-đóng-gói-dữ-liệu-khi-lưu-processwritingsavedata)
8. [Các Cạm bẫy Kỹ thuật (Gotchas & Best Practices)](#8-các-cạm-bẫy-kỹ-thuật-gotchas--best-practices)

---

## 1. TỔNG QUAN KIẾN TRÚC

Hệ thống chấm lại kỹ năng Writing hoạt động theo cơ chế **State Nháp Cách Ly (Drafting State)**:
- Toàn bộ thao tác sửa điểm, sửa nhận xét, xác nhận tiêu chí và lý do sửa được giữ trong bộ nhớ RAM (`window.currentExamState.skillsMap[15].pendingEdits`).
- Dữ liệu gốc (`historyGoc`) trong suốt quá trình chấm **hoàn toàn không bị sửa đổi trực tiếp**.
- Chỉ khi giáo viên/chấm thi bấm **"Lưu kết quả chấm"**, hệ thống mới thực hiện **Deep Clone** dữ liệu gốc, giải nén state nháp và vá ngược trở lại để gửi lên API.

```mermaid
flowchart TD
    API([API / Database]) -->|Load JSON bài thi| InitState[Khởi tạo window.currentExamState]
    InitState --> ParseAI[parseExternalGradingDetail: Chuẩn hóa AI Format Mới/Cũ]
    ParseAI --> RenderUI[Render Giao diện Writing Panel]
    
    subgraph RAM_STATE [Vùng nhớ Nháp pendingEdits]
        taskEdits[taskEdits: Lưu điểm & nhận xét nháp từng tiêu chí]
        cheating[cheating_reports: Nghi vấn gian lận từng Task]
        overallScore[pending.score: Điểm tổng bài thi]
    end

    RenderUI -->|Tương tác: Sửa / Xác nhận / Đặt lại| RAM_STATE
    RAM_STATE -->|Tính toán lại| CalcOverall[calculateWritingOverallScore]
    CalcOverall -->|Cập nhật điểm| overallScore
    
    subgraph SAVE_FLOW [Quy trình Lưu Bài processWritingSaveData]
        SaveBtn[Bấm Lưu Bài Thi] --> Clone[Deep Clone historyGoc]
        Clone --> Patch[Vá taskEdits vào Criteria / Part]
        Patch --> RecalcTask[Cập nhật mảng clonedHistory.writing]
        RecalcTask --> SetLMS[Gán clonedHistory.lms_score]
        SetLMS --> CleanCheating[Tách cheating_reports sang FormData & rescored_comment]
    end

    RAM_STATE --> SaveBtn
    CleanCheating --> SendAPI([Gửi POST /api/save-rescore lên Server])
```

---

## 2. CẤU TRÚC DỮ LIỆU ĐẦU VÀO & CHUẨN HÓA AI

Dữ liệu chấm AI nằm trong `graded.externalGradingDetail.content`. Hệ thống hỗ trợ song song 2 định dạng dữ liệu thông qua hàm `parseExternalGradingDetail(graded)`:

### 2.1. Format Mới (Dạng `Criteria`)
- **Đặc điểm nhận diện:** `content[0].data.criteria.data` là một mảng các tiêu chí.
- **Cấu trúc tiêu chí:** Mỗi criterion có `code` (`'TA'`, `'TR'`, `'CC'`, `'LR'`, `'GRA'`), `data.score`, `data.comment`, `data.evaluation_status`.
- **Quy tắc tính điểm (`isEditableScore`):** **Luôn bằng `true`** cho tất cả criteria. Tất cả các tiêu chí trong mảng này đều tham gia vào phép tính trung bình của Task.

### 2.2. Format Cũ (Dạng `Parts 1..6`)
- **Đặc điểm nhận diện:** `content` là danh sách các part có `part_number` từ 1 đến 6.
- **Cấu trúc parts:**
  - `part_number = 1`: **Vocabulary & Grammar** (Nhận xét lỗi ngữ pháp/từ vựng chung, không có điểm số riêng).
  - `part_number = 2, 3, 4, 5`: **4 Tiêu chí IELTS chính thức** (Task Response, Coherence & Cohesion, Lexical Resource, Grammatical Range). Có điểm `data.score`.
  - `part_number = 6`: **Improve Essay** (Đoạn văn viết lại mẫu, không có điểm số).
- **Quy tắc tính điểm (`isEditableScore`):**
  ```javascript
  isEditableScore: pNum !== 1 && pNum !== 6
  ```
  Chỉ `part 2, 3, 4, 5` mới được tính điểm trung bình Task; `part 1` và `6` bị bỏ qua.

### 2.3. Output Chuẩn hóa của `parseExternalGradingDetail`
```javascript
{
    isFormatNew: boolean,      // true nếu là format Criteria, false nếu là Parts
    totalScore: number|null,   // Điểm tổng AI ban đầu
    items: [
        {
            id: 'TR',          // Format mới: code ('TR'). Format cũ: số part (2)
            code: 'TR',
            name: 'Task Response',
            score: 6.5,
            comment: 'Bài viết trả lời đúng trọng tâm...',
            isEditableScore: true,  // Cờ quyết định có tham gia tính điểm hay không
            evaluation_status: 'pending' | 'confirmed' | 'edited',
            original_score: 6.5,
            original_feedback: 'Bài viết trả lời đúng trọng tâm...',
            edit_reason: ''
        }
    ]
}
```

---

## 3. CẤU TRÚC STATE NHÁP (`pending.taskEdits`) & CƠ CHẾ KEY

### 3.1. Cấu trúc Key `${qId}-${item.id}`
Mỗi mục chấm được định danh duy nhất trên toàn bài thi bằng một khóa chuỗi:
$$\text{key} = \text{qId} - \text{item.id}$$

- **`qId`** (`q.idQuestion`): ID câu hỏi trong đề thi (ví dụ: Task 1 có `idQuestion = 193192`, Task 2 có `idQuestion = 193193`).
- **`item.id`**:
  - Với Format Mới: Là chuỗi `code` tiêu chí (ví dụ: `TA`, `CC`, `LR`, `GRA`).
    $\rightarrow$ Key: `"193192-TA"`, `"193192-CC"`.
  - Với Format Cũ: Là số thứ tự part (ví dụ: `2`, `3`, `4`, `5`).
    $\rightarrow$ Key: `"193192-2"`, `"193192-3"`.

### 3.2. Cấu trúc Object trong `taskEdits[key]`
```javascript
pending.taskEdits[key] = {
    score: 7.0,                             // Điểm số hiện tại (nháp hoặc đã chốt)
    comment: "Từ vựng phong phú hơn...",      // Nhận xét hiện tại
    evaluation_status: "edited",            // Trạng thái: 'pending' | 'confirmed' | 'edited'
    is_editing: false,                      // Đang mở ô input nhập liệu hay không
    edit_reason: "Chấm lại theo rubric mới",// Lý do sửa (bắt buộc khi status = 'edited')
    _backup_score: 6.5,                     // (Tạm thời) Sao lưu trước khi mở chế độ Sửa
    _backup_comment: "...",                 // (Tạm thời) Sao lưu trước khi mở chế độ Sửa
    original_score: 6.5,                    // Điểm gốc ban đầu
    original_feedback: "..."                // Nhận xét gốc ban đầu
};
```

### 3.3. Các vị trí ghi vào `taskEdits[key]`
Trong mã nguồn, `taskEdits[key]` được tạo hoặc biến đổi dữ liệu tại 5 điểm sự kiện:

| STT | Vị trí sự kiện | Tác vụ | Trạng thái `evaluation_status` |
|:---|:---|:---|:---|
| 1 | Click `.btn-confirm-criterion` | Lưu điểm gốc `rawScore` & nhận xét `rawComment`, gọi `calculateWritingOverallScore` | `confirmed` |
| 2 | Click `.btn-edit-criterion` | Tạo backup `_backup_score`, `_backup_comment`, mở ô nhập | Giữ nguyên, `is_editing = true` |
| 3 | Input gõ phím (`.part-score-input`, `.part-comment-input`) | Cập nhật giá trị gõ tức thời vào nháp | Giữ nguyên |
| 4 | Click `.btn-save-edit-criterion` | Validate điểm & lý do sửa, lưu điểm mới, xóa backup, tính lại overall | `edited` |
| 5 | Click `.btn-reset-criterion` | Khôi phục về `origScore` và `origFeedback` ban đầu | `pending` |

---

## 4. VÒNG ĐỜI TƯƠNG TÁC TIÊU CHÍ (CRITERION LIFECYCLE)

```mermaid
stateDiagram-v2
    [*] --> Pending: Mở Modal lần đầu (Chưa duyệt)
    
    Pending --> Confirmed: Bấm "Xác nhận đúng"\n(Lưu rawScore vào taskEdits, cập nhật Overall)
    Pending --> Editing: Bấm "Sửa"
    
    Confirmed --> Editing: Bấm "Sửa"
    
    Editing --> Pending: Bấm "Hủy" (nếu trước đó là Pending)\nKhôi phục _backup
    Editing --> Confirmed: Bấm "Hủy" (nếu trước đó là Confirmed)\nKhôi phục _backup
    Editing --> Edited: Bấm "Lưu"\n(Nhập điểm mới + Lý do sửa)
    
    Edited --> Editing: Bấm "Sửa lại"
    Edited --> Pending: Bấm "Đặt lại"\n(Xóa thay đổi, về điểm gốc)
    Confirmed --> Pending: Bấm "Đặt lại"
```

### Chi tiết hành động "Xác nhận đúng" (`.btn-confirm-criterion`)
Trước đây, nút này không lưu điểm vào `taskEdits`, dẫn đến khi lưu bài hoặc tính lại điểm bị rơi vào `0` hoặc `NaN`. Hiện tại cơ chế chuẩn như sau:
1. Đọc thuộc tính `data-score` và `data-comment` gắn trên nút.
2. Gán vào `pending.taskEdits[key].score = scoreVal`.
3. Gán `pending.taskEdits[key].evaluation_status = 'confirmed'`.
4. Gọi ngay `calculateWritingOverallScore(skillId)` để làm mới thanh điểm tổng.
5. Render lại panel Writing.

---

## 5. ĐỘNG CƠ TÍNH ĐIỂM (`calculateWritingOverallScore`)

Hàm [calculateWritingOverallScore](file:///var/www/html/hocmai_testsite/public/js/exam-history/exam-detail-modal.edit-v3.js#L1750) đảm nhận toàn bộ việc tính toán điểm số từ tiêu chí cấp thấp nhất lên đến điểm tổng bài thi:

```mermaid
flowchart LR
    Criteria[Từng tiêu chí scoringItems] --> TaskAvg[Tính trung bình từng Task]
    TaskAvg --> OverallCalc[Tính Điểm Tổng Writing theo Contest Type]
    OverallCalc --> UIUpdate[Cập nhật UI & pending.score]
```

### 5.1. Bước 1: Tính điểm trung bình của từng Task
Với mỗi câu hỏi Task (`q`):
1. Lấy danh sách tiêu chí chấm:
   ```javascript
   const scoringItems = (parsed.items || []).filter(item => item.isEditableScore);
   ```
2. Với mỗi tiêu chí, lấy điểm theo thứ tự ưu tiên:
   ```javascript
   const key = `${qId}-${item.id}`;
   let partScore = (taskEdits[key] && taskEdits[key].score !== undefined && taskEdits[key].score !== "")
       ? parseFloat(taskEdits[key].score)
       : parseFloat(item.score || 0);
   ```
3. Tính trung bình:
   $$\text{avgScore} = \frac{\sum \text{partScore}}{\text{scoringItems.length}}$$
4. Nếu là **Format Mới (Criteria)**: Làm tròn về bội số của 0.5:
   ```javascript
   avgScore = Math.round(avgScore * 2) / 2;
   ```
5. Lưu kết quả vào `taskScores[taskNum]`.

### 5.2. Bước 2: Tính Điểm Tổng Writing (Overall)
Đồng bộ hoàn toàn với backend (`StudentExamDetailService@calculateWritingAvg`):
- **Điều kiện nhận diện IELTS (`isIeltsFormula`):**
  ```javascript
  const isIeltsFormula = (contestType === 27 || formula === 4);
  ```
- **Khi là IELTS (`isIeltsFormula = true`) và có 2 Task:**
  $$\text{Overall Writing} = \text{floor}\left( \frac{\text{Task 1} + 2 \times \text{Task 2}}{3} \times 2 \right) / 2$$
  *(Làm tròn xuống mức 0.5 gần nhất).*
- **Khi là IELTS (`isIeltsFormula = true`) nhưng chỉ có 1 Task:** Lấy điểm của Task đó và làm tròn sàn bước 0.5.
- **Các Contest khác (không phải Contest 27 và Formula != 4):**
  Tính trung bình cộng các Task có điểm và làm tròn xuống bước 0.1 (`Math.floor(finalScore * 10) / 10`).

Kết quả cuối cùng được gán vào `pending.score` và đồng bộ hiển thị lên header/input điểm.

---

## 6. CƠ CHẾ BÁO CÁO GIAN LẬN (CHEATING REPORTS)

Hệ thống cho phép giáo viên gắn cờ nghi vấn gian lận (chép văn mẫu, dùng AI tool, paraphrase trái phép) độc lập trên từng Task:
- Quản lý trong: `pending.cheating_reports[qId]`.
- Cấu trúc:
  ```javascript
  {
      task_number: 1,
      type: "ai_generated",       // Phân loại: ai_generated | copied | unauthorized_material
      reason: "Nghi ngờ dùng GPT", // Mô tả chi tiết lý do nghi vấn
      file_name: "evidence.pdf",   // Tên file minh chứng
      file_url: "https://...",     // URL file trên S3 (nếu có sẵn)
      file: File                   // File Object tải từ máy tính lên (nếu mới upload)
  }
  ```

> [!IMPORTANT]
> **Quy tắc Lưu Trữ Cheating Report:**  
> Dữ liệu `cheating_reports` **tuyệt đối không được lưu vào `clonedHistory` (tức cột `data_rescored`)** để tránh làm phình JSON lịch sử làm bài.  
> Thay vào đó:
> 1. Xóa `delete clonedHistory.cheating_reports;`.
> 2. Gói vào `commentData.cheating_reports` (lưu ở cột `rescored_comment`).
> 3. File nhị phân được đính kèm vào `formData.append('cheating_files[qId]', file)`.

---

## 7. QUY TRÌNH ĐÓNG GÓI DỮ LIỆU KHI LƯU (`processWritingSaveData`)

Khi người dùng bấm Lưu, hàm [processWritingSaveData](file:///var/www/html/hocmai_testsite/public/js/exam-history/exam-detail-modal.edit-v3.js#L2266) thực hiện theo 5 bước tuần tự:

```mermaid
sequenceDiagram
    participant User as Giáo viên
    participant Process as processWritingSaveData
    participant Cloned as clonedHistory (RAM)
    participant FormData as formData
    participant API as Backend Server

    User->>Process: Bấm "Lưu kết quả chấm"
    Process->>Cloned: 1. Deep clone từ task.historyGoc
    Process->>Cloned: 2. Quét taskEdits, tách key `${qId}-${itemKey}`
    Note over Process,Cloned: Vá điểm & comment mới vào đúng Criteria / Part
    Process->>Cloned: 3. Tính lại mảng clonedHistory.writing (task_number, score)
    Process->>Cloned: 4. Gán clonedHistory.lms_score (fallback historyGoc.lms_score)
    Process->>FormData: 5. Gói cheating_reports vào commentData & file đính kèm
    Process->>Cloned: 6. Xóa delete clonedHistory.cheating_reports
    Process->>FormData: 7. formData.append('data_rescored', JSON.stringify(clonedHistory))
    Process->>API: Gửi Request Lưu
```

### Trích đoạn code vá dữ liệu trọng yếu:
```javascript
// 2. Vá dữ liệu từ taskEdits nháp vào JSON
const taskEdits = task.pendingData.taskEdits || {};
for (const [key, editData] of Object.entries(taskEdits)) {
    const [qIdStr, itemKey] = key.split('-');
    const qId = parseInt(qIdStr);

    const graded = (clonedHistory.listQuestionGraded || []).find(g => g.idQuestion == qId);
    if (!graded || !graded.externalGradingDetail) continue;

    const firstContentData = graded.externalGradingDetail.content[0]?.data;
    const criteriaList = firstContentData?.criteria?.data;

    if (Array.isArray(criteriaList) && criteriaList.length > 0) {
        // Format Mới (Criteria)
        const crit = criteriaList.find(c => c.code === itemKey);
        if (crit && crit.data) {
            if (editData.score !== undefined && editData.score !== "" && editData.score !== null && !isNaN(parseFloat(editData.score))) {
                crit.data.score = parseFloat(editData.score);
            }
            if (editData.comment !== undefined) {
                crit.data.comment = Array.isArray(crit.data.comment) ? [editData.comment] : editData.comment;
            }
            if (editData.evaluation_status !== undefined) crit.data.evaluation_status = editData.evaluation_status;
            if (editData.edit_reason !== undefined) crit.data.edit_reason = editData.edit_reason;
        }
    } else {
        // Format Cũ (Part 1..6)
        const pNum = parseInt(itemKey);
        const detail = graded.externalGradingDetail.content.find(d => d.part_number == pNum);
        if (detail && detail.data) {
            if (editData.score !== undefined && editData.score !== "" && editData.score !== null && !isNaN(parseFloat(editData.score))) {
                detail.data.score = parseFloat(editData.score);
            }
            // Xử lý comment cho từng Part 1, 6 hoặc 2..5...
            if (editData.evaluation_status !== undefined) detail.data.evaluation_status = editData.evaluation_status;
            if (editData.edit_reason !== undefined) detail.data.edit_reason = editData.edit_reason;
        }
    }
}
```

---

## 8. CÁC CẠM BẪY KỸ THUẬT (GOTCHAS & BEST PRACTICES)

### 8.1. Cạm bẫy `parseFloat(null)` sinh ra `NaN`
- **Hiện tượng:** Trong JS, `parseFloat(undefined)` ra `NaN`, và `parseFloat(null)` **cũng ra `NaN`**.
- **Lỗi từng gặp:** Biểu thức `if (editData.score !== undefined && editData.score !== "")` vẫn đánh giá `true` khi `editData.score === null`. Khi đó `parseFloat(null)` gán giá trị `NaN` vào đối tượng JSON, làm hỏng dữ liệu khi `JSON.stringify` gửi lên server.
- **Giải pháp chuẩn:** Luôn kèm điều kiện kiểm tra `!isNaN`:
  ```javascript
  if (editData.score !== undefined && editData.score !== "" && editData.score !== null && !isNaN(parseFloat(editData.score)))
  ```

### 8.2. Reset Criterion không được dùng `null`
- Khi bấm nút "Đặt lại" (`.btn-reset-criterion`), không gán `score = null`. Thay vào đó, gán `score = ''` (chuỗi rỗng) hoặc khôi phục đúng `origScore`.

### 8.3. Điểm LMS tổng bị rơi về `0` khi chưa sửa tiêu chí
- **Hiện tượng:** Nếu giáo viên mở bài thi, chỉ bấm "Xác nhận đúng" hoặc không can thiệp điểm tiêu chí, `task.pendingData.score` có thể bị rỗng.
- **Giải pháp chuẩn:** Luôn có cơ chế Fallback về điểm gốc ban đầu:
  ```javascript
  const finalScoreVal = (task.pendingData.score !== null && task.pendingData.score !== undefined && !isNaN(parseFloat(task.pendingData.score)))
      ? parseFloat(task.pendingData.score)
      : parseFloat(task.historyGoc?.lms_score || 0);
  ```

### 8.4. Tách biệt `data_rescored` và `rescored_comment`
- `data_rescored`: Chứa toàn bộ cấu trúc bài thi, câu hỏi, câu trả lời của học sinh, kết quả chấm chi tiết của từng tiêu chí (JSON sạch, không chứa file binary, không chứa cheating_reports).
- `rescored_comment`: Chứa nhận xét chung của giáo viên, danh sách cheating reports (metadata), và tổng điểm.
- Binary files (file minh chứng gian lận): Luôn gửi qua `multipart/form-data`.
