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
+29 -207
View File
@@ -93,34 +93,6 @@
box-shadow: none;
}
/* AI 点评区 */
#llm-section {
margin-top: 20px;
margin-bottom: 18px;
}
#llm-section .ui.header {
margin-top: 0;
}
#llm-section .llm-section-header {
transition: background-color 0.15s;
padding: 4px 8px;
margin-left: -8px;
margin-right: -8px;
border-radius: 4px;
}
#llm-section .llm-section-header:hover {
background-color: rgba(33,150,243,0.06);
}
#llm-section .llm-toggle-icon {
transition: transform 0.2s;
color: #888;
}
#llm-review-result {
word-break: break-word;
}
/* 错误解释列表(由 JS explain() 填充) - 已弃用,由 AI 点评替代 */
/* 自动刷新状态 */
i.icon.spinning {
animation: spin 1.2s linear infinite;
@@ -138,6 +110,10 @@ i.icon.spinning {
#diff-section {
margin-bottom: 18px;
}
#diff-section.guidance-diff-open {
border-top: 3px solid rgba(33, 133, 208, 0.35);
padding-top: 14px;
}
.diff-card {
margin-bottom: 14px;
border-left: 4px solid #fbbd08;
@@ -157,6 +133,10 @@ i.icon.spinning {
font-family: 'Fira Mono', 'Cascadia Code', Consolas, monospace;
font-size: 0.95em;
}
.diff-download {
margin-left: auto;
font-weight: 400;
}
.diff-table {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -266,164 +246,10 @@ i.icon.spinning {
</div>
<!-- 3. AI 点评区(标题可点击折叠/展开) -->
<?php if (isset($OJ_LLM_ENABLED) && $OJ_LLM_ENABLED) { ?>
<div class="ui raised segment" id="llm-section">
<h3 class="ui header llm-section-header" id="llm-toggle" style="cursor: pointer; user-select: none;">
<i class="magic icon"></i>
<div class="content">
AI 智能点评
<div class="sub header"><?php echo isset($MSG_AI_REVIEW_DESC) ? $MSG_AI_REVIEW_DESC : '基于你的代码和判题结果分析'; ?></div>
</div>
<i class="dropdown icon llm-toggle-icon" style="margin-left: auto; font-size: 1em;"></i>
</h3>
<div id="llm-section-body">
<button class="ui labeled icon primary button" id="llm-review-btn" onclick="fetchLLMReview()">
<i class="magic icon"></i> 获取 AI 点评
</button>
<div id="llm-review-loading" class="ui active inline text loader" style="display:none; margin-left: 1em;">
AI 正在分析你的代码,请稍候...
</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>
<script>
// 折叠/展开 AI 点评区
(function(){
var header = document.getElementById('llm-toggle');
var body = document.getElementById('llm-section-body');
var icon = header ? header.querySelector('.llm-toggle-icon') : null;
if (!header || !body) return;
header.addEventListener('click', function(e){
// 如果用户点击的是按钮本身,不要触发展开(按钮事件自己处理)
if (e.target.closest('button')) return;
var collapsed = body.style.display === 'none';
body.style.display = collapsed ? '' : 'none';
if (icon) {
icon.className = collapsed
? 'dropdown icon llm-toggle-icon'
: 'dropdown icon llm-toggle-icon active';
// active class 让 Semantic UI 的箭头自动旋转 90°,但我们手写也行
icon.style.transform = collapsed ? 'rotate(0deg)' : 'rotate(-90deg)';
}
});
})();
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();
var fullText = "";
var container = document.getElementById('llm-review-content');
var resultDiv = document.getElementById('llm-review-result');
var reader = null;
var decoder = new TextDecoder();
var sseBuffer = "";
function processSSE(text) {
sseBuffer += text;
var lines = sseBuffer.split('\n');
sseBuffer = lines.pop();
var currentEvent = "";
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.indexOf('event: ') === 0) {
currentEvent = line.substring(7);
} else if (line.indexOf('data: ') === 0) {
var dataStr = line.substring(6);
try {
var data = JSON.parse(dataStr);
if (currentEvent === 'chunk') {
fullText += data.text;
container.textContent = fullText;
resultDiv.style.display = 'block';
} else if (currentEvent === 'cached') {
fullText = data.text;
container.textContent = fullText;
resultDiv.style.display = 'block';
} else if (currentEvent === 'done') {
finishReview();
} else if (currentEvent === 'error') {
showError(data.message || '未知错误');
}
} catch(e) {}
currentEvent = "";
}
}
}
function finishReview() {
btn.removeClass('loading').hide();
document.getElementById('llm-review-loading').style.display = 'none';
resultDiv.style.display = 'block';
if (fullText && typeof HustOJVditor !== 'undefined' && HustOJVditor.renderMarkdownBlocks) {
HustOJVditor.renderMarkdownBlocks('#llm-review-content', { useTextContent: true });
}
}
function showError(msg) {
btn.removeClass('loading').prop('disabled', false);
document.getElementById('llm-review-loading').style.display = 'none';
document.getElementById('llm-review-error-msg').textContent = msg;
document.getElementById('llm-review-error').style.display = 'block';
}
if (typeof fetch === 'undefined') {
showError('浏览器不支持,请升级浏览器');
return;
}
fetch('llm-review.php?sid=<?php echo intval($id); ?>')
.then(function(response) {
if (!response.ok) {
return response.text().then(function(text) {
try {
var d = JSON.parse(text);
showError(d.error || '请求失败 (HTTP ' + response.status + ')');
} catch(e) {
showError('请求失败 (HTTP ' + response.status + ')');
}
});
}
reader = response.body.getReader();
function read() {
reader.read().then(function(result) {
if (result.done) {
if (fullText) finishReview();
return;
}
var text = decoder.decode(result.value, { stream: true });
processSSE(text);
read();
}).catch(function(err) {
if (fullText) {
finishReview();
} else {
showError('读取响应失败: ' + err.message);
}
});
}
read();
})
.catch(function(err) {
showError('网络请求失败: ' + err.message);
});
}
</script>
<?php } ?>
<!-- 3. AI 指导:仅错误终态渲染,并自动展开/请求一次 -->
<?php if (isset($OJ_LLM_ENABLED) && $OJ_LLM_ENABLED) {
require("template/$OJ_TEMPLATE/llm-guidance.php");
} ?>
<!-- 3.5. 对比详情(WA/PE 时显示,diff 视图) -->
<?php if (!empty($diff_blocks) && is_array($diff_blocks)): ?>
@@ -440,6 +266,18 @@ i.icon.spinning {
<div class="diff-header">
<i class="file outline icon"></i>
<span class="diff-name"><?php echo htmlspecialchars($block['name'], ENT_QUOTES, 'UTF-8'); ?></span>
<?php
$download_base = llm_guidance_testcase_base($block['name']);
$download_allowed = isset($OJ_DOWNLOAD) && $OJ_DOWNLOAD
&& !empty($llm_guidance_download_allowed)
&& $download_base !== null
&& (!(isset($OJ_DL_1ST_WA_ONLY) && $OJ_DL_1ST_WA_ONLY) || $idx === 0);
if ($download_allowed):
?>
<a class="diff-download" href="download.php?sid=<?php echo intval($id); ?>&amp;name=<?php echo rawurlencode($download_base . '.out'); ?>">
<i class="download icon"></i>下载该测试点
</a>
<?php endif; ?>
</div>
<div class="diff-table">
<div class="diff-side diff-expected">
@@ -552,6 +390,11 @@ i.icon.spinning {
getSource: function (element) {
var pre = element.querySelector ? element.querySelector('pre') : null;
return pre ? pre.textContent : element.textContent || '';
},
previewOptions: {
markdown: {
sanitize: true
}
}
}).then(function () {
for(let i=1;i<10;i++){
@@ -581,27 +424,6 @@ i.icon.spinning {
"background-color": "#2185d088",
"text-align": "center"
});
<?php
if(isset($OJ_DOWNLOAD) && $OJ_DOWNLOAD){
if(isset($OJ_DL_1ST_WA_ONLY) && $OJ_DL_1ST_WA_ONLY){
?>
let down=$($("#errtxt").find("h2")[0]);
let filename=down.text();
down.html("<a href='download.php?sid=<?php echo $id?>&name=" + filename+ "'>" + filename+ "</a>");
<?php
}else{
?>
$("#errtxt").find("h2").each(function(){
let down=$(this);
let filename=down.text();
console.log(filename);
down.html("<a href='download.php?sid=<?php echo $id?>&name=" + filename+ "'>" + filename+ "</a>");
});
<?php
}
}
?>
$("th").each(function(){
let html=$(this).html();
html=html.replace("Expected","<?php echo $MSG_EXPECTED ?>");