✨ 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:
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
$llm_guidance = isset($llm_guidance) && is_array($llm_guidance)
|
||||
? $llm_guidance
|
||||
: array('mode' => 'hidden', 'level' => 'none', 'score' => 0, 'auto_fetch' => false);
|
||||
$llm_guidance_visible = $llm_guidance['mode'] !== 'hidden';
|
||||
$llm_guidance_auto_fetch = isset($llm_guidance_auto_fetch)
|
||||
? !!$llm_guidance_auto_fetch
|
||||
: !empty($llm_guidance['auto_fetch']);
|
||||
$llm_guidance_label = llm_guidance_mode_label($llm_guidance['mode']);
|
||||
?>
|
||||
|
||||
<?php if ($llm_guidance_visible): ?>
|
||||
<style>
|
||||
#llm-section {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 18px;
|
||||
border-left: 4px solid #2185d0;
|
||||
}
|
||||
#llm-section .llm-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.15s;
|
||||
padding: 4px 8px;
|
||||
margin: -4px -8px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#llm-section .llm-section-header:hover { background-color: rgba(33,150,243,0.06); }
|
||||
#llm-section .llm-toggle-icon { margin-left: auto !important; transition: transform 0.2s; color: #888; }
|
||||
#llm-section .llm-guidance-meta { margin: 10px 0 4px; }
|
||||
#llm-section .llm-guidance-meta .label { margin-bottom: 4px; }
|
||||
#llm-review-result { word-break: break-word; }
|
||||
#llm-review-content > :first-child { margin-top: 0; }
|
||||
#llm-review-content > :last-child { margin-bottom: 0; }
|
||||
</style>
|
||||
|
||||
<div class="ui raised segment" id="llm-section"
|
||||
data-guidance-mode="<?php echo htmlspecialchars($llm_guidance['mode'], ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-completion-level="<?php echo htmlspecialchars($llm_guidance['level'], ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-completion-score="<?php echo intval($llm_guidance['score']); ?>">
|
||||
<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" id="llm-mode-description"><?php echo htmlspecialchars($llm_guidance_label, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
</div>
|
||||
<i class="dropdown icon llm-toggle-icon"></i>
|
||||
</h3>
|
||||
|
||||
<div id="llm-section-body">
|
||||
<div class="ui tiny labels llm-guidance-meta">
|
||||
<span class="ui blue basic label" id="llm-mode-label"><?php echo htmlspecialchars($llm_guidance_label, ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
<span class="ui basic label" id="llm-completion-label">完成度 <?php echo intval($llm_guidance['score']); ?>%</span>
|
||||
</div>
|
||||
|
||||
<div id="llm-review-loading" class="ui active inline text loader" style="margin:12px 0;">
|
||||
正在定位最值得检查的一处...
|
||||
</div>
|
||||
<div id="llm-review-result" style="display:none; margin-top:12px;">
|
||||
<div class="ui existing segment" id="llm-review-content"></div>
|
||||
</div>
|
||||
<div id="llm-review-error" style="display:none; margin-top:12px;">
|
||||
<div class="ui negative message">
|
||||
<p id="llm-review-error-msg"></p>
|
||||
<button type="button" class="ui small button" id="llm-review-retry">重新获取</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var section = document.getElementById('llm-section');
|
||||
var header = document.getElementById('llm-toggle');
|
||||
var body = document.getElementById('llm-section-body');
|
||||
var icon = header ? header.querySelector('.llm-toggle-icon') : null;
|
||||
var started = false;
|
||||
var finished = false;
|
||||
var currentEvent = '';
|
||||
|
||||
if (!section || !header || !body) return;
|
||||
|
||||
function setCollapsed(collapsed) {
|
||||
body.style.display = collapsed ? 'none' : '';
|
||||
if (icon) icon.style.transform = collapsed ? 'rotate(-90deg)' : 'rotate(0deg)';
|
||||
}
|
||||
|
||||
header.addEventListener('click', function(){
|
||||
setCollapsed(body.style.display !== 'none');
|
||||
});
|
||||
|
||||
function modeLabel(mode) {
|
||||
var labels = {
|
||||
flowchart: '流程图引导',
|
||||
focused_hint: '聚焦排错',
|
||||
output_diff: '输出差异定位',
|
||||
compile_location: '编译错误定位'
|
||||
};
|
||||
return labels[mode] || 'AI 指导';
|
||||
}
|
||||
|
||||
function applyMeta(data) {
|
||||
if (!data) return;
|
||||
var label = modeLabel(data.mode);
|
||||
section.setAttribute('data-guidance-mode', data.mode || '');
|
||||
document.getElementById('llm-mode-label').textContent = label;
|
||||
document.getElementById('llm-mode-description').textContent = label;
|
||||
document.getElementById('llm-completion-label').textContent = '完成度 ' + parseInt(data.completion_score || 0, 10) + '%';
|
||||
|
||||
var diff = document.getElementById('diff-section');
|
||||
if (diff && data.show_output_diff) diff.classList.add('guidance-diff-open');
|
||||
}
|
||||
|
||||
function renderResult(fullText) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
var container = document.getElementById('llm-review-content');
|
||||
var result = document.getElementById('llm-review-result');
|
||||
document.getElementById('llm-review-loading').style.display = 'none';
|
||||
container.textContent = fullText;
|
||||
result.style.display = 'block';
|
||||
if (fullText && typeof HustOJVditor !== 'undefined' && HustOJVditor.renderMarkdownBlocks) {
|
||||
HustOJVditor.renderMarkdownBlocks('#llm-review-content', {
|
||||
useTextContent: true,
|
||||
previewOptions: { markdown: { sanitize: true } }
|
||||
}).catch(function(error){
|
||||
console.error('Failed to render AI guidance.', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
if (finished) return;
|
||||
started = false;
|
||||
document.getElementById('llm-review-loading').style.display = 'none';
|
||||
document.getElementById('llm-review-error-msg').textContent = message || '获取指导失败';
|
||||
document.getElementById('llm-review-error').style.display = 'block';
|
||||
}
|
||||
|
||||
function fetchGuidance() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
finished = false;
|
||||
currentEvent = '';
|
||||
setCollapsed(false);
|
||||
document.getElementById('llm-review-loading').style.display = '';
|
||||
document.getElementById('llm-review-error').style.display = 'none';
|
||||
document.getElementById('llm-review-result').style.display = 'none';
|
||||
|
||||
var fullText = '';
|
||||
var sseBuffer = '';
|
||||
var decoder = null;
|
||||
|
||||
function processSSE(text) {
|
||||
sseBuffer += text;
|
||||
var lines = sseBuffer.split('\n');
|
||||
sseBuffer = lines.pop();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].replace(/\r$/, '');
|
||||
if (line.indexOf('event: ') === 0) {
|
||||
currentEvent = line.substring(7);
|
||||
} else if (line.indexOf('data: ') === 0) {
|
||||
try {
|
||||
var data = JSON.parse(line.substring(6));
|
||||
if (currentEvent === 'meta') {
|
||||
applyMeta(data);
|
||||
} else if (currentEvent === 'chunk') {
|
||||
fullText += data.text || '';
|
||||
} else if (currentEvent === 'cached') {
|
||||
fullText = data.text || '';
|
||||
} else if (currentEvent === 'done') {
|
||||
renderResult(fullText);
|
||||
} else if (currentEvent === 'error') {
|
||||
showError(data.message || '获取指导失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Invalid AI guidance event.', error);
|
||||
}
|
||||
currentEvent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof fetch === 'undefined' || typeof TextDecoder === 'undefined') {
|
||||
showError('浏览器版本过旧,请升级后重试');
|
||||
return;
|
||||
}
|
||||
decoder = new TextDecoder();
|
||||
|
||||
fetch('llm-review.php?sid=<?php echo intval($id); ?><?php
|
||||
if (isset($llm_guidance_has_output_diff)) {
|
||||
echo '&structured_diff=' . ($llm_guidance_has_output_diff ? '1' : '0');
|
||||
}
|
||||
?>', { credentials: 'same-origin' })
|
||||
.then(function(response){
|
||||
if (!response.ok) {
|
||||
return response.text().then(function(text){
|
||||
var message = '请求失败 (HTTP ' + response.status + ')';
|
||||
try {
|
||||
var data = JSON.parse(text);
|
||||
if (data && data.error) message = data.error;
|
||||
} catch (ignore) {}
|
||||
throw new Error(message);
|
||||
});
|
||||
}
|
||||
if (!response.body || !response.body.getReader) throw new Error('浏览器不支持流式响应');
|
||||
var reader = response.body.getReader();
|
||||
|
||||
function readNext() {
|
||||
return reader.read().then(function(result){
|
||||
if (result.done) {
|
||||
var tail = decoder.decode();
|
||||
if (tail || sseBuffer) processSSE(tail + '\n');
|
||||
if (!finished && fullText) {
|
||||
renderResult(fullText);
|
||||
} else if (!finished) {
|
||||
showError('响应提前结束,请重试');
|
||||
}
|
||||
return;
|
||||
}
|
||||
processSSE(decoder.decode(result.value, { stream: true }));
|
||||
return readNext();
|
||||
});
|
||||
}
|
||||
return readNext();
|
||||
})
|
||||
.catch(function(error){ showError(error.message || '网络请求失败'); });
|
||||
}
|
||||
|
||||
document.getElementById('llm-review-retry').addEventListener('click', fetchGuidance);
|
||||
window.fetchLLMReview = fetchGuidance;
|
||||
|
||||
<?php if ($llm_guidance_auto_fetch): ?>
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', fetchGuidance, { once: true });
|
||||
} else {
|
||||
fetchGuidance();
|
||||
}
|
||||
<?php else: ?>
|
||||
document.getElementById('llm-review-loading').style.display = 'none';
|
||||
<?php endif; ?>
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
Reference in New Issue
Block a user