From 7e964fdbdd6353c185ad41a4de05633ba3928d9b Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 22:14:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=98=E7=9B=AE):=20=E7=AD=94=E6=A1=88?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=97=B6=E6=8B=BF=E5=85=AC=E5=BC=80=E6=A0=B7?= =?UTF-8?q?=E4=BE=8B=E9=87=8D=E8=B7=91=EF=BC=8C=E5=B9=B6=E6=8E=92=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=AD=A3=E7=A1=AE=E8=BE=93=E5=87=BA=E5=92=8C=E4=BD=A0?= =?UTF-8?q?=E7=9A=84=E8=BE=93=E5=87=BA=EF=BC=8C=E6=8C=89=E5=B7=AE=E5=BC=82?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E7=BB=99=E4=B8=AD=E6=96=87=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来答案错误只有四个字。6901 条历史答案错误在公开样例上重跑过:73% 在样例上就错了, 其中约三分之一只差格式(没输出、input() 提示文字、空格换行、中英文标点、小数位数)。 - judge/run.ts 的 checkSamples:判出答案错误后以内联测试点重跑题目样例(output: true), 记下第一个没过的样例和学生在它上面的输出,写进 statistic_info.sample_check; 样例全过则只记 passed。全是公开信息,不碰隐藏测试点;比赛不跑(有期末考试) - 前端按差异归类(数字先于「多了内容」判,免得 007 被当成多打了文字), 并排显示输入 / 正确输出 / 你的输出,标出第一处不同的行;只差空白时把空格、换行画出来, 末尾空白不画(判题本来就忽略) - 提示给具体值(「正确输出是 84.10,你输出的是 84.1」),input("…") 单独提示 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/judge/run.ts | 78 +++++++- .../problem/components/SubmissionResult.vue | 15 ++ .../problem/components/WrongAnswerExplain.vue | 171 ++++++++++++++++++ apps/web/src/oj/problem/utils/pythonError.ts | 2 +- apps/web/src/oj/problem/utils/wrongAnswer.ts | 162 +++++++++++++++++ packages/contract/src/submission.ts | 20 ++ 6 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/oj/problem/components/WrongAnswerExplain.vue create mode 100644 apps/web/src/oj/problem/utils/wrongAnswer.ts diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index e4c0359..be76f14 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" -import { astRuleSchema, type ContestSubmissionInfo } from "@oj2/contract" +import { astRuleSchema, type ContestSubmissionInfo, type StatisticInfo } from "@oj2/contract" import { and, eq, inArray } from "drizzle-orm" import { config } from "../config" @@ -377,6 +377,69 @@ async function diagnoseRuntimeError( return parsePythonTraceback(output, row.submission.code, prependLines) } +/** sample_check 里三段文本各自的上限。样例本身很短,截的主要是学生的输出(死循环打印) */ +const SAMPLE_TEXT_LIMIT = 2000 + +type SampleCheck = NonNullable + +/** + * 答案错误时拿题目的**公开样例**重跑一次,记下第一个没过的样例和学生在它上面的输出, + * 写进 `statistic_info.sample_check`。 + * + * 2025 秋以来的 6901 条答案错误在样例上重跑过:73% 在样例上就已经错了。样例写在题面上, + * 所以「输入 700,正确输出 7,你的输出 007」给学生看不泄露任何隐藏数据,而这正是他最 + * 缺的线索 —— 原来答案错误只有四个字,连错在哪一类都不知道。其中约三分之一只差格式 + * (多了提示文字、空格换行、中英文标点、小数位数),前端按差异类型再补一句中文提示。 + * + * 和运行时错误的诊断一样,主判题不开 output;这里的样例是内联传的,输出只回这几个点。 + */ +async function checkSamples( + row: { + submission: typeof schema.submission.$inferSelect + problem: typeof schema.problem.$inferSelect + }, + source: string, +): Promise { + const samples = (Array.isArray(row.problem.samples) ? row.problem.samples : []) + .map((item) => asRecord(item)) + .filter( + (item): item is { input: string; output: string } => + typeof item.input === "string" && typeof item.output === "string", + ) + if (!samples.length) return null + + const response = await requestJudge( + row.submission.language, + source, + row.problem.timeLimit, + row.problem.memoryLimit, + // 题面上的样例输入常常没有结尾换行,而 input() 读到文件末尾没有换行照样能读, + // C 的 scanf 也不在乎。补一个是为了和测试点文件的样子一致 + samples.map((item) => ({ + input: item.input.endsWith("\n") ? item.input : `${item.input}\n`, + output: item.output, + })), + true, + ) + // 在样例上就编译不过不会发生(正式判题刚编译过同一份代码),真遇到了就当没检查 + if (response.err || !Array.isArray(response.data)) return null + const cases = [...(response.data as JudgeCase[])].sort( + (left, right) => Number(left.test_case) - Number(right.test_case), + ) + const index = cases.findIndex((item) => item.result !== JudgeStatus.ACCEPTED) + if (index < 0) return { passed: true } + const sample = samples[index]! + const output = cases[index]!.output + return { + passed: false, + index, + input: sample.input.slice(0, SAMPLE_TEXT_LIMIT), + expected: sample.output.slice(0, SAMPLE_TEXT_LIMIT), + output: typeof output === "string" ? output.slice(0, SAMPLE_TEXT_LIMIT) : "", + result: cases[index]!.result, + } +} + export async function judgeSubmission(job: JudgeJobData) { const [row] = await db .select({ @@ -473,6 +536,19 @@ export async function judgeSubmission(job: JudgeJobData) { if (diagnosis) statisticInfo.runtime_error = diagnosis } + // 同上,比赛不跑:比赛里有期末考试,格式提示在那里不该给 + if ( + result === JudgeStatus.WRONG_ANSWER && + row.submission.contestId === null && + row.submission.language !== "SQL" + ) { + const check = await checkSamples(row, source).catch((error: unknown) => { + console.warn(`[judge] 样例重跑失败 ${row.submission.id}:`, error) + return null + }) + if (check) statisticInfo.sample_check = check + } + if (result === JudgeStatus.ACCEPTED) { const rules = astRulesForLanguage(row.problem.astRules, row.submission.language) if (rules.length > 0) { diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index d73ad37..f476f6a 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -16,6 +16,7 @@ import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import { useProblemStore } from "oj/store/problem" import PythonErrorExplain from "./PythonErrorExplain.vue" import RuntimeErrorExplain from "./RuntimeErrorExplain.vue" +import WrongAnswerExplain from "./WrongAnswerExplain.vue" import { useAIStream } from "shared/composables/aiStream" import { submitHintFeedback } from "oj/api" import { MdPreview } from "md-editor-v3" @@ -113,6 +114,13 @@ const runtimeError = computed(() => { return submission.statisticInfo?.runtime_error ?? null }) +/** 答案错误时公开样例上的对比,交给 WrongAnswerExplain;比赛提交没有(见后端 checkSamples) */ +const sampleCheck = computed(() => { + const submission = props.submission + if (!submission || submission.result !== SubmissionStatus.wrong_answer) return null + return submission.statisticInfo?.sample_check ?? null +}) + const msg = computed(() => { if (!props.submission) return "" // 走 PythonErrorExplain / RuntimeErrorExplain 那两张中文卡片 @@ -285,6 +293,7 @@ const columns: DataTableColumn[] = [ v-if=" pythonCompileError || runtimeError || + sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length @@ -292,6 +301,12 @@ const columns: DataTableColumn[] = [ > + +import { JUDGE_STATUS, SubmissionStatus } from "utils/constants" +import { + classifyDiff, + explainDiff, + firstDifferentLine, + type SampleCheck, +} from "oj/problem/utils/wrongAnswer" + +/** + * 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。 + * 全是公开信息:样例写在题面上,「你的输出」是学生自己的程序在公开输入上的输出。 + */ +const props = defineProps<{ + check: SampleCheck + code: string + language: string +}>() + +/** 后端每段截到 2000 字,到了上限就是被截过 */ +const TEXT_LIMIT = 2000 + +const onSample = computed(() => props.check.result === SubmissionStatus.wrong_answer) +const kind = computed(() => + onSample.value ? classifyDiff(props.check.expected ?? "", props.check.output ?? "") : null, +) +const hint = computed(() => { + if (props.check.passed) return "" + if (!kind.value) { + const name = JUDGE_STATUS[props.check.result as keyof typeof JUDGE_STATUS]?.name ?? "出错了" + return `你的程序在这个例子上就「${name}」了,先让它在例子上跑通。` + } + return explainDiff(kind.value, props.check, props.code, props.language) +}) +const diffLine = computed(() => + firstDifferentLine(props.check.expected ?? "", props.check.output ?? ""), +) + +/** + * 只差空白的时候把空白画出来,不然两边看着一模一样。 + * + * 末尾的空白先去掉:判题比的是整个输出去掉末尾空白之后的样子(JudgeServer 的 + * stripped_output_md5),print 自己带的那个换行不算错。画出来的话,学生会以为 + * 是结尾那个「↵」惹的祸。 + */ +function lines(text: string) { + const visible = kind.value === "whitespace" + const parts = text.replace(/\s+$/, "").split("\n") + return parts.map((line, i) => { + if (!visible) return line + const ending = i < parts.length - 1 ? "↵" : "" + return line.replace(/ /g, "·").replace(/\t/g, "→") + ending + }) +} + + + + + diff --git a/apps/web/src/oj/problem/utils/pythonError.ts b/apps/web/src/oj/problem/utils/pythonError.ts index 5db2f1b..eb19492 100644 --- a/apps/web/src/oj/problem/utils/pythonError.ts +++ b/apps/web/src/oj/problem/utils/pythonError.ts @@ -76,7 +76,7 @@ export const PUNCTUATION_MAP: Record = { * 几乎一样,「把「:」换成「:」」这句话学生看不出区别。「冒号是中文的,要换成英文的」 * 看得懂,要换哪一个由卡片里那行代码的标红来指。 */ -const PUNCTUATION_NAMES: Record = { +export const PUNCTUATION_NAMES: Record = { "(": "左括号", ")": "右括号", ",": "逗号", diff --git a/apps/web/src/oj/problem/utils/wrongAnswer.ts b/apps/web/src/oj/problem/utils/wrongAnswer.ts new file mode 100644 index 0000000..8c2bbed --- /dev/null +++ b/apps/web/src/oj/problem/utils/wrongAnswer.ts @@ -0,0 +1,162 @@ +import type { StatisticInfo } from "@oj2/contract" +import { PUNCTUATION_MAP, PUNCTUATION_NAMES } from "./pythonError" + +/** + * 答案错误时,比较学生在**公开样例**上的输出和正确输出,归一类、给一句中文提示。 + * 数据来自后端 judge/run.ts 的 checkSamples(`statistic_info.sample_check`)。 + * + * 分类是拿 2025 秋以来 5055 条「样例上就错了」的答案错误定的: + * 格式和内容都不同 44%、数值不对 15%、没有输出 10%、多了文字 9%、只差空格换行 9%、 + * 少了内容 5%、中英文标点 4%、数字写法(小数位数)2.5%、大小写 1%。 + * 前几类之外的「几乎对了」合起来约三分之一,一句话就能改对。 + * + * 样例是公开的,这里可以放心地把具体的值说出来(「正确输出是 84.10,你输出的是 84.1」)。 + */ +export type SampleCheck = NonNullable + +export type DiffKind = + | "no-output" + | "whitespace" + | "punctuation" + | "case" + | "extra" + | "missing" + | "number-format" + | "values" + | "other" + +const NUMBER = /-?\d+(?:\.\d+)?/g + +function squash(text: string) { + return text.replace(/\s+/g, " ").trim() +} + +function toEnglishPunctuation(text: string) { + return [...text].map((ch) => PUNCTUATION_MAP[ch] ?? ch).join("") +} + +export function classifyDiff(expected: string, output: string): DiffKind { + const e = squash(expected) + const o = squash(output) + if (!o) return "no-output" + if (e === o) return "whitespace" + if (squash(toEnglishPunctuation(expected)) === squash(toEnglishPunctuation(output))) { + return "punctuation" + } + if (e.toLowerCase() === o.toLowerCase()) return "case" + // 数字先于「多了内容」判:007 里包含 7、3.0 里包含 3,先判包含就会把它们当成 + // 「多打了说明文字」,给出完全不相干的建议 + const en = e.match(NUMBER) ?? [] + const on = o.match(NUMBER) ?? [] + const sameShape = e.replace(NUMBER, "N") === o.replace(NUMBER, "N") && en.length === on.length + if (sameShape && en.length) { + const sameValues = en.every((value, i) => Math.abs(Number(value) - Number(on[i])) < 1e-9) + return sameValues ? "number-format" : "values" + } + if (e && o.includes(e)) return "extra" + if (o && e.includes(o)) return "missing" + return "other" +} + +/** 第一处不一样的标点:[正确输出里的, 你输出的] */ +function firstPunctuationPair(expected: string, output: string) { + const e = [...squash(expected)] + const o = [...squash(output)] + for (let i = 0; i < Math.min(e.length, o.length); i++) { + if (e[i] !== o[i]) return [e[i]!, o[i]!] as const + } + return null +} + +function punctuationHint(expected: string, output: string) { + const pair = firstPunctuationPair(expected, output) + if (pair) { + const [want, got] = pair + // 中文的那个才在名字表里 + const name = PUNCTUATION_NAMES[got] ?? PUNCTUATION_NAMES[want] + if (name && PUNCTUATION_MAP[got] === want) { + return `内容对了,但标点不一样:正确输出用的是英文的${name},你输出的是中文的${name}。输出里的标点要和题目完全一样。` + } + if (name && PUNCTUATION_MAP[want] === got) { + return `内容对了,但标点不一样:正确输出用的是中文的${name},你输出的是英文的${name}。输出里的标点要和题目完全一样。` + } + } + return "内容对了,但标点不一样:输出里的标点要和题目完全一样,注意分清中文标点和英文标点。" +} + +function numberFormatHint(expected: string, output: string, language: string) { + const en = squash(expected).match(NUMBER) ?? [] + const on = squash(output).match(NUMBER) ?? [] + const i = en.findIndex((value, k) => value !== on[k]) + if (i < 0) return "数字的写法和正确输出不一样。" + const want = en[i]! + const got = on[i]! + const decimals = want.split(".")[1]?.length ?? 0 + const head = `正确输出是 ${want},你输出的是 ${got}。` + // 007 和 7:逆序数、取位数一类题的老毛病,跟小数没关系 + if (/^-?0\d/.test(got)) { + const how = language === "Python" ? "用 int() 转成整数再输出" : "按整数(%d)输出" + return `${head}数字前面多了 0。要输出的是一个数,不是一串数字字符:${how},前面的 0 就没了。` + } + if (decimals > 0) { + const how = + language === "Python" + ? `Python 可以写 f"{x:.${decimals}f}"` + : `C 语言可以写 printf("%.${decimals}f", x)` + return `${head}题目要求保留 ${decimals} 位小数,${how}。` + } + if (!got.includes(".")) return `${head}数字的写法和正确输出不一样。` + if (language === "Python") { + return `${head}这里要输出整数:Python 里 / 算出来的总是小数,整除要用 //,或者用 int() 转换。` + } + return `${head}这里要输出整数,不要带小数点:printf 用 %d 输出整数。` +} + +/** Python 的 input("请输入……"):提示文字会被打印进输出,判题时必然对不上 */ +export function hasInputPrompt(code: string) { + return /\binput\(\s*[fr]?["'][^"'\n]*\S[^"'\n]*["']\s*\)/.test(code) +} + +export function explainDiff( + kind: DiffKind, + check: SampleCheck, + code: string, + language: string, +): string { + const expected = check.expected ?? "" + const output = check.output ?? "" + const printName = language === "Python" ? "print" : "printf" + switch (kind) { + case "no-output": + return `你的程序什么都没有输出。检查一下是不是忘了写 ${printName},或者 ${printName} 写在了执行不到的地方(比如某个 if 的里面)。` + case "whitespace": + return "内容对了,只是空格或换行不一样。下面把空格画成了「·」、换行画成了「↵」,对照着看看哪一行多了或少了。" + case "punctuation": + return punctuationHint(expected, output) + case "case": + return "内容对了,只是字母的大小写不一样。输出要和题目的大小写完全一样。" + case "extra": + if (language === "Python" && hasInputPrompt(code)) { + return 'input() 括号里的提示文字也会被输出,判题时就对不上了。把 input("……") 里的文字删掉,只写 input()。' + } + return "你的输出比正确输出多了一些内容。题目要输出什么就只输出什么,不要加「请输入」「结果是」这类提示或说明文字。" + case "missing": + return "你的输出比正确输出少了一部分,对照看看漏了什么。" + case "number-format": + return numberFormatHint(expected, output, language) + case "values": + return "输出的格式对了,但算出来的数不对。拿这个例子的输入,自己一步一步算一遍,看看程序在哪一步和你想的不一样。" + case "other": + return "对照一下你的输出和正确输出,找找从哪里开始不一样。" + } +} + +/** 第一处不一样的行号,0 起;完全一样(只差结尾空白)时为 -1 */ +export function firstDifferentLine(expected: string, output: string) { + const e = expected.replace(/\s+$/, "").split("\n") + const o = output.replace(/\s+$/, "").split("\n") + for (let i = 0; i < Math.max(e.length, o.length); i++) { + if (e[i] !== o[i]) return i + } + return -1 +} diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index 5fe21fa..9f0c196 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -101,6 +101,26 @@ export const statisticInfoSchema = z.looseObject({ exit_code: z.number().int().optional(), }) .optional(), + /** + * 答案错误时拿题目的**公开样例**重跑的结果,judge/run.ts 的 checkSamples 写。 + * 只在非比赛提交上有。 + * + * 这里的东西全是学生本来就看得到的:样例写在题面上,output 是他自己的程序在 + * 公开输入上的输出。隐藏测试点的内容一概不碰。三段文本都截断过(见 SAMPLE_TEXT_LIMIT)。 + */ + sample_check: z + .object({ + /** 所有样例都过了(错在隐藏测试点上) */ + passed: z.boolean(), + /** 第一个没过的样例,0 起;passed 为 true 时没有下面这些 */ + index: z.number().int().optional(), + input: z.string().optional(), + expected: z.string().optional(), + output: z.string().optional(), + /** 样例上的判题结果:多数是答案错误,也可能在样例上就超时、运行出错 */ + result: z.number().int().optional(), + }) + .optional(), }) /**