feat(llm): 引入基于完成度的 AI 提交指导与限流

- 新增 llm_guidance 策略层,根据源码、判题结果与通过率评估完成度分级
- llm-review.php 改为服务端缓冲上游输出,仅下发白名单分类的固定模板
- 新增 llm_review 与 llm_review_rate_limit 表,实现结果缓存与账户/提交两级限流
- ceinfo 模板渲染编译器标出的源码行号/列号,reinfo 仅向高完成度提交展示结构化 diff
- download.php 按完成度授权测试点下载,封堵白卷提交套取隐藏用例
- judge_client 在 ACM 非比赛场景下记录已通过测试点数,前端可显示进度
- 附 llm_guidance_policy_test 单测覆盖空白卷、注释卷、隐藏 SPJ、OJ_FULL_DIFF 等关键路径
This commit is contained in:
2026-07-23 10:47:50 +08:00
parent 5a27c07bba
commit 12bea84d8b
12 changed files with 1721 additions and 768 deletions
+92 -117
View File
@@ -13,7 +13,8 @@ if(!isset($_SESSION[$OJ_NAME.'_'.'user_id'])){
exit(0);
}
require_once("./include/const.inc.php");
require_once("./include/const.inc.php");
require_once("./include/llm_guidance.inc.php");
if(!isset($_GET['sid'])){
echo "No such code!\n";
@@ -57,10 +58,18 @@ $isAC = $row['result']==4 ;
$mark=$row['pass_rate']*100;
if($isAC) $mark=100;
if((isset($_SESSION[$OJ_NAME.'_'.'user_id']) && $row && ($row['user_id']==$_SESSION[$OJ_NAME.'_'.'user_id']))||isset($_SESSION[$OJ_NAME.'_'.'source_browser']))
{
$ok = true;
}
if((isset($_SESSION[$OJ_NAME.'_'.'user_id']) && $row && (strval($row['user_id'])===strval($_SESSION[$OJ_NAME.'_'.'user_id'])))||isset($_SESSION[$OJ_NAME.'_'.'source_browser']))
{
$ok = true;
}
// Compile Error has its own page with compiler diagnostics and source-line
// annotations. Redirect after the ownership check so submit-page polling and
// status-page clicks end up on the same safe CE view.
if ($ok && intval($row['result']) === 11) {
header("Location: ceinfo.php?sid=" . intval($id));
exit(0);
}
$spj=pdo_query("select spj from problem where problem_id=?",$row['problem_id']);
if(!empty($spj)&&$spj[0][0]==2 && $OJ_HIDE_RIGHT_ANSWER && !isset($_SESSION[$OJ_NAME.'_'.'source_browser']) ){
$view_errors = "<h1>$MSG_MARK:$mark</h1><br>";
@@ -111,46 +120,27 @@ function parse_diff_blocks($text) {
$blocks = array();
if (empty($text)) return $blocks;
// 先按 "\n\n" 切分(simple mode 每个测试点之间有空行)
$chunks = preg_split('/\n\s*\n/', $text);
foreach ($chunks as $chunk) {
$chunk = trim($chunk);
if ($chunk === '') continue;
// 先按 "\n\n" 切分(simple mode 每个测试点之间有空行)
$chunks = preg_split('/\n\s*\n/', $text);
foreach ($chunks as $chunk) {
if (trim($chunk) === '') continue;
$chunk = trim($chunk, "\r\n");
// 格式 1: 整段就是一个 =====[name]===== 块(包含表头+数据+结尾 ====)
// 形如 "========[test1.out]========\nExpected | Yours\nFail | Fall\n=============================="
if (preg_match('/^=+\[([^\]]+)\]\=+(.+?)\n=+\s*$/s', $chunk, $m)) {
$name = trim($m[1]);
$body = $m[2];
// body 形如 "\nExpected | Yours\nFail | Fall"
if (preg_match('/^=+\[([^\]]+)\]\=+(.+?)\n=+\s*$/s', $chunk, $m)) {
$name = trim($m[1]);
$body = $m[2];
if (llm_guidance_testcase_base($name) === null) continue;
// OJ_FULL_DIFF also contains hidden test input. It is never interpreted
// as an Expected/Yours block; normal users fall back to focused guidance.
if (llm_guidance_has_full_diff_sections($chunk)) continue;
// body 形如 "\nExpected | Yours\nFail | Fall"
// 去掉首尾的换行符
$body = trim($body, "\n\r");
$lines_in_body = explode("\n", $body);
$expected = array();
$yours = array();
$found_header = false;
foreach ($lines_in_body as $bl) {
$bl = rtrim($bl);
if ($bl === '') continue;
// 跳过表头
if (!$found_header && preg_match('/^Expected.*\|.*Yours/i', $bl)) {
$found_header = true;
continue;
}
if (!$found_header && (preg_match('/^\|?--\s*\|/', $bl) || trim($bl) === '--')) {
continue;
}
$parsed = parse_one_diff_line($bl);
if ($parsed !== null) {
$expected[] = $parsed[0];
$yours[] = $parsed[1];
$found_header = true;
} else {
$expected[] = $bl;
$yours[] = '';
$found_header = true;
}
}
$rows = llm_guidance_parse_diff_rows($body);
$expected = $rows['expected'];
$yours = $rows['yours'];
if (!empty($expected)) {
$blocks[] = array(
'name' => $name,
@@ -164,33 +154,12 @@ function parse_diff_blocks($text) {
// 格式 2: simple mode "test1.out\n--\n|Expected|Yours\n|--|--\n|row1|row2"
if (preg_match('/^([^\n=|]+)\n--\n([\s\S]+)$/', $chunk, $m)) {
$name = trim($m[1]);
$body = $m[2];
$lines_in_body = explode("\n", $body);
$expected = array();
$yours = array();
$found_header = false;
foreach ($lines_in_body as $bl) {
$bl = rtrim($bl);
if ($bl === '') continue;
if (!$found_header && preg_match('/Expected.*\|.*Yours/i', $bl)) {
$found_header = true;
continue;
}
if (!$found_header && (preg_match('/^\|?--\s*\|/', $bl) || trim($bl) === '--')) {
continue;
}
$parsed = parse_one_diff_line($bl);
if ($parsed !== null) {
$expected[] = $parsed[0];
$yours[] = $parsed[1];
$found_header = true;
} else {
$expected[] = $bl;
$yours[] = '';
$found_header = true;
}
}
$name = trim($m[1]);
$body = $m[2];
if (llm_guidance_testcase_base($name) === null) continue;
$rows = llm_guidance_parse_diff_rows($body);
$expected = $rows['expected'];
$yours = $rows['yours'];
if (!empty($expected)) {
$blocks[] = array(
'name' => $name,
@@ -204,44 +173,7 @@ function parse_diff_blocks($text) {
return $blocks;
}
/**
* 解析单行 "expected | yours" 格式
* 支持 "|a|b"、"a | b"、"a|b" 等
* 返回 [left, right] 或 null(无法解析)
*/
function parse_one_diff_line($line) {
$line = rtrim($line);
if ($line === '') return null;
// 格式 A: "|a|b" (首尾带 |)
if ($line[0] === '|') {
$rest = substr($line, 1);
$pos = strpos($rest, '|');
if ($pos !== false) {
return array(substr($rest, 0, $pos), substr($rest, $pos + 1));
}
}
// 格式 B: "a | b" 或 "a | b"(带空格的 markdown 风格)
// 注意:可能行内有空格,所以用 " | " 或 " | " 等宽松匹配
if (preg_match('/^(.*?)\s+\|\s+(.*)$/', $line, $m)) {
// 确保左右两边都非空
$left = trim($m[1]);
$right = trim($m[2]);
if ($left !== '' || $right !== '') {
return array($left, $right);
}
}
// 格式 C: "a<b" 或 "a>b"diff -y 风格)
if (preg_match('/^(.*?)\s+([<>])\s+(.*)$/', $line, $m)) {
return array($m[1], $m[3]);
}
return null;
}
$view_reinfo = "";
$view_reinfo = "";
$view_reinfo_raw = ""; // 原始(管理员/有权限者看)
$view_reinfo_summary = ""; // 折叠后(普通用户看)
$view_reinfo_dedup_count = 0; // 折叠掉的测试点数
@@ -251,7 +183,7 @@ $verdict_tip = "";
if($ok){
if($row['user_id']!=$_SESSION[$OJ_NAME.'_'.'user_id']){
if(strval($row['user_id'])!==strval($_SESSION[$OJ_NAME.'_'.'user_id'])){
$view_mail_link= "<a href='mail.php?to_user=".htmlentities($row['user_id'],ENT_QUOTES,"UTF-8")."&title=$MSG_SUBMIT $id'>Mail the auther</a>";
}
@@ -344,17 +276,60 @@ if($ok){
$view_reinfo = $view_reinfo_summary;
// 尝试解析为结构化的 diff 数据(用于 WA/PE/AC 时的对比展示)
$diff_blocks = parse_diff_blocks($raw_error);
// 管理员或 source_browser 永远看完整原文
$show_raw = $is_admin_session || $is_source_browser;
if ($show_raw) {
$view_reinfo = $view_reinfo_raw;
} else {
$view_reinfo = $view_reinfo_summary;
}
}
$show_raw = $is_admin_session || $is_source_browser;
$unsafe_full_diff = llm_guidance_has_full_diff_sections($raw_error);
// 尝试解析为结构化的 diff 数据(用于高完成度 WA/PE 的对比展示)。
// Full diff 同时携带隐藏输入,普通页面不解析也不回显。
$diff_blocks = $unsafe_full_diff ? array() : parse_diff_blocks($raw_error);
// 完成度只使用学生自己的源码和判题统计;不查询参考答案。
$source_result = pdo_query("SELECT `source` FROM `source_code_user` WHERE `solution_id`=?", $id);
$student_source = (!empty($source_result) && $source_result !== -1)
? $source_result[0]['source']
: "";
$llm_guidance = llm_guidance_assess($student_source, $result_code, floatval($row['pass_rate']));
$llm_guidance = llm_guidance_refine_with_output_attempt(
$llm_guidance,
$result_code,
llm_guidance_output_attempt($unsafe_full_diff ? '' : $raw_error)
);
if ($llm_guidance['show_output_diff'] && empty($diff_blocks)) {
$llm_guidance['mode'] = 'focused_hint';
$llm_guidance['show_output_diff'] = false;
}
$llm_guidance_auto_fetch = true;
// 管理员或 source_browser 永远看完整原文。普通学生的 WA/PE
// 只看经过策略授权的结构化 diff,不再从原始折叠区旁路读取。
if (!$show_raw && ($result_code === 5 || $result_code === 6)) {
if ($llm_guidance['show_output_diff'] && !empty($diff_blocks)) {
$view_reinfo_summary = "已在下方显示本次错误测试点的结构化输出差异。";
} else {
// 低/中完成度不能通过重复白卷提交套取隐藏测试点答案。
$diff_blocks = array();
$view_reinfo_summary = "当前提交完成度不足,暂不显示隐藏测试点的期望输出。请先补齐程序,再重新提交。";
}
}
if ($show_raw) {
$view_reinfo = $view_reinfo_raw;
} else {
$view_reinfo = $view_reinfo_summary;
}
$llm_guidance_has_output_diff = !empty($diff_blocks);
$is_owner_session = isset($_SESSION[$OJ_NAME.'_'.'user_id'])
&& strval($row['user_id']) === strval($_SESSION[$OJ_NAME.'_'.'user_id']);
$download_hidden_spj = !empty($spj)
&& intval($spj[0][0]) === 2
&& !empty($OJ_HIDE_RIGHT_ANSWER);
$download_diff_enabled = !isset($OJ_SHOW_DIFF) || $OJ_SHOW_DIFF;
$llm_guidance_download_allowed = $is_admin_session
|| ($is_owner_session
&& $download_diff_enabled
&& !$download_hidden_spj
&& !$unsafe_full_diff
&& !empty($llm_guidance['show_output_diff']));
}
// 不是本人的提交,且不是 source_browser
else{