From bb0f5ec5efc7cdb168ac3eaab959a7bd8911536f Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 6 Sep 2026 07:24:26 -0600 Subject: [PATCH] =?UTF-8?q?fix(AI=E6=8F=90=E7=A4=BA):=20=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=89=8D=E5=90=8E=E7=AB=AF=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E7=A4=BA=E4=B8=8D=E5=86=8D=E4=B8=80=E7=82=B9?= =?UTF-8?q?=E5=B0=B1=E6=B2=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「失败 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 Claude-Session: https://claude.ai/code/session_01RC5uL72UY9aZFuTvUKe2jv --- apps/api/src/judge/status.ts | 37 +++++++++++++++++++ apps/api/src/routes/ai.ts | 29 +++++++-------- apps/api/src/routes/helpers.ts | 26 +++++++++++++ apps/api/src/routes/problem.ts | 16 ++------ .../problem/components/SubmissionResult.vue | 25 ++++++++++++- .../src/oj/problem/components/SubmitCode.vue | 23 +++++++++++- apps/web/src/oj/problem/detail.vue | 16 ++++++++ apps/web/src/oj/store/problem.ts | 26 +++++++++++-- apps/web/src/utils/stream.ts | 2 + packages/contract/src/ai.ts | 6 +++ 10 files changed, 172 insertions(+), 34 deletions(-) diff --git a/apps/api/src/judge/status.ts b/apps/api/src/judge/status.ts index 5985fdb..04b4a6b 100644 --- a/apps/api/src/judge/status.ts +++ b/apps/api/src/judge/status.ts @@ -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 = { + [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, +] diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 88bdc3e..e83bc8e 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -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() const accepted = [0, 10] const difficultyNames: Record = { 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) }) diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index 233da34..256fa1d 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -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 +} diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index a4c7063..f97abe5 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -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() @@ -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 : [] diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index f9afacb..319c187 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -1,6 +1,7 @@