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

@@ -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 : []