# Hướng dẫn thêm Prompt chấm bài AiHub mới

Tài liệu này mô tả các bước thêm một `prompt_id` mới vào luồng chấm bài qua API:

```
EMS → POST /api/open_ai/hub/task/all → HubTaskJob → OpenAI → normalize → EMS (lưu kết quả)
FE  → đọc kết quả từ EMS → render theo blocks
```

---

## Kiến trúc tóm tắt

| Tầng | Thành phần | Vai trò |
|------|------------|---------|
| Rubric | `app/Models/AiHub/Prompt/Prompt*.php` | Nội dung hướng dẫn AI chấm (word count, tiêu chí, STOP case…) |
| Output block | `AiHubOutputPromptBlock` | Block cuối prompt — bắt AI trả **JSON trung gian** |
| Config | `AiHubPromptConfig` | `scale`, `lang`, flags theo từng `prompt_id` |
| Normalizer | `AiHubResponseNormalizer` | JSON trung gian → format `meta` + `overall` + `blocks` cho FE/EMS |
| Job | `HubTaskJob` | Gọi chấm → normalize → callback EMS |

**Không cần sửa route** khi thêm prompt mới — EMS vẫn gọi `POST /api/open_ai/hub/task/all` với `prompt_id` tương ứng.

---

## Checklist nhanh

- [ ] Bước 1: Tạo file `Prompt/PromptXxx.php`
- [ ] Bước 2: Thêm config vào `AiHubPromptConfig.php`
- [ ] Kiểm tra flag output JSON trong `AiHubPromptConfig.php`:
  - [ ] `include_revised_essay` (có hiện block `revised_essay`/“Bài viết mẫu” hay không)
  - [ ] `include_examples` (có yêu cầu mảng `examples` cho từng criterion hay không)
- [ ] Thêm `prompt_id` vào `AiHubPromptConfig::supportsBlockFormat()`
- [ ] Thêm `prompt_id` vào `AiHubGradingResult::BLOCK_FORMAT_PROMPT_IDS`
- [ ] Bước 3: Map `prompt_id` trong `CommonAiHubOther.php`
- [ ] Bước 4: (Tuỳ chọn) Thêm tên vào `returnPromptData()` trong `Helper.php`
- [ ] Bước 5: Test qua `/task/all/test`
- [ ] Bước 6: Deploy + `php artisan queue:restart`

---

## Bước 1 — Tạo file Prompt PHP

**Vị trí:** `app/Models/AiHub/Prompt/PromptYourNew.php`

**Ví dụ:** thêm `prompt_id = 11` (thay số và nội dung rubric cho phù hợp).

```php
<?php

namespace App\Models\AiHub\Prompt;

use Illuminate\Database\Eloquent\Model;
use OpenAI;
use App\Models\AiHub\Output\AiHubOutputPromptBlock;

class PromptYourNew extends Model
{
    public static function gradeEssayTreeJson($jsonData)
    {
        $yourApiKey = getenv('OPENAI_HOCMAI_KEY');
        $client = OpenAI::client($yourApiKey);
        $model = getenv('OPENAI_API_MODEL');

        $topic = $jsonData['topic'];
        $essay = $jsonData['essay'];

        // Phần 1: Rubric chấm bài (viết tự do theo yêu cầu nghiệp vụ)
        $systemMessage = <<<'PROMPT'
Act as an IELTS examiner. Score the student's writing based on four criteria:
Task Achievement, Coherence & Cohesion, Lexical Resource, Grammatical Range & Accuracy.

STEP 1 — WORD COUNT (internal, do not show in output)
- Fewer than 20 words → STOP with score 0.

STEP 2 — SCORING RULES
- Maximum band: 9.0
- Overall score = average of 4 criteria, rounded to nearest 0.5.

[... thêm rubric chi tiết tại đây ...]
PROMPT;

        // Phần 2: BẮT BUỘC — block output JSON trung gian (dùng chung)
        $systemMessage .= "\n\n" . AiHubOutputPromptBlock::forPrompt(11);

        $message = [
            [
                'role' => 'system',
                'content' => $systemMessage,
            ],
            [
                'role' => 'user',
                'content' => "ĐỀ BÀI:\n" . $topic . "\n\nBÀI LÀM CỦA HỌC VIÊN:\n" . $essay,
            ],
        ];

        $chat = $client->chat()->create([
            'model' => $model,
            'messages' => $message,
            'temperature' => 0,
            'max_completion_tokens' => 3000, // rubric dài thì tăng lên 5000
        ]);

        return $chat;
    }
}
```

**Tham khảo prompt có sẵn:**

| prompt_id | File mẫu |
|-----------|----------|
| 5 | `PromptEasyPass13.php` — Homework, max 7.5, tiếng Việt |
| 6 | `PromptEasyPass14.php` — A2 Key, max 2.0 |
| 7 | `PromptIeltsClassroom11.php` — B1 Foundation, max 3.0 |
| 8 | `PromptIeltsClassroom12.php` — Task 1, max 9.0, tiếng Anh |
| 9 | `PromptIelts25Band16.php` — Task 2, max 9.0, tiếng Anh |

**Lưu ý quan trọng:**

- Cuối rubric **luôn** append `AiHubOutputPromptBlock::forPrompt($promptId)`.
- Không cần viết format plain-text kiểu `Overall Score: ...` ở cuối — output block sẽ override.
- Không yêu cầu AI trả `question_id` — backend tự gắn khi gọi EMS.

---

## Bước 2 — Thêm config thang điểm

**File:** `app/Models/AiHub/Output/AiHubPromptConfig.php`

### 2.1. Thêm entry trong `get()`

```php
public static function get(int $promptId): array
{
    $configs = [
        // ... các prompt cũ ...
        11 => [
            'lang' => 'vi',                  // 'vi' hoặc 'en' — ngôn ngữ feedback
            'scale' => 9.0,                  // điểm TỐI ĐA của prompt này
            // CÁC FLAG ĐIỀU KHIỂN OUTPUT JSON
            // - include_revised_essay: bật/tắt block `revised_essay` (FE sẽ hiển thị “Bài viết mẫu”)
            // - include_examples: bật/tắt field `examples` cho từng criterion
            'include_revised_essay' => true, // bật/tắt block "revised_essay"
            'include_examples' => false,     // bật/tắt field "examples"
        ],
    ];

    return $configs[$promptId] ?? [
        'lang' => 'en',
        'scale' => 9.0,
        'include_revised_essay' => true,
        'include_examples' => false,
    ];
}
```

**Ý nghĩa `scale`:** FE dùng để hiển thị `overall.score / meta.scale`.

| prompt_id | scale | Ý nghĩa |
|-----------|-------|---------|
| 5 | 7.5 | Homework |
| 6 | 2.0 | A2 Key |
| 7 | 3.0 | B1 Foundation |
| 8, 9 | 9.0 | IELTS full band |

### 2.2. Thêm vào `supportsBlockFormat()`

Prompt mới **phải** nằm trong danh sách này để `HubTaskJob` normalize output trước khi gửi EMS:

```php
public static function supportsBlockFormat(int $promptId): bool
{
    return in_array($promptId, [5, 6, 7, 8, 9, 11], true);
}
```

### 2.3. Lưu ý quan trọng về `revised_essay`

Trong hệ thống này, phần rubric plain-text trong `Prompt/PromptXxx.php` chỉ để **hướng dẫn** AI.

Thứ quyết định JSON output (và vì vậy FE/EMS có hiển thị “Bài viết mẫu” hay không) là block `AiHubOutputPromptBlock::forPrompt($promptId)` và đặc biệt là config `AiHubPromptConfig`:
- Nếu `include_revised_essay = true` ⇒ normalizer sẽ thêm block `revised_essay` vào response.
- Nếu rubric/prompt của bạn **không** muốn có “bài viết mẫu” ⇒ bắt buộc đặt `include_revised_essay => false`.

Sau khi đổi config, nhớ chạy:
`php artisan queue:restart`

---

## Mở rộng schema — thêm một block tùy chọn mới

Phần này áp dụng khi prompt mới không chỉ dùng 4 block tiêu chí có sẵn mà cần thêm một “cục” mới tương tự `revised_essay`.

Ví dụ: prompt 11 cần thêm kế hoạch học tập:

```json
{
  "code": "learning_plan",
  "type": "list",
  "title": "Kế hoạch học tập",
  "items": [
    "Ôn lại câu điều kiện",
    "Viết lại phần mở bài",
    "Học 10 collocations theo chủ đề"
  ]
}
```

Ta sẽ dùng flag:

```php
'include_learning_plan' => true
```

### Các file bắt buộc phải sửa

| File | Nội dung cần sửa |
|------|------------------|
| `app/Models/AiHub/Output/AiHubPromptConfig.php` | Thêm flag `include_learning_plan` và title nếu cần đa ngôn ngữ |
| `app/Models/AiHub/Output/AiHubOutputPromptBlock.php` | Thêm field `learning_plan` vào JSON trung gian và quy tắc yêu cầu AI sinh dữ liệu |
| `app/Models/AiHub/Output/AiHubResponseNormalizer.php` | Đọc `learning_plan` và chuyển thành block chuẩn cho EMS/FE |
| Prompt PHP mới, ví dụ `PromptYourNew.php` | Append `AiHubOutputPromptBlock::forPrompt($promptId)` như bình thường |
| FE | Thêm renderer nếu `type` mới chưa được FE hỗ trợ |

### Các file có thể phải sửa

| File | Khi nào cần sửa |
|------|-----------------|
| `AiHubPromptConfig.php::supportsBlockFormat()` | Luôn thêm `prompt_id` mới |
| `AiHubGradingResult.php::BLOCK_FORMAT_PROMPT_IDS` | Luôn thêm nếu prompt tham gia luồng Writing block format/status/score |
| `CommonAiHubOther.php` | Luôn map `prompt_id` sang class prompt |
| `Helper.php::returnPromptData()` | Khi cần hiển thị prompt trong API danh sách/UI |
| `AiHubResponseNormalizer::isIntermediateFormat()` | Chỉ cần nếu response mới có thể không chứa `criteria`; hiện schema chuẩn luôn có `criteria` nên thường không cần |
| Các hàm chuyển legacy trong `AiHubResponseNormalizer` | Chỉ cần nếu phải hỗ trợ response cũ cũng chứa block mới |

### Bước A — thêm flag trong `AiHubPromptConfig`

Thêm flag vào prompt cần dùng. Các prompt không dùng nên đặt `false`.

```php
11 => [
    'lang' => 'vi',
    'scale' => 9.0,
    'include_revised_essay' => false,
    'include_examples' => false,
    'include_learning_plan' => true,
],
```

Nên thêm flag với giá trị mặc định an toàn trong các entry khác và fallback:

```php
'include_learning_plan' => false,
```

Khi đọc flag ở code dùng `!empty(...)` hoặc `?? false` để tránh lỗi `Undefined array key`:

```php
$includeLearningPlan = !empty($config['include_learning_plan']);
```

Nếu title phụ thuộc ngôn ngữ, thêm helper:

```php
public static function learningPlanTitle(string $lang): string
{
    return $lang === 'vi' ? 'Kế hoạch học tập' : 'Learning Plan';
}
```

### Bước B — cập nhật `AiHubOutputPromptBlock`

Trong `forPrompt()` đọc flag:

```php
$includeLearningPlan = !empty($config['include_learning_plan']);
```

Tạo rule có điều kiện:

```php
$learningPlanRule = $includeLearningPlan
    ? '- "learning_plan" must be an array of 3-5 actionable strings.'
    : '- Set "learning_plan" to an empty array [].';
```

Thêm field vào cấu trúc JSON trung gian:

```json
{
  "overall_score": 0,
  "stop": false,
  "stop_message": "",
  "summary": "",
  "criteria": {},
  "revised_essay": "",
  "learning_plan": []
}
```

Sau đó chèn `{$learningPlanRule}` vào phần `JSON rules`.

Lưu ý:
- Tên field AI trả về là `learning_plan`.
- `code` block gửi cho FE cũng nên là `learning_plan`.
- Không dùng cùng một tên cho hai mục có ý nghĩa khác nhau.
- Khi flag `false`, bắt AI trả `[]` và normalizer không tạo block.

### Bước C — cập nhật `AiHubResponseNormalizer`

Trong `fromIntermediateFormat()`, sau đoạn xử lý `revised_essay`, thêm:

```php
if (!empty($config['include_learning_plan'])) {
    $items = $raw['learning_plan'] ?? [];

    if (is_string($items)) {
        $items = [$items];
    }

    if (is_array($items)) {
        $items = array_values(array_filter(array_map('strval', $items)));
    } else {
        $items = [];
    }

    if (!empty($items)) {
        $blocks[] = [
            'code' => 'learning_plan',
            'type' => 'list',
            'title' => AiHubPromptConfig::learningPlanTitle($lang),
            'items' => $items,
        ];
    }
}
```

Quy tắc normalize:
- Không tin trực tiếp kiểu dữ liệu AI trả về.
- Ép về đúng type (`array`, `string`, số...).
- Bỏ dữ liệu rỗng.
- Chỉ tạo block khi flag bật và dữ liệu hợp lệ.
- Không đưa nguyên object không kiểm soát từ AI sang FE.

### Bước D — cập nhật FE

Nếu FE chỉ hỗ trợ:
- `type = criterion`
- `type = essay`

thì block mới `type = list` cần component tương ứng:

```text
type=list
fields: code, title, items
```

FE nên render theo `type`, không hard-code theo vị trí trong mảng `blocks`.

Nếu không muốn sửa FE, có thể tái sử dụng type đã hỗ trợ, ví dụ:

```json
{
  "code": "learning_plan",
  "type": "essay",
  "title": "Kế hoạch học tập",
  "content": "1. ...\n2. ...\n3. ..."
}
```

Tuy nhiên, dùng `type = list` và `items` rõ ràng hơn nếu FE có thể bổ sung renderer.

### Bước E — cập nhật danh sách prompt block format

Hai nơi sau phải đồng bộ:

```php
// AiHubPromptConfig.php
public static function supportsBlockFormat(int $promptId): bool
{
    return in_array($promptId, [5, 6, 7, 8, 9, 11], true);
}
```

```php
// AiHubGradingResult.php
public const BLOCK_FORMAT_PROMPT_IDS = [5, 6, 7, 8, 9, 11];
```

Nếu quên:
- Thiếu trong `supportsBlockFormat()` ⇒ kết quả không được normalize thành `meta`, `overall`, `blocks`.
- Thiếu trong `BLOCK_FORMAT_PROMPT_IDS` ⇒ logic lấy điểm/trạng thái Writing có thể không nhận prompt mới là block-format flow.

### Bước F — test block mới

Phải test đủ bốn trường hợp:

1. Prompt có flag `true`, AI trả dữ liệu hợp lệ ⇒ có block mới.
2. Prompt có flag `true`, AI trả field rỗng ⇒ không tạo block rỗng.
3. Prompt có flag `false` ⇒ tuyệt đối không có block mới.
4. AI trả sai kiểu dữ liệu ⇒ normalizer không lỗi và không đưa dữ liệu bẩn sang FE.

Response mong đợi:

```json
{
  "meta": {
    "prompt_id": 11,
    "language": "vi",
    "scale": 9,
    "status": "graded"
  },
  "overall": {
    "score": 6.5,
    "summary": "..."
  },
  "blocks": [
    {
      "code": "task_achievement",
      "type": "criterion",
      "title": "Task Achievement",
      "score": 6,
      "feedback": "...",
      "suggestion": "...",
      "examples": []
    },
    {
      "code": "learning_plan",
      "type": "list",
      "title": "Kế hoạch học tập",
      "items": [
        "Ôn lại câu điều kiện",
        "Viết lại phần mở bài",
        "Học 10 collocations theo chủ đề"
      ]
    }
  ]
}
```

### Checklist thêm block tùy chọn mới

- [ ] Chọn tên flag, ví dụ `include_learning_plan`
- [ ] Chọn key JSON trung gian, ví dụ `learning_plan`
- [ ] Chọn `code` block ổn định, ví dụ `learning_plan`
- [ ] Chọn `type` và contract fields cho FE
- [ ] Thêm flag vào config prompt và fallback
- [ ] Cập nhật `AiHubOutputPromptBlock`
- [ ] Cập nhật `AiHubResponseNormalizer`
- [ ] Thêm title đa ngôn ngữ nếu cần
- [ ] Cập nhật FE renderer nếu dùng `type` mới
- [ ] Thêm prompt ID vào hai danh sách block format
- [ ] Test flag bật/tắt, dữ liệu rỗng và sai kiểu
- [ ] Chạy `php artisan queue:restart`

---

## Bước 3 — Map prompt_id → class chấm bài

**File:** `app/Models/AiHub/CommonAiHubOther.php`

```php
use App\Models\AiHub\Prompt\PromptYourNew;

public static function gradeEssay($jsonData)
{
    if (!isset($jsonData['prompt_id'])) {
        return false;
    }

    // ... các prompt cũ ...

    if ($jsonData['prompt_id'] == 11) {
        return PromptYourNew::gradeEssayTreeJson($jsonData);
    }

    return false;
}
```

Nếu thiếu bước này, API trả lỗi `prompt_id is not found`.

---

## Bước 4 — (Tuỳ chọn) Thêm tên hiển thị

**File:** `app/Helpers/Helper.php` — hàm `returnPromptData()`

```php
function returnPromptData($prompt_id = null)
{
    $formatted_prompts = [
        // ... các prompt cũ ...
        ['prompt_id' => 11, 'prompt_name' => 'Easy IELTS - Tên prompt mới'],
    ];
    // ...
}
```

Dùng cho:

- `GET /api/open_ai/hub/prompt/list`
- UI quản lý prompt (`prompt_management`)

---

## Bước 5 — Test

### 5.1. Test nhanh (không qua queue)

```bash
curl --location 'https://lmsnew.hocmai.net/api/open_ai/hub/task/all/test' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
    "username": "test_user",
    "user_id": "123",
    "prompt_id": 11,
    "topic": "Write about the benefits of learning English.",
    "essay": "Learning English is important because it helps people communicate with others around the world."
}'
```

### 5.2. Response mong đợi

```json
{
  "code": 200,
  "data": {
    "meta": {
      "prompt_id": 11,
      "language": "vi",
      "scale": 9,
      "status": "graded"
    },
    "overall": {
      "score": 5.5,
      "summary": "Nhận xét tổng quan..."
    },
    "blocks": [
      {
        "code": "task_achievement",
        "type": "criterion",
        "title": "Task Achievement",
        "score": 5.0,
        "feedback": "...",
        "suggestion": "...",
        "examples": []
      },
      {
        "code": "coherence_cohesion",
        "type": "criterion",
        "title": "Coherence & Cohesion",
        "score": 5.5,
        "feedback": "...",
        "suggestion": "...",
        "examples": []
      },
      {
        "code": "lexical_resource",
        "type": "criterion",
        "title": "Lexical Resource",
        "score": 6.0,
        "feedback": "...",
        "suggestion": "...",
        "examples": []
      },
      {
        "code": "grammatical_range",
        "type": "criterion",
        "title": "Grammatical Range & Accuracy",
        "score": 5.5,
        "feedback": "...",
        "suggestion": "...",
        "examples": []
      },
      {
        "code": "revised_essay",
        "type": "essay",
        "title": "Bài viết mẫu",
        "content": "Improved essay text..."
      }
    ]
  },
  "message": "Success"
}
```

### 5.3. FE render theo `type`

| `type` | Component gợi ý | Fields |
|--------|-----------------|--------|
| `criterion` | Thẻ tiêu chí | `code`, `title`, `score`, `feedback`, `suggestion`, `examples` |
| `essay` | Khối văn bản | `code`, `title`, `content` |

Hiển thị điểm: `${overall.score} / ${meta.scale}`

Trạng thái STOP: `meta.status === "stopped"` → chỉ hiện `overall.summary`, ẩn hoặc hiện blocks score 0.

### 5.4. Test luồng thật (queue + EMS)

```bash
curl --location 'https://lmsnew.hocmai.net/api/open_ai/hub/task/all' \
--header 'Content-Type: application/json' \
--data '{
    "username": "test_user",
    "user_id": "123",
    "prompt_id": 11,
    "topic": "...",
    "essay": "...",
    "question_id": 12345,
    "idHistory": "your-id-history"
}'
```

Sau khi deploy code mới:

```bash
php artisan queue:restart
```

---

## JSON trung gian (AI trả về — không gửi thẳng cho FE)

AI được hướng dẫn trả object này (định nghĩa trong `AiHubOutputPromptBlock`):

```json
{
  "overall_score": 2.5,
  "stop": false,
  "stop_message": "",
  "summary": "Nhận xét tổng quan",
  "criteria": {
    "ta": {
      "score": 2.5,
      "feedback": "Feedback Task Achievement",
      "suggestion": "Gợi ý cải thiện",
      "examples": []
    },
    "cc": { "score": 2.5, "feedback": "...", "suggestion": "...", "examples": [] },
    "lr": { "score": 2.5, "feedback": "...", "suggestion": "...", "examples": [] },
    "gra": { "score": 2.5, "feedback": "...", "suggestion": "...", "examples": [] }
  },
  "revised_essay": "Bài viết mẫu tiếng Anh"
}
```

`AiHubResponseNormalizer` chuyển object trên → format `meta` + `overall` + `blocks` trước khi lưu DB và gửi EMS.

---

## Các file liên quan

| File | Vai trò |
|------|---------|
| `app/Jobs/AiHub/Task/HubTaskJob.php` | Gọi chấm + normalize + callback EMS |
| `app/Models/AiHub/Output/AiHubGradingResult.php` | Helper build payload EMS; phải thêm prompt mới vào `BLOCK_FORMAT_PROMPT_IDS` |
| `app/Models/AiHub/Output/AiHubResponseNormalizer.php` | Chuyển đổi format; phải sửa khi thêm loại block/field output mới |
| `app/Models/AiHub/CommonHubTask.php` | Gọi API EMS lưu kết quả |
| `routes/api.php` | Route `task/all` — không đổi |

Khi chỉ thêm prompt dùng đúng schema hiện có, thường không cần sửa `HubTaskJob`, `CommonHubTask` hoặc route.
Khi thêm một loại block mới, phải sửa config, output block, normalizer và có thể cả FE như phần **Mở rộng schema** ở trên.

---

## API endpoints

| Method | Endpoint | Mục đích |
|--------|----------|----------|
| POST | `/api/open_ai/hub/task/all` | EMS gọi chấm bài (async, qua queue) |
| POST | `/api/open_ai/hub/task/all/test` | Test đồng bộ, không queue |
| POST | `/api/open_ai/hub/task/all/run-by-id` | Chấm lại theo `api_hub_user_question_id` |
| GET | `/api/open_ai/hub/prompt/list` | Danh sách prompt (từ `returnPromptData`) |

---

## Request body tối thiểu (`task/all`)

```json
{
  "username": "string",
  "user_id": "string",
  "prompt_id": 11,
  "topic": "Đề bài",
  "essay": "Bài làm học viên",
  "question_id": 12345,
  "idHistory": "tenant-history-id",
  "contest_type_id": 19,
  "sample": null
}
```

- `sample`: optional (có thể `null`).
- `question_id`, `idHistory`: bắt buộc với luồng EMS production.

---

## Troubleshooting

| Triệu chứng | Nguyên nhân | Cách xử lý |
|-------------|-------------|------------|
| `prompt_id is not found` | Chưa map trong `CommonAiHubOther` | Thêm bước 3 |
| Response raw text, không có `blocks` | Chưa có trong `supportsBlockFormat` | Thêm bước 2.2 |
| `meta.status = "error"` | AI không trả JSON hợp lệ | Kiểm tra output block đã append; xem log raw response |
| Điểm vượt `scale` | Rubric và config không khớp | Đồng bộ max band trong rubric với `scale` trong config |
| Queue không chạy prompt mới | Worker cache code cũ | `php artisan queue:restart` |

---

## Tóm tắt luồng dữ liệu

```
1. EMS POST /task/all { prompt_id, topic, essay, ... }
2. ApiHubController::taskAll → dispatch HubTaskJob
3. CommonAiHubOther::gradeEssay → PromptXxx::gradeEssayTreeJson
4. OpenAI trả JSON trung gian
5. AiHubGradingResult::buildEmsPayload → normalize
6. Lưu DB (openai_response) + CommonHubTask::resultWritingTask → EMS
7. FE đọc từ EMS → render blocks
```
