✨ 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:
+29
-207
@@ -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); ?>&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 ?>");
|
||||
|
||||
Reference in New Issue
Block a user