fix(AI提示): 解锁条件前后端对齐,提示不再一点就没
Some checks failed
Deploy / deploy (push) Has been cancelled

「失败 3 次解锁 AI 提示」实际是「在当前这次页面会话里再失败 3 次」:
problemStore.failCount 是个从 0 起数的内存计数器,刷新、切题、跳进跳出就归零,
而后端闸门数的是数据库里的历史失败数,两边根本不是一回事。昨天在这题上撞了
十次墙的学生今天进来照样看不到按钮。

- 数法收成一个 countFailedSubmissions(),题目详情的 myFailedCount 和
  POST /ai/hint 共用。原来详情把「等待/正在评分」也算失败,连点三次提交就能
  把按钮点亮,点下去却回 hint-locked
- 阈值 3 挪进契约 HINT_MIN_FAILURES,两端引用同一个常量
- failCount 改成 myFailedCount + 本次会话增量;在题目页里登录的补拉一次详情,
  否则停在匿名时的 0
- 结果面板改 display-directive="show",不再一收起来就把流式输出中的提示连同
  那次 LLM 调用一起作废;补「上次结果」按钮,原来唯一的重开方式是再提交一次
- prompt 里的判题结果翻成中文,原来拼的是裸状态码,模型不知道 -1 是什么
- system_error 不计入失败数、也不显示按钮:判题机自己崩了不是学生的问题
- 比赛中不给提示,和「求助」按钮同一个口径

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC5uL72UY9aZFuTvUKe2jv
This commit is contained in:
2026-09-06 07:24:26 -06:00
parent 2e78b6efdf
commit bb0f5ec5ef
10 changed files with 172 additions and 34 deletions

View File

@@ -18,3 +18,40 @@ export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
export function isAccepted(result: number) {
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
}
/**
* 判题状态的中文名,和前端 `utils/constants.ts` 的 `JUDGE_STATUS` 一致,两边必须同步。
* 目前只用在喂给模型的 prompt 里 —— 原来那里拼的是裸状态码(`结果:-1`
* 模型根本不知道 -1 是「答案错误」还是别的什么,等于白给一条信息。
*/
export const JUDGE_STATUS_NAME: Record<number, string> = {
[JudgeStatus.COMPILE_ERROR]: "编译失败",
[JudgeStatus.WRONG_ANSWER]: "答案错误",
[JudgeStatus.ACCEPTED]: "答案正确",
[JudgeStatus.CPU_TIME_LIMIT_EXCEEDED]: "运行超时",
[JudgeStatus.REAL_TIME_LIMIT_EXCEEDED]: "运行超时",
[JudgeStatus.MEMORY_LIMIT_EXCEEDED]: "内存超限",
[JudgeStatus.RUNTIME_ERROR]: "运行时错误",
[JudgeStatus.SYSTEM_ERROR]: "系统错误",
[JudgeStatus.PENDING]: "等待评分",
[JudgeStatus.JUDGING]: "正在评分",
[JudgeStatus.PARTIALLY_ACCEPTED]: "部分正确",
[JudgeStatus.AST_CHECK_FAILED]: "答案正确,但语法未通过",
}
export function judgeStatusName(result: number) {
return JUDGE_STATUS_NAME[result] ?? `未知状态(${result})`
}
/**
* **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED那也是答案对了
* 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题,
* 不该推着 AI 提示的解锁进度往前走。
*/
export const NON_FAILURE_RESULTS: number[] = [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
JudgeStatus.PENDING,
JudgeStatus.JUDGING,
JudgeStatus.SYSTEM_ERROR,
]

View File

@@ -1,4 +1,5 @@
import {
HINT_MIN_FAILURES,
aiAnalysisRecordSchema,
aiAnalysisRequestSchema,
aiDetailSchema,
@@ -11,25 +12,23 @@ import {
solvedListSchema,
solvedProblemSchema,
} from "@oj2/contract"
import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, notInArray, sql } from "drizzle-orm"
import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm"
import { Hono, type Context } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { getPreviousLogin, type AuthUser } from "../auth/session"
import { config } from "../config"
import { db, schema } from "../db"
import { JudgeStatus } from "../judge/status"
import { JudgeStatus, judgeStatusName } from "../judge/status"
import { failure, success } from "../http"
import { completeChat, streamChat } from "../services/ai"
import { consumeToken } from "../services/throttling"
import { isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers"
import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers"
export const aiRoutes = new Hono<AppEnv>()
const accepted = [0, 10]
const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" }
/** 解锁 AI 提示所需的失败提交数,与前端 SubmissionResult.vue 的显示条件一致 */
const HINT_MIN_FAILURES = 3
/**
* 每次 AI 调用都过一遍令牌桶,复用 services/throttling 的那只桶capacity 20 / 0.03 每秒)。
@@ -495,15 +494,15 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(and(eq(schema.submission.id, parsed.data.submissionId), eq(schema.submission.userId, c.get("user")!.id))).limit(1)
if (!row) return failure(c, 404, "submission-not-found", "Submission not found")
// 失败次数在端点这边也要卡一道。前端那个 problemStore.failCount 是页面内的计数器
// 刷新就归零,直接 POST 更是完全绕开它 —— 不然这就是个不限次数的免费 LLM 接口
// 判题中的提交不算失败,否则连点几次提交就能提前解锁。
const [failed] = await db.select({ value: count() }).from(schema.submission).where(and(
eq(schema.submission.userId, c.get("user")!.id),
eq(schema.submission.problemId, row.submission.problemId),
notInArray(schema.submission.result, [...accepted, JudgeStatus.PENDING, JudgeStatus.JUDGING]),
))
if ((failed?.value ?? 0) < HINT_MIN_FAILURES) return failure(c, 403, "hint-locked", "Hint unlocks after 3 failed submissions")
// 比赛里不给 AI 提示,和「求助」按钮同一个口径。前端在比赛路由下压根不显示按钮
// 这里是防直接 POST 的那一道 —— 比赛只有 ACM 模式,提示等于变相放水
if (row.submission.contestId !== null) return failure(c, 403, "contest-hint-disabled", "Hint is disabled in contests")
// 失败次数在端点这边也要卡一道:直接 POST 完全绕开前端的显示条件 ——
// 不然这就是个不限次数的免费 LLM 接口。数法(判题中的不算、判题机自己崩的不算)
// 由 countFailedSubmissions 统一,题目详情的 myFailedCount 走的是同一个函数,
// 所以前端亮出按钮的时刻和这里放行的时刻严格对齐。
const failed = await countFailedSubmissions(c.get("user")!.id, row.submission.problemId)
if (failed < HINT_MIN_FAILURES) return failure(c, 403, "hint-locked", `Hint unlocks after ${HINT_MIN_FAILURES} failed submissions`)
const limited = await throttleAi(c)
if (limited) return limited
// 这里**不要**把 problem.answers 的参考答案放进 prompt。学生的代码本身就是 prompt 的
@@ -511,7 +510,7 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
// 写「不可透露」只是软约束,挡不住。题面预算从 500 提到 2000正好是参考答案让出来的那份
// 让模型靠题目要求 + 报错信息判断,入门题的常见错误够用了。
const system = "你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown不超过6句话。"
const prompt = `题目:${row.problem.title}\n描述${row.problem.description.slice(0, 2000)}\n语言${row.submission.language}\n结果${row.submission.result}\n错误${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码${row.submission.code.slice(0, 2000)}`
const prompt = `题目:${row.problem.title}\n描述${row.problem.description.slice(0, 2000)}\n语言${row.submission.language}\n结果${judgeStatusName(row.submission.result)}\n错误${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码${row.submission.code.slice(0, 2000)}`
return streamChat(system, prompt)
})

View File

@@ -5,7 +5,11 @@ import {
type SampleUser,
} from "@oj2/contract"
import { and, count, eq, notInArray } from "drizzle-orm"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { NON_FAILURE_RESULTS } from "../judge/status"
/**
* 用户对象的序列化层,对齐旧后端 `utils/api/_serializers.py` 的 `UsernameSerializer`。
@@ -108,3 +112,25 @@ export function rounded(value: number, digits = 2) {
const factor = 10 ** digits
return Math.round(value * factor) / factor
}
/**
* 这个用户在这道题上失败了几次 —— 也就是 AI 提示的解锁进度。
*
* 题目详情下发的 `myFailedCount` 和 `POST /ai/hint` 的服务端闸门必须用**同一个**口径,
* 所以两边都走这里。原来是各写各的:详情那边 `notInArray(result, [0, 10])` 把
* 等待评分 / 正在评分也算成失败,连点三次提交就能让按钮亮起来,而 hint 端点排掉了
* 这两个状态,于是按钮亮着、点下去回 `hint-locked`。
*/
export async function countFailedSubmissions(userId: number, problemId: number) {
const [failed] = await db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, userId),
eq(schema.submission.problemId, problemId),
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
),
)
return failed?.value ?? 0
}

View File

@@ -28,7 +28,7 @@ import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { objectValue as toObject, queryInteger, sampleUser } from "./helpers"
import { countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers"
export const problemRoutes = new Hono<AppEnv>()
@@ -277,17 +277,9 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
if (typeof problemStatus === "number") myStatus = problemStatus
const [failed] = await db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, row.problem.id),
notInArray(schema.submission.result, [0, 10]),
),
)
myFailedCount = failed?.value ?? 0
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
// 的服务端闸门一致,所以两边共用 countFailedSubmissions
myFailedCount = await countFailedSubmissions(user.id, row.problem.id)
}
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { HINT_MIN_FAILURES } from "@oj2/contract"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import {
submissionMemoryFormat,
@@ -54,19 +55,39 @@ const msg = computed(() => {
return msg
})
// 是否显示AI提示区域
// 是否显示AI提示区域
// 阈值和后端 POST /ai/hint 共用契约里的 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 >= 3 &&
problemStore.failCount >= HINT_MIN_FAILURES &&
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,
() => {
hintContent.value = ""
hintError.value = ""
hintLoading.value = false
},
)
async function fetchHint(submissionId: string) {
hintLoading.value = true
hintContent.value = ""

View File

@@ -172,6 +172,9 @@ async function submit() {
}
// ==================== 失败计数 ====================
// 这里只数本次会话的增量,历史失败数由 problem.myFailedCount 带进来。
// 排除的状态要和后端 judge/status.ts 的 NON_FAILURE_RESULTS 对齐,
// 尤其是 system_error —— 判题机自己崩了不该推进 AI 提示的解锁进度。
watch(
() => submission.value?.result,
(result) => {
@@ -184,7 +187,8 @@ watch(
return
if (
result !== SubmissionStatus.accepted &&
result !== SubmissionStatus.ast_check_failed
result !== SubmissionStatus.ast_check_failed &&
result !== SubmissionStatus.system_error
) {
problemStore.incrementFailCount()
}
@@ -228,9 +232,13 @@ watch(
</script>
<template>
<!-- 提交按钮 + 结果弹窗 -->
<!-- 提交按钮 + 结果弹窗
display-directive 默认是 "if"面板一收起来整个 SubmissionResult 就被卸载
正在流式输出的 AI 提示连同已经生成的内容一起没了那次 LLM 调用白花
改成 "show" 之后内容留着重新打开还是原样 -->
<n-popover
trigger="manual"
display-directive="show"
placement="bottom-end"
scrollable
:show-arrow="false"
@@ -258,6 +266,17 @@ watch(
<SubmissionResult :submission="submission" />
</n-popover>
<!-- 结果面板点一下别处就收起来 showResult 只在提交时被置 true
原来唯一的重开方式是再提交一次AI 提示读到一半去看眼题面就回不来了
只在这次会话提交过之后才出现没提交时工具栏保持原样 -->
<n-button
v-if="submission && !showResult"
:size="isDesktop ? 'medium' : 'small'"
@click="showResult = true"
>
上次结果
</n-button>
<!-- 评价弹窗 -->
<n-modal
preset="card"

View File

@@ -5,6 +5,7 @@ import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useScreenModeStore } from "shared/store/screenMode"
import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useUserStore } from "shared/store/user"
// 抽成具名 loader便于进页面时与接口并行预取编辑器 chunk
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
@@ -128,6 +129,21 @@ async function init() {
}
onMounted(init)
watch(() => problemID, init)
// 题目详情里的 myStatus / myFailedCount 是按当前用户算的,而登录不重新挂载这个页面 ——
// 会话过期后直接在题目页登录的机房里最常见的那条路不补拉一次AI 提示的解锁进度
// 就还是匿名时的 0等于白改。只换 problem不走 init那里还会重置分栏模式。
watch(
() => useUserStore().isAuthed,
async (authed) => {
if (!authed || !problem.value) return
try {
problem.value = await getProblem(problemID, contestID)
} catch {
// 拉不到就留着现在这份题面,不要把页面清空
}
},
)
onBeforeUnmount(() => {
problem.value = null
errMsg.value = "无数据"

View File

@@ -5,7 +5,27 @@ export const useProblemStore = defineStore("problem", () => {
const problem = ref<Problem | null>(null)
const route = useRoute()
const failCount = ref(0)
/**
* 本次会话里新增的失败提交数。**只是增量**,历史失败数看 `problem.myFailedCount`。
*
* 原来 failCount 就是这一个从 0 起数的 ref于是「失败 3 次解锁 AI 提示」实际变成了
* 「在当前这次页面会话里再失败 3 次」:刷新一下、从题单跳进跳出一次就清零,
* 昨天在这题上撞了十次墙的学生今天进来照样看不到按钮。而后端的闸门数的是数据库里
* 的历史失败数,两边根本不是一回事。
*/
const sessionFailCount = ref(0)
/**
* 这道题一共失败了几次 = 服务端算好的历史值 + 本次会话的增量。
* 和后端 `countFailedSubmissions` 同一个口径,所以按钮亮起来的时刻就是
* `POST /ai/hint` 放行的时刻。
*
* 题目详情只在 problemID 变化时重新拉(见 oj/problem/detail.vue 的 init
* 而那时下面的 watch 已经把增量清零了,不会和新的 myFailedCount 叠加。
*/
const failCount = computed(
() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value,
)
const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allowFlowchart) {
@@ -15,13 +35,13 @@ export const useProblemStore = defineStore("problem", () => {
})
function incrementFailCount() {
failCount.value++
sessionFailCount.value++
}
watch(
() => problem.value?.id,
() => {
failCount.value = 0
sessionFailCount.value = 0
},
)

View File

@@ -106,6 +106,8 @@ export async function aiStreamError(response: Response) {
return new Error("AI 请求太频繁了,歇一会儿再试")
case "hint-locked":
return new Error("再多试几次AI 提示会自动解锁")
case "contest-hint-disabled":
return new Error("比赛中不提供 AI 提示")
case "permission-denied":
return new Error("没有权限使用这个功能")
default:

View File

@@ -110,6 +110,12 @@ export const aiAnalysisRequestSchema = z.object({
username: z.string().optional(),
})
/**
* 解锁「让 AI 分析我的代码」所需的失败提交数。前端拿它决定按钮露不露面、
* 后端拿它卡 POST /ai/hint —— 放在契约里就是为了不让两边各写一个 3。
*/
export const HINT_MIN_FAILURES = 3
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
export const classAnalysisRequestSchema = z.object({