feat(题目): 答案错误时拿公开样例重跑,并排显示正确输出和你的输出,按差异类型给中文提示
Deploy / deploy (push) Canceled after 0s

原来答案错误只有四个字。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 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 22:14:24 +08:00
1 parent db70ab62e8
commit 7e964fdbdd
6 files changed
+446 -2

No files matched your search

+77 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto" 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 { and, eq, inArray } from "drizzle-orm"
import { config } from "../config" import { config } from "../config"
@@ -377,6 +377,69 @@ async function diagnoseRuntimeError(
return parsePythonTraceback(output, row.submission.code, prependLines) return parsePythonTraceback(output, row.submission.code, prependLines)
} }
/** sample_check 里三段文本各自的上限。样例本身很短,截的主要是学生的输出(死循环打印) */
const SAMPLE_TEXT_LIMIT = 2000
type SampleCheck = NonNullable<StatisticInfo["sample_check"]>
/**
* 答案错误时拿题目的**公开样例**重跑一次,记下第一个没过的样例和学生在它上面的输出,
* 写进 `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<SampleCheck | null> {
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) { export async function judgeSubmission(job: JudgeJobData) {
const [row] = await db const [row] = await db
.select({ .select({
@@ -473,6 +536,19 @@ export async function judgeSubmission(job: JudgeJobData) {
if (diagnosis) statisticInfo.runtime_error = diagnosis 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) { if (result === JudgeStatus.ACCEPTED) {
const rules = astRulesForLanguage(row.problem.astRules, row.submission.language) const rules = astRulesForLanguage(row.problem.astRules, row.submission.language)
if (rules.length > 0) { if (rules.length > 0) {
@@ -16,6 +16,7 @@ import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import PythonErrorExplain from "./PythonErrorExplain.vue" import PythonErrorExplain from "./PythonErrorExplain.vue"
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue" import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
import WrongAnswerExplain from "./WrongAnswerExplain.vue"
import { useAIStream } from "shared/composables/aiStream" import { useAIStream } from "shared/composables/aiStream"
import { submitHintFeedback } from "oj/api" import { submitHintFeedback } from "oj/api"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
@@ -113,6 +114,13 @@ const runtimeError = computed(() => {
return submission.statisticInfo?.runtime_error ?? null 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(() => { const msg = computed(() => {
if (!props.submission) return "" if (!props.submission) return ""
// 走 PythonErrorExplain / RuntimeErrorExplain 那两张中文卡片 // 走 PythonErrorExplain / RuntimeErrorExplain 那两张中文卡片
@@ -285,6 +293,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
v-if=" v-if="
pythonCompileError || pythonCompileError ||
runtimeError || runtimeError ||
sampleCheck ||
msg || msg ||
infoTable.length || infoTable.length ||
submission.statisticInfo?.ast_results?.length submission.statisticInfo?.ast_results?.length
@@ -292,6 +301,12 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
> >
<PythonErrorExplain v-if="pythonCompileError" :err-info="pythonCompileError" /> <PythonErrorExplain v-if="pythonCompileError" :err-info="pythonCompileError" />
<RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" /> <RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" />
<WrongAnswerExplain
v-if="sampleCheck"
:check="sampleCheck"
:code="submission.code"
:language="submission.language"
/>
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded> <n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
<n-flex vertical :size="8"> <n-flex vertical :size="8">
<n-flex <n-flex
@@ -0,0 +1,171 @@
<script setup lang="ts">
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
})
}
</script>
<template>
<n-card embedded class="explain-card">
<div v-if="check.passed" class="explain">
题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。
</div>
<n-flex v-else vertical :size="12">
<div class="explain">
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过:</b>{{ hint }}
</div>
<div class="blocks">
<div class="block input">
<div class="label">输入</div>
<div class="text">
<div
v-for="(line, i) in (check.input ?? '').split('\n')"
:key="i"
class="line"
v-text="line"
/>
</div>
</div>
<div class="block">
<div class="label">正确输出</div>
<div class="text">
<div
v-for="(line, i) in lines(check.expected ?? '')"
:key="i"
class="line"
:class="{ diff: onSample && i === diffLine }"
v-text="line"
/>
</div>
</div>
<div class="block">
<div class="label">你的输出</div>
<div v-if="check.output" class="text">
<div
v-for="(line, i) in lines(check.output)"
:key="i"
class="line"
:class="{ diff: onSample && i === diffLine }"
v-text="line"
/>
</div>
<div v-else class="text empty">(什么都没有输出)</div>
<n-text v-if="check.output && check.output.length >= TEXT_LIMIT" depth="3" class="cut">
输出太长,只显示了前面一部分
</n-text>
</div>
</div>
</n-flex>
</n-card>
</template>
<style scoped>
.explain-card {
max-width: 560px;
}
.explain {
font-size: 16px;
line-height: 1.7;
}
.blocks {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.block {
min-width: 0;
}
.block.input {
grid-column: 1 / -1;
}
.label {
font-size: 13px;
opacity: 0.7;
margin-bottom: 4px;
}
.text {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
padding: 8px 10px;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
max-height: 200px;
overflow: auto;
background-color: rgba(128, 128, 128, 0.1);
}
/* 每行用 v-text 填,不写 {{ line }}:模板里标签两边的换行缩进会被 Vue 压成一个空格,
配上 white-space: pre 就是每行开头多一个空格 —— 在一个专门比对空格的地方,这不能有 */
.line {
white-space: pre;
min-height: 1.6em;
}
.diff {
background-color: rgba(208, 48, 80, 0.2);
}
.empty {
opacity: 0.6;
}
.cut {
font-size: 12px;
}
</style>
+1 -1
View File
@@ -76,7 +76,7 @@ export const PUNCTUATION_MAP: Record<string, string> = {
* 几乎一样,「把「:」换成「:」」这句话学生看不出区别。「冒号是中文的,要换成英文的」 * 几乎一样,「把「:」换成「:」」这句话学生看不出区别。「冒号是中文的,要换成英文的」
* 看得懂,要换哪一个由卡片里那行代码的标红来指。 * 看得懂,要换哪一个由卡片里那行代码的标红来指。
*/ */
const PUNCTUATION_NAMES: Record<string, string> = { export const PUNCTUATION_NAMES: Record<string, string> = {
"(": "左括号", "(": "左括号",
")": "右括号", ")": "右括号",
",": "逗号", ",": "逗号",
@@ -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<StatisticInfo["sample_check"]>
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
}
+20
View File
@@ -101,6 +101,26 @@ export const statisticInfoSchema = z.looseObject({
exit_code: z.number().int().optional(), exit_code: z.number().int().optional(),
}) })
.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(),
}) })
/** /**