Deploy / deploy (push) Canceled after 0s
提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是 ai_hint.level 的历史,当前等级 = 这道题上(最近一次 AC 之后)给过的最高一级。 阶梯(services/hint-level.ts) - 只有学生点「再多一点提示」才升级(请求带 more),不带就按当前等级再生成一次 - 升一级要先再交一次:锚点是「这一级是**什么时候**开出来的」,也就是这一级最早那条 提示的 ai_hint.create_time,必须有比这个时刻更新的提交才准 +1。锚点不能用提示所在 那条提交的时间 —— 端点谁的提交 id 都认(只校验归属),拿一条老提交去要提示,锚点 就退回到那条老提交的时间,连点两下 more 就能从 L0 爬到 L2,一次新提交都不用交 - AC 之后清零;编译失败自成一档(level = -1),既不消耗也不推进阶梯 - HINT_MIN_FAILURES 3 → 1:门槛的活由阶梯接走了,第一次失败只开放 L0,而 L0 只反问、 什么都不泄露,拦着它没有意义 - canEscalate 由后端算好在 done 事件里给,前端不自己推阶梯 输出后过滤(services/hint-filter.ts) - 「不要给代码」写在 prompt 里只是软约束,模型忍不住一次就把这一级的意义废掉了。 所以整段生成、过滤通过才推给前端,逐字显示改由前端模拟 —— 边流式边过滤做不到, 发现违规时内容已经在学生屏幕上了 - 判定只用客观、低误报的信号:代码块、过长的行内代码、整行不含中文的类代码行、 和标准答案重合 3 行以上、L0 一句问句都没有 - 违规就重生成一次,只重一次,再不过发写死的兜底话术。重试措辞按档分叉:编译档本来 就允许给片段,对它说「不要出现任何代码」等于用阶梯的标准把这一档也砍了 - 两次都留痕(filter_attempt / filter_blocked / filter_reason),7.5 的输出过滤触发率 就是从这三列出来的 services/ai.ts 加 streamWhole:事件形状和 streamChat 一样,前端不分叉。produce 期间 每 15 秒发一行 SSE 注释当心跳 —— 这条流中间有一大段静默(诊断 20s + 生成 60s + 重生成 60s,最坏 140 秒),而 NPM / nginx 的 proxy_read_timeout 默认 60 秒,超了学生 看到「请求失败」,后端却还在烧第二次调用,那条提示照样落库、照样把等级推上去。 prompt 版本另开 3 / 4(阶梯上每一级都换了 system),编译档仍走 1 / 2 的单段式基线, 两批数据不混在一起。迁移 0021 给 ai_hint 加四列,都可空、不带默认值,已有的行留 null 表示「分级上线前」。 实跑 - 阶梯:在 dev 库上用真实行驱动 decideHintLevel。正常路径 S1→S2→S3 走出 L0→L1→L2, 同级连点 more 不升,AC 之后回 L0,编译档给 L-1 且不推进阶梯。把旧锚点规则复刻出来 跑同一组数据做对照:只拿最老那条提交反复 POST,旧规则 L0→L1→L2(零新提交), 新规则钉死在 L0,正常路径两者行为一致 - 过滤:起假 AI 服务端走完整条链路。L1 摊平代码→重生成后合规(attempt 2 / 未拦), L0 两次都甩代码块→兜底话术(blocked,reason 两条相连),编译档抄标程→命中「和标准 答案重合 3 行」且追加的是分叉后的措辞 - streamWhole:produce 拖 16.5 秒收到 1 条心跳;同一份流喂给前端 consumeJSONEventStream 只解析出 delta + done(注释行被静默跳过,前端零改动);中途 cancel 断开后 produce 跑完不抛 - api / web typecheck、check:routes、fmt 全过 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
401 lines
13 KiB
Vue
401 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { Icon } from "@iconify/vue"
|
||
import { useThemeVars } from "naive-ui"
|
||
import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract"
|
||
import type { JudgeCaseResult } from "@oj2/contract"
|
||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||
import {
|
||
submissionCaseResults,
|
||
submissionMemoryFormat,
|
||
submissionPartialCases,
|
||
submissionResultTitle,
|
||
submissionTimeFormat,
|
||
} from "utils/functions"
|
||
import type { Submission } from "utils/types"
|
||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||
import { useProblemStore } from "oj/store/problem"
|
||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||
import { submitHintFeedback } from "oj/api"
|
||
import { MdPreview } from "md-editor-v3"
|
||
import "md-editor-v3/lib/preview.css"
|
||
import { useDark } from "@vueuse/core"
|
||
|
||
const props = defineProps<{
|
||
submission?: Submission
|
||
}>()
|
||
|
||
const isDark = useDark()
|
||
const problemStore = useProblemStore()
|
||
const theme = useThemeVars()
|
||
|
||
// AI 提示状态。
|
||
// hintTarget 是后端发来的全文,hintContent 是已经"打"出来的那一截 ——
|
||
// 后端从 2c 起整段生成、过滤通过才推(设计 2.6),一次就把全文发过来,
|
||
// 逐字显示改在这边模拟。
|
||
const hintTarget = ref("")
|
||
const hintContent = ref("")
|
||
const hintLoading = ref(false)
|
||
const hintError = ref("")
|
||
// 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮
|
||
const hintId = ref<number | null>(null)
|
||
// 这条提示是哪一级(-1 = 编译错误那一档,不在阶梯上),以及还能不能再往上要一级。
|
||
// 两个都由后端算好在 done 里给,前端不自己推阶梯
|
||
const hintLevel = ref<number | null>(null)
|
||
const hintCanEscalate = ref(false)
|
||
const hintHelpful = ref<boolean | null>(null)
|
||
const hintFeedbackSending = ref(false)
|
||
|
||
// 打字机:每 24ms 吐 3 个字,约 125 字/秒。步子不敢迈太小 ——
|
||
// 每一帧都要让 MdPreview 重渲染一次 Markdown,机房那批机器扛不住逐字
|
||
const TYPE_STEP = 3
|
||
const TYPE_INTERVAL = 24
|
||
let typingTimer: ReturnType<typeof setInterval> | null = null
|
||
const hintTyping = computed(
|
||
() => hintContent.value.length < hintTarget.value.length,
|
||
)
|
||
|
||
function stopTyping() {
|
||
if (typingTimer === null) return
|
||
clearInterval(typingTimer)
|
||
typingTimer = null
|
||
}
|
||
|
||
function startTyping() {
|
||
if (typingTimer !== null) return
|
||
typingTimer = setInterval(() => {
|
||
if (!hintTyping.value) {
|
||
stopTyping()
|
||
return
|
||
}
|
||
hintContent.value = hintTarget.value.slice(
|
||
0,
|
||
hintContent.value.length + TYPE_STEP,
|
||
)
|
||
}, TYPE_INTERVAL)
|
||
}
|
||
|
||
function resetHint() {
|
||
stopTyping()
|
||
hintTarget.value = ""
|
||
hintContent.value = ""
|
||
hintError.value = ""
|
||
hintId.value = null
|
||
hintLevel.value = null
|
||
hintCanEscalate.value = false
|
||
hintHelpful.value = null
|
||
}
|
||
|
||
onUnmounted(stopTyping)
|
||
|
||
// 错误信息格式化
|
||
const msg = computed(() => {
|
||
if (!props.submission) return ""
|
||
|
||
let msg = ""
|
||
const result = props.submission.result
|
||
|
||
// 编译错误或运行时错误时给出提示;
|
||
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
|
||
if (
|
||
(result === SubmissionStatus.compile_error ||
|
||
result === SubmissionStatus.runtime_error) &&
|
||
props.submission.language !== "SQL"
|
||
) {
|
||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||
}
|
||
|
||
if (
|
||
result !== SubmissionStatus.ast_check_failed &&
|
||
props.submission.statisticInfo?.err_info
|
||
) {
|
||
msg += props.submission.statisticInfo.err_info
|
||
}
|
||
|
||
return msg
|
||
})
|
||
|
||
// 部分测试点通过时的进度。学生拿不到 info,那张测试点表格只有管理员看得见,
|
||
// 这是学生这边唯一能看出「比上次多过了几个点」的地方。
|
||
const partialCases = computed(() => submissionPartialCases(props.submission))
|
||
|
||
// 是否显示AI提示区域。
|
||
// 阈值和后端 POST /ai/hint 共用契约里的 HINT_MIN_FAILURES,别在这里写死数字;
|
||
// 编译失败不数次数,和后端同口径(理由见 HINT_MIN_FAILURES 的注释);
|
||
// failCount 现在含服务端下发的历史失败数,刷新页面不会把进度清掉。
|
||
// system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析
|
||
// 只会瞎编一通,后端的失败计数同样不认这个状态。
|
||
const showAIHint = computed(() => {
|
||
if (!props.submission) return false
|
||
// 比赛题不给提示,和「求助」按钮一致。用 problem.contestId 而不是路由参数:
|
||
// 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。
|
||
if (problemStore.problem?.contestId != null) return false
|
||
return (
|
||
(problemStore.failCount >= HINT_MIN_FAILURES ||
|
||
props.submission.result === SubmissionStatus.compile_error) &&
|
||
props.submission.result !== SubmissionStatus.accepted &&
|
||
props.submission.result !== SubmissionStatus.ast_check_failed &&
|
||
props.submission.result !== SubmissionStatus.system_error &&
|
||
props.submission.result !== SubmissionStatus.pending &&
|
||
props.submission.result !== SubmissionStatus.judging &&
|
||
props.submission.result !== SubmissionStatus.submitting
|
||
)
|
||
})
|
||
|
||
// 结果面板现在是 display-directive="show",关掉不再销毁组件,提示内容能留到重新打开。
|
||
// 代价是换了一次提交它也留着,所以这里按提交 id 手动清一次 —— 否则新结果底下挂着
|
||
// 上一次提交的提示,而且按钮已经被 v-if 藏了,学生没法重新分析。
|
||
watch(
|
||
() => props.submission?.id,
|
||
() => {
|
||
resetHint()
|
||
hintLoading.value = false
|
||
},
|
||
)
|
||
|
||
// more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看
|
||
// 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准
|
||
async function fetchHint(submissionId: string, more = false) {
|
||
hintLoading.value = true
|
||
resetHint()
|
||
|
||
try {
|
||
const response = await fetch("/api/ai/hint", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ submissionId, more }),
|
||
})
|
||
|
||
if (!response.ok) throw await aiStreamError(response)
|
||
|
||
await consumeJSONEventStream(response, {
|
||
onMessage: (data: {
|
||
type: string
|
||
content?: string
|
||
message?: string
|
||
hintId?: number | null
|
||
level?: number
|
||
canEscalate?: boolean
|
||
}) => {
|
||
if (data.type === "delta" && data.content) {
|
||
hintTarget.value += data.content
|
||
startTyping()
|
||
} else if (data.type === "done") {
|
||
hintId.value = data.hintId ?? null
|
||
hintLevel.value = data.level ?? null
|
||
hintCanEscalate.value = data.canEscalate === true
|
||
} else if (data.type === "error") {
|
||
hintError.value = data.message || "AI 提示生成失败"
|
||
}
|
||
},
|
||
})
|
||
} catch (e: any) {
|
||
hintError.value = e.message || "请求失败"
|
||
} finally {
|
||
hintLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 可以改票:点另一个就覆盖。失败了不打扰学生,按钮恢复原样就行 ——
|
||
// 评价是给我们看的,不值得为它弹一条报错
|
||
async function sendHintFeedback(helpful: boolean) {
|
||
if (hintId.value === null || hintFeedbackSending.value) return
|
||
if (hintHelpful.value === helpful) return
|
||
hintFeedbackSending.value = true
|
||
try {
|
||
await submitHintFeedback(hintId.value, helpful)
|
||
hintHelpful.value = helpful
|
||
} catch {
|
||
// 静默
|
||
} finally {
|
||
hintFeedbackSending.value = false
|
||
}
|
||
}
|
||
|
||
// 测试用例表格数据(只在部分通过时显示)
|
||
const infoTable = computed(() => {
|
||
const submission = props.submission
|
||
if (!submission) return []
|
||
const data = submissionCaseResults(submission.info)
|
||
if (!data.length) return []
|
||
|
||
const result = submission.result
|
||
// AC、编译错误、运行时错误不显示测试用例表格
|
||
if (
|
||
result === SubmissionStatus.accepted ||
|
||
result === SubmissionStatus.ast_check_failed ||
|
||
result === SubmissionStatus.compile_error ||
|
||
result === SubmissionStatus.runtime_error
|
||
) {
|
||
return []
|
||
}
|
||
|
||
// 只有存在失败的测试用例时才显示
|
||
return data.some((item) => item.result === 0) ? data : []
|
||
})
|
||
|
||
// 测试用例表格列配置
|
||
const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||
{ title: "测试用例", key: "test_case" },
|
||
{
|
||
title: "测试状态",
|
||
key: "result",
|
||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||
},
|
||
{
|
||
title: "占用内存",
|
||
key: "memory",
|
||
render: (row) => submissionMemoryFormat(row.memory),
|
||
},
|
||
{
|
||
title: "执行耗时",
|
||
key: "real_time",
|
||
render: (row) => submissionTimeFormat(row.real_time),
|
||
},
|
||
{ title: "信号", key: "signal" },
|
||
]
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="submission">
|
||
<n-alert
|
||
:type="JUDGE_STATUS[submission.result]['type']"
|
||
:title="submissionResultTitle(submission)"
|
||
class="mb-3"
|
||
>
|
||
<template v-if="partialCases" #default>
|
||
<n-progress
|
||
type="line"
|
||
status="success"
|
||
:percentage="(partialCases.passed / partialCases.total) * 100"
|
||
:show-indicator="false"
|
||
/>
|
||
</template>
|
||
</n-alert>
|
||
<n-flex
|
||
vertical
|
||
v-if="
|
||
msg || infoTable.length || submission.statisticInfo?.ast_results?.length
|
||
"
|
||
>
|
||
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
|
||
<n-flex vertical :size="8">
|
||
<n-flex
|
||
v-for="(rule, i) in submission.statisticInfo.ast_results"
|
||
:key="i"
|
||
align="center"
|
||
:size="6"
|
||
>
|
||
<n-icon
|
||
:color="rule.passed ? theme.successColor : theme.errorColor"
|
||
>
|
||
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
|
||
</n-icon>
|
||
<span>{{ rule.description }}</span>
|
||
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
|
||
<span
|
||
v-if="!rule.passed && rule.actual !== undefined"
|
||
:style="{ color: theme.errorColor }"
|
||
>
|
||
当前 {{ rule.actual }} 次
|
||
</span>
|
||
</n-flex>
|
||
</n-flex>
|
||
</n-card>
|
||
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
|
||
<n-data-table
|
||
v-if="infoTable.length"
|
||
striped
|
||
:data="infoTable"
|
||
:columns="columns"
|
||
/>
|
||
</n-flex>
|
||
|
||
<!-- AI 提示区域 -->
|
||
<template v-if="showAIHint">
|
||
<n-card size="small" style="margin-top: 12px; max-width: 480px">
|
||
<n-alert
|
||
v-if="hintError"
|
||
type="error"
|
||
:title="hintError"
|
||
class="mb-3"
|
||
/>
|
||
<n-button
|
||
v-if="!hintTarget && !hintLoading"
|
||
type="primary"
|
||
@click="fetchHint(submission.id)"
|
||
>
|
||
让 AI 分析我的代码
|
||
</n-button>
|
||
<n-spin v-else-if="hintLoading && !hintTarget" size="small" />
|
||
<MdPreview
|
||
v-if="hintContent"
|
||
:model-value="hintContent"
|
||
preview-theme="vuepress"
|
||
:theme="isDark ? 'dark' : 'light'"
|
||
/>
|
||
<!-- 等级和「再多一点提示」。按钮出不出由后端 done 里的 canEscalate 定,
|
||
前端不自己推阶梯(要再交一次才升得动,规则在 services/hint-level.ts) -->
|
||
<n-flex
|
||
v-if="hintLevel !== null && !hintLoading && !hintTyping"
|
||
align="center"
|
||
size="small"
|
||
style="margin-top: 8px"
|
||
>
|
||
<n-tag size="small" :bordered="false">
|
||
{{ hintLevelLabel(hintLevel) }}
|
||
</n-tag>
|
||
<n-button
|
||
v-if="hintCanEscalate"
|
||
size="tiny"
|
||
type="primary"
|
||
ghost
|
||
@click="fetchHint(submission.id, true)"
|
||
>
|
||
再多一点提示
|
||
</n-button>
|
||
</n-flex>
|
||
<n-flex
|
||
v-if="hintId !== null && !hintLoading && !hintTyping"
|
||
align="center"
|
||
size="small"
|
||
style="margin-top: 8px"
|
||
>
|
||
<n-text depth="3">这条提示对你有帮助吗?</n-text>
|
||
<n-button
|
||
size="tiny"
|
||
:type="hintHelpful === true ? 'primary' : 'default'"
|
||
:disabled="hintFeedbackSending"
|
||
@click="sendHintFeedback(true)"
|
||
>
|
||
有帮助
|
||
</n-button>
|
||
<n-button
|
||
size="tiny"
|
||
:type="hintHelpful === false ? 'warning' : 'default'"
|
||
:disabled="hintFeedbackSending"
|
||
@click="sendHintFeedback(false)"
|
||
>
|
||
没帮助
|
||
</n-button>
|
||
</n-flex>
|
||
</n-card>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.msg {
|
||
white-space: pre;
|
||
word-break: break-all;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.gradient-text {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
font-weight: bold;
|
||
}
|
||
</style>
|