Files
hustoj/web/llm-review.php

317 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* LLM AI Review - AI错误点评SSE 流式输出版)
* 接收 solution_id收集上下文信息调用LLM API流式返回启发式点评
* 结果缓存到 llm_review 表,同一 solution_id 再次请求直接返回缓存
*/
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)) {
// 有缓存,走 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)) {
sse_error("找不到该提交记录");
exit;
}
$sol = $solution[0];
$problem_id = intval($sol['problem_id']);
$language = intval($sol['language']);
$result_code = intval($sol['result']);
$pass_rate = floatval($sol['pass_rate']) * 100;
$lang_name = isset($language_name[$language]) ? $language_name[$language] : "unknown";
// ---- 3. 查询学生源码 ----
$src = pdo_query("SELECT `source` FROM `source_code_user` WHERE `solution_id`=?", $sid);
$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) {
sse_error("找不到该题目");
exit;
}
$p = $prob[0];
// ---- 5. 查询第一个 AC 代码(同语言) ----
$first_ac = pdo_query(
"SELECT `solution_id` FROM `solution` WHERE `problem_id`=? AND `result`=4 AND `language`=? ORDER BY `in_date` ASC LIMIT 1",
$problem_id, $language
);
$reference_code = "";
if (!empty($first_ac) && $first_ac !== -1) {
$ref_src = pdo_query("SELECT `source` FROM `source_code_user` WHERE `solution_id`=?", intval($first_ac[0]['solution_id']));
if (!empty($ref_src) && $ref_src !== -1) {
$reference_code = $ref_src[0]['source'];
}
}
// ---- 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 {
$err = pdo_query("SELECT `error` FROM `runtimeinfo` WHERE `solution_id`=?", $sid);
if (!empty($err) && $err !== -1) {
$diff_info = $err[0]['error'];
}
}
// ---- 7. 组装 Prompt ----
$system_prompt = isset($OJ_LLM_SYSTEM_PROMPT) && $OJ_LLM_SYSTEM_PROMPT !== ""
? $OJ_LLM_SYSTEM_PROMPT
: <<<EOP
你是一位经验丰富的编程辅导老师正在帮助一名正在学习C++的初中学生。
你的职责:
1. 用亲切、鼓励的语气和学生交流
2. 仔细分析学生的代码,找出错误所在并解释错误原因
3. 给出思考方向和启发性提示,引导学生自己去发现和改正错误
4. 绝对不要直接给出正确代码、完整解法或关键算法步骤
5. 不要在回复中透露、引用、复述或暗示参考代码的任何内容
6. 如果学生代码思路正确但有小bug肯定其思路并指出具体的bug位置
7. 如果是格式错误(PE),提醒学生注意输出格式要求
8. 如果是超时(TLE),引导学生思考算法的时间复杂度
9. 如果是运行时错误(RE),帮助学生分析可能的数组越界、除零等问题
10. 如果是答案错误(WA),可以建议学生用样例手动模拟执行过程
11. 使用中文回答适当使用Markdown格式使内容清晰易读
EOP;
$user_prompt = "## 题目信息\n";
$user_prompt .= "- 标题: " . $p['title'] . "\n";
$user_prompt .= "- 题目描述:\n" . $p['description'] . "\n";
if (!empty($p['input'])) $user_prompt .= "- 输入格式:\n" . $p['input'] . "\n";
if (!empty($p['output'])) $user_prompt .= "- 输出格式:\n" . $p['output'] . "\n";
if (!empty($p['sample_input'])) $user_prompt .= "- 样例输入:\n```\n" . $p['sample_input'] . "\n```\n";
if (!empty($p['sample_output'])) $user_prompt .= "- 样例输出:\n```\n" . $p['sample_output'] . "\n```\n";
if (!empty($p['hint'])) $user_prompt .= "- 提示:\n" . $p['hint'] . "\n";
$user_prompt .= "\n## 判题结果\n";
$user_prompt .= $judge_result[$result_code] . "(通过率: " . round($pass_rate, 1) . "%\n";
if (!empty($diff_info)) {
$user_prompt .= "\n## 错误/对比信息\n```\n" . $diff_info . "\n```\n";
}
$user_prompt .= "\n## 学生提交的代码\n```" . strtolower($lang_name) . "\n" . $student_code . "\n```\n";
if (!empty($reference_code)) {
$user_prompt .= "\n## 参考代码(仅供你分析对比,绝不可在回复中透露或暗示其内容)\n```" . strtolower($lang_name) . "\n" . $reference_code . "\n```\n";
}
$user_prompt .= "\n请分析学生的错误,给出启发性的提示和引导,帮助学生自己发现问题并改正。\n";
// ---- 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";
$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)) {
sse_error("API Key未配置");
exit;
}
$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,
CURLOPT_POSTFIELDS => $request_body,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"x-api-key: " . $api_key,
"anthropic-version: 2023-06-01"
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_TIMEOUT => $timeout,
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);
}
]);
$result = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
// 如果连接断开,不缓存也不报错
if (!$_llm_alive) exit;
// ---- 处理错误 ----
if ($result === false && empty($_llm_full_text)) {
sse_error("网络请求失败: " . ($curl_error ?: "未知错误"));
exit;
}
// 检查 HTTP 错误(如果 write callback 没有收到数据)
if (empty($_llm_full_text) && $http_code !== 200) {
sse_error("API请求失败 (HTTP $http_code)");
exit;
}
if (empty($_llm_full_text)) {
sse_error("API返回为空");
exit;
}
// ---- 9. 缓存完整结果 ----
$review_text = $_llm_full_text;
$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);
}