feat: 新增 LLM AI 错误点评功能,支持通过 OpenAI 兼容 API 对提交代码进行智能分析
This commit is contained in:
@@ -145,6 +145,15 @@ static $OJ_LOG_TRACE_ENABLED=false;
|
|||||||
static $OJ_SaaS_ENABLE=false;
|
static $OJ_SaaS_ENABLE=false;
|
||||||
static $OJ_MENU_NEWS=true;
|
static $OJ_MENU_NEWS=true;
|
||||||
|
|
||||||
|
/* LLM AI Review - AI错误点评功能 */
|
||||||
|
static $OJ_LLM_ENABLED=false; // 总开关,设为true开启AI点评
|
||||||
|
static $OJ_LLM_API_URL="https://api.openai.com/v1/chat/completions"; // API地址(兼容OpenAI格式)
|
||||||
|
static $OJ_LLM_API_KEY=""; // 填入你的API Key
|
||||||
|
static $OJ_LLM_MODEL="gpt-4o-mini"; // 模型名称
|
||||||
|
static $OJ_LLM_MAX_TOKENS=1024; // 最大输出token数
|
||||||
|
static $OJ_LLM_TIMEOUT=30; // 请求超时(秒)
|
||||||
|
static $OJ_LLM_SYSTEM_PROMPT=""; // 自定义系统prompt,留空使用内置默认
|
||||||
|
|
||||||
require_once(dirname(__FILE__) . "/pdo.php");
|
require_once(dirname(__FILE__) . "/pdo.php");
|
||||||
require_once(dirname(__FILE__) . "/init.php");
|
require_once(dirname(__FILE__) . "/init.php");
|
||||||
|
|
||||||
|
|||||||
212
web/llm-review.php
Normal file
212
web/llm-review.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* LLM AI Review - AI错误点评
|
||||||
|
* 接收 solution_id,收集上下文信息,调用LLM API返回启发式点评
|
||||||
|
* 结果缓存到 llm_review 表,同一 solution_id 只调用一次 LLM
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
|
||||||
|
// 检查功能是否开启
|
||||||
|
if (!isset($OJ_LLM_ENABLED) || !$OJ_LLM_ENABLED) {
|
||||||
|
echo json_encode(["error" => "AI点评功能未开启"], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查登录
|
||||||
|
if (!isset($_SESSION[$OJ_NAME . '_user_id'])) {
|
||||||
|
echo json_encode(["error" => "请先登录"], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 solution_id
|
||||||
|
if (!isset($_GET['sid'])) {
|
||||||
|
echo json_encode(["error" => "缺少参数 sid"], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$sid = intval($_GET['sid']);
|
||||||
|
if ($sid <= 0) {
|
||||||
|
echo json_encode(["error" => "无效的 solution_id"], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 1. 检查缓存 ----
|
||||||
|
$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);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. 查询 solution 信息 ----
|
||||||
|
$solution = pdo_query("SELECT * FROM `solution` WHERE `solution_id`=?", $sid);
|
||||||
|
if (empty($solution)) {
|
||||||
|
echo json_encode(["error" => "找不到该提交记录"], JSON_UNESCAPED_UNICODE);
|
||||||
|
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) {
|
||||||
|
echo json_encode(["error" => "找不到该题目"], JSON_UNESCAPED_UNICODE);
|
||||||
|
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 {
|
||||||
|
// 运行时/WA 等
|
||||||
|
$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.openai.com/v1/chat/completions";
|
||||||
|
$api_key = isset($OJ_LLM_API_KEY) ? $OJ_LLM_API_KEY : "";
|
||||||
|
$model = isset($OJ_LLM_MODEL) ? $OJ_LLM_MODEL : "gpt-4o-mini";
|
||||||
|
$max_tokens = isset($OJ_LLM_MAX_TOKENS) ? intval($OJ_LLM_MAX_TOKENS) : 1024;
|
||||||
|
$timeout = isset($OJ_LLM_TIMEOUT) ? intval($OJ_LLM_TIMEOUT) : 30;
|
||||||
|
|
||||||
|
if (empty($api_key)) {
|
||||||
|
echo json_encode(["error" => "API Key未配置"], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request_body = json_encode([
|
||||||
|
"model" => $model,
|
||||||
|
"messages" => [
|
||||||
|
["role" => "system", "content" => $system_prompt],
|
||||||
|
["role" => "user", "content" => $user_prompt]
|
||||||
|
],
|
||||||
|
"max_tokens" => $max_tokens,
|
||||||
|
"temperature" => 0.7
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init($api_url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $request_body,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"Authorization: Bearer " . $api_key
|
||||||
|
],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = 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);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($http_code !== 200) {
|
||||||
|
echo json_encode(["error" => "API请求失败 (HTTP $http_code)"], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resp_data = json_decode($response, true);
|
||||||
|
if (!$resp_data || !isset($resp_data['choices'][0]['message']['content'])) {
|
||||||
|
echo json_encode(["error" => "API返回格式异常"], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$review_text = $resp_data['choices'][0]['message']['content'];
|
||||||
|
|
||||||
|
// ---- 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;");
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -177,5 +177,80 @@
|
|||||||
|
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
|
|
||||||
|
<?php if(isset($OJ_LLM_ENABLED) && $OJ_LLM_ENABLED){ ?>
|
||||||
|
<div style="margin-top: 20px; margin-bottom: 14px; ">
|
||||||
|
<div class="ui styled fluid accordion" id="llm-review-accordion">
|
||||||
|
<div class="title" style="font-size: 1.1em; font-weight: bold; ">
|
||||||
|
<i class="dropdown icon"></i> 🤖 AI 点评
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div id="llm-review-area">
|
||||||
|
<button class="ui labeled icon blue button" id="llm-review-btn" onclick="fetchLLMReview()">
|
||||||
|
<i class="magic icon"></i> 获取 AI 点评
|
||||||
|
</button>
|
||||||
|
<div id="llm-review-loading" style="display:none; margin-top: 15px; ">
|
||||||
|
<div class="ui active centered inline text loader">AI 正在分析你的代码,请稍候...</div>
|
||||||
|
</div>
|
||||||
|
<div id="llm-review-result" style="display:none; margin-top: 15px; ">
|
||||||
|
<div class="ui existing segment" id="llm-review-content"></div>
|
||||||
|
</div>
|
||||||
|
<div id="llm-review-error" style="display:none; margin-top: 15px; ">
|
||||||
|
<div class="ui negative message">
|
||||||
|
<p id="llm-review-error-msg"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function(){
|
||||||
|
$('#llm-review-accordion').accordion();
|
||||||
|
});
|
||||||
|
|
||||||
|
function fetchLLMReview(){
|
||||||
|
var btn = $('#llm-review-btn');
|
||||||
|
btn.prop('disabled', true).addClass('loading');
|
||||||
|
$('#llm-review-loading').show();
|
||||||
|
$('#llm-review-error').hide();
|
||||||
|
$('#llm-review-result').hide();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: 'llm-review.php?sid=<?php echo intval($id); ?>',
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 60000,
|
||||||
|
success: function(data){
|
||||||
|
btn.removeClass('loading');
|
||||||
|
$('#llm-review-loading').hide();
|
||||||
|
if(data.error){
|
||||||
|
$('#llm-review-error-msg').text(data.error);
|
||||||
|
$('#llm-review-error').show();
|
||||||
|
btn.prop('disabled', false);
|
||||||
|
} else if(data.review){
|
||||||
|
$('#llm-review-result').show();
|
||||||
|
var container = document.getElementById('llm-review-content');
|
||||||
|
// 先以纯文本放入,再用 Vditor 渲染为 Markdown HTML
|
||||||
|
container.textContent = data.review;
|
||||||
|
if(typeof HustOJVditor !== 'undefined' && HustOJVditor.renderMarkdownBlocks){
|
||||||
|
HustOJVditor.renderMarkdownBlocks('#llm-review-content', {
|
||||||
|
useTextContent: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
btn.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr, status){
|
||||||
|
btn.removeClass('loading').prop('disabled', false);
|
||||||
|
$('#llm-review-loading').hide();
|
||||||
|
var msg = '请求失败';
|
||||||
|
if(status === 'timeout') msg = '请求超时,请稍后重试';
|
||||||
|
$('#llm-review-error-msg').text(msg);
|
||||||
|
$('#llm-review-error').show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<?php } ?>
|
||||||
|
|
||||||
<?php include("template/$OJ_TEMPLATE/footer.php");?>
|
<?php include("template/$OJ_TEMPLATE/footer.php");?>
|
||||||
|
|||||||
Reference in New Issue
Block a user