# DuckMind (DM) — Chấm bài Testsite

Tài liệu mô tả tích hợp DuckMind (`dm`) vào LMS để chấm bài viết Testsite, thay/l song song luồng OpenAI hiện có.

---

## 1. Tổng quan

| Thành phần | Mô tả |
|---|---|
| Binary | `/usr/bin/dm` (hardcode trong `config/dm.php`) |
| Model / provider | `lite` / `duckmind` |
| Thư mục làm việc | `dm/` (`DM_WORK_DIR`, mặc định `base_path('dm')`) |
| Rubric / hướng dẫn agent | `dm/AGENTS.md` (stdin khi chạy CLI) |
| Prompt chấm theo `idPrompt` | `dm/prompts.json` (Laravel load theo `prompt_id`) |
| Kết quả CLI | `dm/grading_results/<input_stem>_graded.json` |
| Service chính | `app/Services/Dm/DmGradingService.php` |
| Map kết quả → CMS format | `app/Services/Dm/DmResultMapper.php` |
| DB run | bảng `dm_grading_runs` |

**Quan trọng:** Agent `dm` **không tự gọi HTTP**. Laravel spawn process `dm`, **chờ process thoát**, rồi kiểm tra file kết quả trên disk.

---

## 2. Cấu trúc code

| Thành phần | Path |
|---|---|
| Config | `config/dm.php` |
| Service | `app/Services/Dm/DmGradingService.php` |
| Result mapper | `app/Services/Dm/DmResultMapper.php` |
| Model run | `app/Models/DmGradingRun.php` |
| Migration | `database/migrations/2026_08_17_100000_create_dm_grading_runs_table.php` |
| Controller API DM | `app/Http/Controllers/DmController.php` |
| Controller Testsite | `app/Http/Controllers/Testsite/TestsiteController.php` |
| Job production (queue) | `app/Jobs/Dm/GradeDmTestsiteJob.php` |
| Job test / legacy | `app/Jobs/GradeDmTestsiteJob.php`, `app/Jobs/RunDmExamGradingJob.php` |
| UI quản lý file `dm/` | `app/Http/Controllers/DmFilesController.php`, `/dm-files` |
| Agent instructions | `dm/AGENTS.md` |
| Sample exam | `dm/exam_01.json` |

---

## 3. API endpoints

### 3.1. Production — submit bài (queue)

| | |
|---|---|
| **Method** | `POST /api/testsite/essay/submit-dm` |
| **Controller** | `TestsiteController::essaySubmitAllDm` |
| **Tương tự** | `essaySubmitAll` (OpenAI) về phần tạo `testsite_api_questions` |
| **Khác** | Chấm bằng DM qua queue, **không** dispatch job OpenAI |

**Body bắt buộc:** `essayPrompt`, `idPrompt`, `sample`, `essay` (optional nếu rỗng).

**Response:**

```json
{
  "status": 1,
  "data": {
    "idGptMessage": 100001
  }
}
```

### 3.2. Test sync — chấm ngay (không queue)

| | |
|---|---|
| **Method** | `POST /api/testsite/essay/test` |
| **Controller** | `TestsiteController::test15` |
| **Hành vi** | Tạo `dm_grading_runs` → gọi `gradeFromDatabase` **đồng bộ** → trả score/content |
| **CMS** | `notify_cms = false` |

**Poll trạng thái run:**

```
GET /api/testsite/essay/test/status?id={run_id}
```

### 3.3. API DM chung (file-based)

| Method | URI | Mô tả |
|---|---|---|
| GET/POST | `/api/dm/grade-exam` | Chấm file trong `dm/` (vd. `exam_01.json`), có thể `sync=1` hoặc queue |
| GET | `/api/dm/grade-exam/status?file=` | Poll file kết quả có tồn tại chưa |
| GET | `/api/dm/grade-exam/result?file=` | Đọc JSON graded |
| POST | `/api/dm/grade-exam/completed` | Callback nội bộ sau khi chấm xong |

### 3.4. Luồng OpenAI cũ (tham chiếu)

| Method | URI |
|---|---|
| POST | `/api/testsite/essay/submit` → `essaySubmitAll` → `GradeTestsiteEssayJob` |

Luồng DM **tách namespace** `Jobs/Dm` để không ảnh hưởng job OpenAI hiện tại.

---

## 4. Luồng chấm production (`submit-dm`)

```mermaid
flowchart TD
  A[POST /essay/submit-dm] --> B[Tạo/cập nhật testsite_api_questions]
  B --> C[id_gpt_message = 100000 + id]
  C --> D[dispatch GradeDmTestsiteJob]
  D --> E[Queue worker]
  E --> F[createRunFromRequest → dm_grading_runs]
  F --> G[gradeFromDatabase]
  G --> H[writeExamFileFromRun → exam_{run_id}.json]
  H --> I[run → spawn CLI dm]
  I --> J{Process exit + file graded?}
  J -->|OK| K[Map cms_payload, update testsite_api_questions]
  J -->|Fail| L[Ghi error vào dm_grading_runs]
  I --> M[POST /api/dm/grade-exam/completed]
```

### Step 1 — API (đồng bộ, nhanh)

1. Validate body giống `essaySubmitAll`.
2. Insert/update `testsite_api_questions` (`status = 0`).
3. Gán `id_gpt_message = 100000 + testsite_api_questions.id`.
4. `GradeDmTestsiteJob::dispatch($questionId)`.
5. Trả `idGptMessage` cho client.

### Step 2–4 — Job `Jobs/Dm/GradeDmTestsiteJob`

1. **Step 2:** `createRunFromRequest($jsonData, notify_cms: false, $questionId)` → bản ghi `dm_grading_runs`.
2. **Step 3:** `gradeFromDatabase($runId)`:
   - Ghi `dm/exam_{run_id}.json` (prompt embed từ `prompts.json` theo `id_prompt`).
   - Gọi `run()` → CLI `dm`.
3. **Step 4:** Format payload giống job OpenAI:

```php
[
    'idGptMessage' => 100000 + questionId,
    'score'        => ...,
    'content'      => ...,
]
```

Cập nhật `testsite_api_questions`: `status = 2`, `openai_response = json_encode(payload)`, `score`.

**Job không gọi CMS** và không dùng `Log::info` / `Log::error` (chỉ `writeGlobalLog`).

---

## 5. Cách Laravel biết “chấm xong”

Không có webhook từ binary `dm`. Cơ chế:

1. `Symfony\Component\Process\Process::run()` — **block** đến khi process `dm` thoát (timeout mặc định 1800s, job timeout ~1900s).
2. Kiểm tra file: `dm/grading_results/exam_{run_id}_graded.json`.
3. Coi **thành công** khi: exit code OK **và** file tồn tại, parse được JSON.
4. Sau đó (nếu `$notify = true` trong `run()`): Laravel gọi API nội bộ completed.

```text
Laravel                    dm CLI
   |                          |
   |-- spawn dm ------------->|
   |   (stdin = AGENTS.md)    | chấm, ghi _graded.json
   |<-- process exit ---------|
   |-- is_file(_graded.json)  |
   |-- notifyCompleted()      |
```

Nếu chạy `dm` **tay ngoài Laravel**, hệ thống không tự biết — cần poll `GET /api/dm/grade-exam/status` hoặc tự kiểm tra file.

---

## 6. Hàm `DmGradingService::run()`

Điểm vào chạy CLI, được gọi từ:

| Caller | Ghi chú |
|---|---|
| `gradeFromDatabase()` | Luồng Testsite / test15 |
| `RunDmExamGradingJob` | Gọi trực tiếp `run()` |
| `DmController::gradeExam(sync=1)` | Sync file-based |

**Lệnh tương đương:**

```bash
cd dm
cat AGENTS.md | /usr/bin/dm --model lite --provider duckmind --print "Read AGENTS.md and grade exam_{id}.json"
```

- **Stdin:** nội dung `AGENTS.md`
- **Print prompt:** không chứa URL callback — chỉ tên file cần chấm
- **Env:** `DUCKMIND_API_KEY`, `OPENROUTER_API_KEY` (qua `Process`, không truyền trên CLI)

**Output path:**

```text
Input:  dm/exam_{run_id}.json
Output: dm/grading_results/exam_{run_id}_graded.json
```

Mỗi bài một `run_id` → không ghi đè lẫn nhau.

---

## 7. Mapping field API → exam JSON

| API / DB (`testsite_api_questions`) | Field trong `exam_*.json` | Ghi chú |
|---|---|---|
| `essay` | `bai_lam_writing` | Bài làm học viên |
| `essay_prompt` / `essayPrompt` | `de_bai_luc_thi` | Đề lúc thi; thay `{essayPrompt}` trong prompt |
| `sample` | `baimau` | Đề/sample gốc (tham chiếu) |
| `test_site_prompt_id` / `idPrompt` | (load từ `prompts.json`) | Laravel embed vào field `prompt` |
| `part` (mặc định 5) | `part_id` | Convention “chấm all part” giống OpenAI |

Placeholder trong prompt:

- `{essayPrompt}` → `de_bai_luc_thi`
- `{essay}` → `bai_lam_writing`

---

## 8. Nhiều bài → nhiều job

| | |
|---|---|
| 1 request `submit-dm` | 1 `testsite_api_questions` + 1 `GradeDmTestsiteJob` |
| N request | N job trên queue |
| Mỗi job | 1 lần `gradeFromDatabase()` → 1 lần `run()` → 1 process `dm` |

**Queue worker:**

- **1 worker:** job chạy tuần tự (bài sau chờ bài trước xong).
- **N worker:** có thể chấm song song (N process `dm`).

Cần `php artisan queue:work` đang chạy trên môi trường production.

---

## 9. API callback nội bộ sau khi grade

| | |
|---|---|
| **URL mặc định** | `{APP_URL}/api/dm/grade-exam/completed` |
| **Override** | env `DM_CALLBACK_URL` |
| **Token (optional)** | `DM_CALLBACK_TOKEN` → header `X-DM-Callback-Token` |
| **Ai gọi** | Laravel `DmGradingService::notifyCompleted()` sau `run()` |
| **Handler** | `DmController::completed` |
| **Lưu thêm** | `dm/grading_results/last_callback.json` |

**DM agent không gọi endpoint này** — ghi rõ trong `dm/AGENTS.md`.

### CMS Testsite (khác callback)

| | |
|---|---|
| **Hàm** | `Testsite{idPrompt}::callCms()` qua `callCmsForRun()` |
| **Điều kiện** | `dm_grading_runs.notify_cms = true` |
| **Luồng hiện tại** | `submit-dm` và `test15` đều `notify_cms = false` → **chưa gọi CMS** |

Payload CMS format (giống job OpenAI):

```json
{
  "idGptMessage": 100001,
  "score": 6.5,
  "content": "Overall Score: 6.5\n\n..."
}
```

---

## 10. Bảng `dm_grading_runs`

| Cột | Ý nghĩa |
|---|---|
| `testsite_api_question_id` | FK logic tới bài Testsite (nullable với test15 thuần) |
| `id_prompt` | `idPrompt` / `test_site_prompt_id` |
| `part` | Part id (mặc định 5) |
| `status` | `pending` / `running` / `completed` / `failed` |
| `notify_cms` | Có gọi CMS sau khi xong không |
| `input_payload` | JSON request gốc |
| `input_file` | vd. `exam_12.json` |
| `dm_result_path` | Đường dẫn file graded |
| `score`, `content`, `cms_payload` | Kết quả đã map |
| `error_message` | Lỗi nếu failed |

---

## 11. Cấu hình môi trường

Trong `.env` / `.env.example`:

```env
DM_WORK_DIR=           # optional, default: {project}/dm
DM_TIMEOUT=1800
DUCKMIND_API_KEY=
OPENROUTER_API_KEY=
DM_CALLBACK_URL=       # optional
DM_CALLBACK_TOKEN=     # optional
```

Sau đổi config: `php artisan config:clear`.

---

## 12. UI quản lý file `dm/`

| | |
|---|---|
| **URL** | `/dm-files` (middleware `auth`) |
| **Sửa được** | `AGENTS.md`, `prompts.json` |
| **Chỉ xem** | `exam_*.json`, mọi file trong `grading_results/` |
| **Xóa** | Chọn checkbox file trong `grading_results/` → xóa 1 hoặc nhiều file |

---

## 13. Deploy / vận hành

1. Deploy code + chạy migration `dm_grading_runs`.
2. Đảm bảo `/usr/bin/dm` tồn tại trên server (PHP-FPM user đọc được).
3. Set API keys trong `.env`.
4. Chạy queue worker cho job `GradeDmTestsiteJob`.
5. Kiểm thử:
   - Sync: `POST /api/testsite/essay/test`
   - Queue: `POST /api/testsite/essay/submit-dm`
6. Xem log: channel `writeGlobalLog('dm', ...)` và `writeGlobalLog('testsite', ...)`.
7. Xem file kết quả: `/dm-files` hoặc `dm/grading_results/`.

---

## 14. So sánh nhanh các luồng

| | `essay/submit` (OpenAI) | `essay/submit-dm` | `essay/test` (test15) |
|---|---|---|---|
| Tạo `testsite_api_questions` | Có | Có | Không (chỉ `dm_grading_runs`) |
| Chấm | OpenAI jobs | DM queue job | DM sync |
| Trả kết quả ngay | Không | Không | Có |
| Gọi CMS | Có (trong job OpenAI) | Không (hiện tại) | Không |
| Job class | `GradeTestsiteEssayJob` | `Jobs/Dm/GradeDmTestsiteJob` | Không dùng job |

---

## 15. Tài liệu liên quan

- `dm/AGENTS.md` — hướng dẫn cho agent DuckMind khi chấm
- `dm/prompts.json` — prompt theo `prompt_id`
- `dm/exam_01.json` — mẫu cấu trúc file bài làm
