1. 12:12 13/08/2026
CHUỖI THỰC HÀNH: CHATBOT AI HỎI ĐÁP ĐẤT ĐAI — PHP 8.x + MySQL 9.x
Lưu ý trách nhiệm: chatbot chỉ tổng hợp thông tin tham khảo từ nguồn do quản trị viên phê duyệt. Không dùng để kết luận quyền sử dụng đất, tranh chấp, quy hoạch hoặc nghĩa vụ tài chính cụ thể. Không nhập CCCD, số sổ, ảnh giấy tờ, địa chỉ/tọa độ chính xác hay dữ liệu cá nhân vào chat.
# 06/10 — AI client: khóa mẫu, fake mode và timeout
Ta tách cuộc gọi AI vào một lớp duy nhất. Nhờ vậy website có thể chạy bằng `AI_FAKE_MODE=true` mà không cần API key thật; khi đổi nhà cung cấp, bạn chỉ chỉnh adapter/cấu hình, không chạm router hay database. Ví dụ dùng endpoint dạng `/chat/completions` phổ biến; hãy đọc tài liệu chính thức của nhà cung cấp bạn chọn để khớp tên model và response.
## 1. Nhắc lại cấu hình khóa — chỉ là ví dụ
Trong `.env.example` hoặc tài liệu hướng dẫn, luôn để dạng sau:
```dotenv
# KHÔNG phải khóa thật. Đừng thay chuỗi này trong source code.
AI_API_KEY=YOUR_AI_API_KEY_HERE
AI_FAKE_MODE=true
AI_BASE_URL=https://api.example.invalid/v1
AI_MODEL=your-model-name
```
Khi lên production, người vận hành tự tạo `.env` riêng và thay `YOUR_AI_API_KEY_HERE` bằng khóa của họ trên máy chủ. `.env` nằm trong `.gitignore`. Không ghi lệnh `var_dump($_ENV)`, không đưa header Authorization vào log, không gửi khóa lên frontend. Khi `AI_FAKE_MODE=true`, `AI_API_KEY` có thể giữ nguyên nhãn mẫu.
## 2. Tạo `app/AiClient.php`
```php
<?php
declare(strict_types=1);
final class AiClient
{
/** @return array{answer:string, model:string} */
public function answer(string $systemPrompt, string $userPrompt): array
{
if (Config::bool('AI_FAKE_MODE', true)) {
return [
'answer' => "[Chế độ mô phỏng] Hệ thống đã tìm thấy nguồn tham khảo. Hãy đặt AI_FAKE_MODE=false và cấu hình khóa tại .env trên máy chủ để nhận câu trả lời AI thực tế.",
'model' => 'fake-mode',
];
}
$apiKey = Config::require('AI_API_KEY');
if ($apiKey === 'YOUR_AI_API_KEY_HERE' || strlen($apiKey) < 12) {
throw new RuntimeException('AI chưa được cấu hình: thay nhãn khóa mẫu trong .env trên máy chủ.');
}
$url = rtrim(Config::require('AI_BASE_URL'), '/') . '/chat/completions';
$payload = [
'model' => Config::require('AI_MODEL'),
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
'temperature' => 0.1,
'max_tokens' => 700,
];
$curl = curl_init($url);
if ($curl === false) throw new RuntimeException('Không khởi tạo được HTTP client.');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => max(5, min(60, (int)Config::get('AI_TIMEOUT_SECONDS', '25'))),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$raw = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if (!is_string($raw) || $status < 200 || $status >= 300) {
// Không log raw response vì có thể chứa dữ liệu nhạy cảm hoặc header từ proxy.
error_log("AI request failed: HTTP={$status}; curl=" . ($error !== '' ? $error : 'n/a'));
throw new RuntimeException('Dịch vụ AI đang tạm thời không phản hồi.');
}
try {
$data = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
$answer = $data['choices'][0]['message']['content'] ?? null;
} catch (Throwable) {
throw new RuntimeException('Dịch vụ AI trả về dữ liệu không hợp lệ.');
}
if (!is_string($answer) || trim($answer) === '') throw new RuntimeException('Dịch vụ AI không có nội dung trả lời.');
return ['answer' => trim($answer), 'model' => (string)($data['model'] ?? 'unknown')];
}
}
```
## 3. Nạp class
Thêm dòng này vào cuối `app/Bootstrap.php`:
```php
require __DIR__ . '/KnowledgeService.php';
require __DIR__ . '/AiClient.php';
```
Các điểm cố ý trong code: timeout ngắn, giới hạn token, nhiệt độ thấp, không lộ raw API response, và không có khóa bí mật nào nằm trong mã. Bài 07 sẽ ghép KnowledgeService + AiClient thành ChatService có guardrail, rate limit và lưu lịch sử tối thiểu.