feat(llm): 启用AI点评功能并新增AI聊天API接口,自动创建相关数据库表

This commit is contained in:
jieer
2026-05-29 17:16:26 +08:00
parent d60458ad35
commit 88706f5d78
7 changed files with 1033 additions and 80 deletions

View File

@@ -1,50 +1,89 @@
<?php
/**
* LLM AI Review - AI错误点评
* 接收 solution_id收集上下文信息调用LLM API返回启发式点评
* 结果缓存到 llm_review 表,同一 solution_id 只调用一次 LLM
* LLM AI Review - AI错误点评SSE 流式输出版)
* 接收 solution_id收集上下文信息调用LLM API流式返回启发式点评
* 结果缓存到 llm_review 表,同一 solution_id 再次请求直接返回缓存
*/
header("Content-Type: application/json; charset=utf-8");
header("Cache-Control: no-cache, must-revalidate");
require_once('./include/db_info.inc.php');
require_once('./include/setlang.php');
require_once('./include/const.inc.php');
// 检查功能是否开启
// ---- 辅助函数SSE 事件输出 ----
function sse_send($event, $data) {
echo "event: $event\ndata: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
@ob_flush();
@flush();
}
function sse_error($msg) {
sse_send("error", ["message" => $msg]);
}
// ---- 基础校验 ----
if (!isset($OJ_LLM_ENABLED) || !$OJ_LLM_ENABLED) {
// 返回 JSON非 SSE因为前端可能用 $.ajax 调用
header("Content-Type: application/json; charset=utf-8");
echo json_encode(["error" => "AI点评功能未开启"], JSON_UNESCAPED_UNICODE);
exit;
}
// 检查登录
session_start();
if (!isset($_SESSION[$OJ_NAME . '_user_id'])) {
header("Content-Type: application/json; charset=utf-8");
echo json_encode(["error" => "请先登录"], JSON_UNESCAPED_UNICODE);
exit;
}
// 获取 solution_id
if (!isset($_GET['sid'])) {
header("Content-Type: application/json; charset=utf-8");
echo json_encode(["error" => "缺少参数 sid"], JSON_UNESCAPED_UNICODE);
exit;
}
$sid = intval($_GET['sid']);
if ($sid <= 0) {
header("Content-Type: application/json; charset=utf-8");
echo json_encode(["error" => "无效的 solution_id"], JSON_UNESCAPED_UNICODE);
exit;
}
// ---- 1. 检查缓存 ----
// 自动建表(首次调用时)
pdo_query("CREATE TABLE IF NOT EXISTS `llm_review` (
`solution_id` INT NOT NULL,
`review` TEXT NOT NULL,
`create_time` DATETIME NOT NULL,
PRIMARY KEY (`solution_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
$cache = pdo_query("SELECT `review` FROM `llm_review` WHERE `solution_id`=?", $sid);
if ($cache !== -1 && !empty($cache)) {
echo json_encode(["review" => $cache[0]['review']], JSON_UNESCAPED_UNICODE);
// 有缓存,走 SSE 立刻返回(前端统一用 SSE 接收)
header("Content-Type: text/event-stream; charset=utf-8");
header("Cache-Control: no-cache");
header("X-Accel-Buffering: no");
sse_send("cached", ["text" => $cache[0]['review']]);
sse_send("done", []);
exit;
}
// ---- 设置 SSE 响应头 ----
header("Content-Type: text/event-stream; charset=utf-8");
header("Cache-Control: no-cache");
header("X-Accel-Buffering: no");
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
@ini_set('zlib.output_compression', 0);
@ini_set('implicit_flush', 1);
while (ob_get_level()) ob_end_flush();
// ---- 2. 查询 solution 信息 ----
$solution = pdo_query("SELECT * FROM `solution` WHERE `solution_id`=?", $sid);
if (empty($solution)) {
echo json_encode(["error" => "找不到该提交记录"], JSON_UNESCAPED_UNICODE);
sse_error("找不到该提交记录");
exit;
}
$sol = $solution[0];
@@ -61,7 +100,7 @@ $student_code = (!empty($src) && $src !== -1) ? $src[0]['source'] : "";
// ---- 4. 查询题目信息 ----
$prob = pdo_query("SELECT `title`,`description`,`input`,`output`,`sample_input`,`sample_output`,`hint` FROM `problem` WHERE `problem_id`=?", $problem_id);
if (empty($prob) || $prob === -1) {
echo json_encode(["error" => "找不到该题目"], JSON_UNESCAPED_UNICODE);
sse_error("找不到该题目");
exit;
}
$p = $prob[0];
@@ -82,13 +121,11 @@ if (!empty($first_ac) && $first_ac !== -1) {
// ---- 6. 查询 diff/error 信息 ----
$diff_info = "";
if ($result_code == 11) {
// 编译错误
$err = pdo_query("SELECT `error` FROM `compileinfo` WHERE `solution_id`=?", $sid);
if (!empty($err) && $err !== -1) {
$diff_info = $err[0]['error'];
}
} else {
// 运行时/WA 等
$err = pdo_query("SELECT `error` FROM `runtimeinfo` WHERE `solution_id`=?", $sid);
if (!empty($err) && $err !== -1) {
$diff_info = $err[0]['error'];
@@ -139,7 +176,7 @@ if (!empty($reference_code)) {
$user_prompt .= "\n请分析学生的错误,给出启发性的提示和引导,帮助学生自己发现问题并改正。\n";
// ---- 8. 调用 LLM API ----
// ---- 8. 流式调用 LLM API ----
$api_url = isset($OJ_LLM_API_URL) ? $OJ_LLM_API_URL : "https://api.minimaxi.com/anthropic/v1/messages";
$api_key = isset($OJ_LLM_API_KEY) ? $OJ_LLM_API_KEY : "";
$model = isset($OJ_LLM_MODEL) ? $OJ_LLM_MODEL : "MiniMax-M2.5";
@@ -147,21 +184,33 @@ $max_tokens = isset($OJ_LLM_MAX_TOKENS) ? intval($OJ_LLM_MAX_TOKENS) : 1024;
$timeout = isset($OJ_LLM_TIMEOUT) ? intval($OJ_LLM_TIMEOUT) : 60;
if (empty($api_key)) {
echo json_encode(["error" => "API Key未配置"], JSON_UNESCAPED_UNICODE);
sse_error("API Key未配置");
exit;
}
// ---- Anthropic API 兼容格式 ----
$request_body = json_encode([
"model" => $model,
"max_tokens" => $max_tokens,
"system" => $system_prompt,
"temperature" => 1.0,
"stream" => true,
"messages" => [
["role" => "user", "content" => $user_prompt]
]
]);
// ---- 流式 SSE 转发 ----
// 追踪连接状态和累积文本
$GLOBALS['_llm_alive'] = true;
$GLOBALS['_llm_full_text'] = "";
$GLOBALS['_llm_buffer'] = "";
$GLOBALS['_llm_sent_text'] = ""; // 已发送给前端的文本(用于增量比较)
// 检测客户端断开
register_shutdown_function(function() {
$GLOBALS['_llm_alive'] = false;
});
$ch = curl_init($api_url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
@@ -171,51 +220,97 @@ curl_setopt_array($ch, [
"x-api-key: " . $api_key,
"anthropic-version: 2023-06-01"
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_WRITEFUNCTION => function($ch, $chunk) use (&$_llm_alive, &$_llm_full_text, &$_llm_buffer) {
if (!$_llm_alive) return 0;
$_llm_buffer .= $chunk;
// 按行解析 SSE
while (($pos = strpos($_llm_buffer, "\n")) !== false) {
$line = substr($_llm_buffer, 0, $pos);
$_llm_buffer = substr($_llm_buffer, $pos + 1);
$line = trim($line);
if (empty($line)) continue;
// 解析 data: 行
if (strncmp($line, "data: ", 6) === 0) {
$json_str = substr($line, 6);
if ($json_str === "[DONE]") continue;
$data = json_decode($json_str, true);
if (!$data) continue;
$type = isset($data['type']) ? $data['type'] : '';
// content_block_delta: 增量文本
if ($type === 'content_block_delta' && isset($data['delta']['text'])) {
$_llm_full_text .= $data['delta']['text'];
sse_send("chunk", ["text" => $data['delta']['text']]);
// message_delta: 消息完成
} elseif ($type === 'message_delta' && isset($data['delta']['stop_reason'])) {
if ($data['delta']['stop_reason'] === 'end_turn' ||
$data['delta']['stop_reason'] === 'max_tokens') {
sse_send("done", []);
return 0; // 停止 curl
}
// message_start / ping: 忽略
} elseif ($type === 'message_start' || $type === 'ping') {
continue;
// content_block_start / stop: 忽略
} elseif ($type === 'content_block_start' || $type === 'content_block_stop') {
continue;
// 错误事件
} elseif ($type === 'error') {
$msg = isset($data['error']['message']) ? $data['error']['message'] : '未知错误';
sse_send("error", ["message" => "API错误: " . $msg]);
return 0;
}
}
}
return strlen($chunk);
}
]);
$response = curl_exec($ch);
$result = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
if ($response === false) {
echo json_encode(["error" => "网络请求失败: " . $curl_error], JSON_UNESCAPED_UNICODE);
// 如果连接断开,不缓存也不报错
if (!$_llm_alive) exit;
// ---- 处理错误 ----
if ($result === false && empty($_llm_full_text)) {
sse_error("网络请求失败: " . ($curl_error ?: "未知错误"));
exit;
}
if ($http_code !== 200) {
echo json_encode(["error" => "API请求失败 (HTTP $http_code)"], JSON_UNESCAPED_UNICODE);
// 检查 HTTP 错误(如果 write callback 没有收到数据)
if (empty($_llm_full_text) && $http_code !== 200) {
sse_error("API请求失败 (HTTP $http_code)");
exit;
}
$resp_data = json_decode($response, true);
// Anthropic 响应格式: content 是一个数组,取 type=text 的块
$review_text = "";
if ($resp_data && isset($resp_data['content']) && is_array($resp_data['content'])) {
foreach ($resp_data['content'] as $block) {
if (isset($block['type']) && $block['type'] === 'text' && isset($block['text'])) {
$review_text .= $block['text'];
}
}
}
if (empty($review_text)) {
echo json_encode(["error" => "API返回格式异常"], JSON_UNESCAPED_UNICODE);
if (empty($_llm_full_text)) {
sse_error("API返回为空");
exit;
}
// ---- 9. 缓存结果 ----
// 自动建表(首次调用时)
pdo_query("CREATE TABLE IF NOT EXISTS `llm_review` (
`solution_id` INT NOT NULL,
`review` TEXT NOT NULL,
`create_time` DATETIME NOT NULL,
PRIMARY KEY (`solution_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
// ---- 9. 缓存完整结果 ----
$review_text = $_llm_full_text;
pdo_query("INSERT INTO `llm_review` (`solution_id`, `review`, `create_time`) VALUES (?, ?, NOW())", $sid, $review_text);
// ---- 10. 返回结果 ----
echo json_encode(["review" => $review_text], JSON_UNESCAPED_UNICODE);
$insert_ok = pdo_query("INSERT INTO `llm_review` (`solution_id`, `review`, `create_time`) VALUES (?, ?, NOW())", $sid, $review_text);
if ($insert_ok === -1) {
// 插入失败(可能已有记录),尝试更新
pdo_query("UPDATE `llm_review` SET `review`=?, `create_time`=NOW() WHERE `solution_id`=?", $review_text, $sid);
}