Files
OJ2/apps/web/src/oj/problem/components/SubmissionResult.vue
T
xuyueandClaude Opus 5 954797a9a5
Deploy / deploy (push) Canceled after 0s
feat(AI 提示): 提示分级(L0 反问 / L1 定位 / L2 概念)与输出后过滤
提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是
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>
2026-09-21 19:03:21 -06:00

401 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>