Compare commits
2
Commits
2a4405d2ea
...
7e964fdbdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e964fdbdd | ||
|
|
db70ab62e8 |
No files matched your search
+149
-4
@@ -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"
|
||||||
@@ -19,6 +19,11 @@ import { judgeConfigFor } from "./languages"
|
|||||||
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
|
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
|
||||||
import { parseProblemTemplate } from "./template"
|
import { parseProblemTemplate } from "./template"
|
||||||
import { runSqlCase } from "./sql"
|
import { runSqlCase } from "./sql"
|
||||||
|
import {
|
||||||
|
nativeRuntimeError,
|
||||||
|
parsePythonTraceback,
|
||||||
|
type RuntimeErrorInfo,
|
||||||
|
} from "./runtime-diagnosis"
|
||||||
import { readInfo } from "../services/test-case"
|
import { readInfo } from "../services/test-case"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import { resolve as resolvePath } from "node:path"
|
import { resolve as resolvePath } from "node:path"
|
||||||
@@ -63,12 +68,18 @@ function astRulesForLanguage(value: unknown, language: string): AstRule[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `testCase` 是测试点目录名(正常判题),或者内联的测试点(诊断重跑,见
|
||||||
|
* diagnoseRuntimeError)。`output` 只在诊断时打开:它让判题机把每个测试点的
|
||||||
|
* 程序输出原样带回来,正常判题开着的话,死循环打印能把 worker 内存撑爆。
|
||||||
|
*/
|
||||||
async function requestJudge(
|
async function requestJudge(
|
||||||
language: string,
|
language: string,
|
||||||
code: string,
|
code: string,
|
||||||
timeLimit: number,
|
timeLimit: number,
|
||||||
memoryLimit: number,
|
memoryLimit: number,
|
||||||
testCaseId: string,
|
testCase: string | { input: string; output: string }[],
|
||||||
|
output = false,
|
||||||
) {
|
) {
|
||||||
const languageConfig = judgeConfigFor(language)
|
const languageConfig = judgeConfigFor(language)
|
||||||
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
|
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
|
||||||
@@ -85,8 +96,8 @@ async function requestJudge(
|
|||||||
src: code,
|
src: code,
|
||||||
max_cpu_time: timeLimit,
|
max_cpu_time: timeLimit,
|
||||||
max_memory: 1024 * 1024 * memoryLimit,
|
max_memory: 1024 * 1024 * memoryLimit,
|
||||||
test_case_id: testCaseId,
|
...(typeof testCase === "string" ? { test_case_id: testCase } : { test_case: testCase }),
|
||||||
output: false,
|
output,
|
||||||
io_mode: {
|
io_mode: {
|
||||||
io_mode: "Standard IO",
|
io_mode: "Standard IO",
|
||||||
input: "input.txt",
|
input: "input.txt",
|
||||||
@@ -324,6 +335,111 @@ export async function failAbandonedSubmission(submissionId: string, error: unkno
|
|||||||
await markSystemError(submissionId, row.userId, error)
|
await markSystemError(submissionId, row.userId, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时错误的诊断,结果写进 `statistic_info.runtime_error`(字段说明见契约)。
|
||||||
|
*
|
||||||
|
* C / C++ 直接用失败测试点的信号和退出码。Python 要拿回溯,得**重跑那一个测试点**:
|
||||||
|
* 主判题不开 output(理由见 requestJudge),所以这里读出第一个失败测试点的输入,
|
||||||
|
* 以内联测试点的形式单独再跑一次、打开 output。同样的代码、同样的输入,抛的是
|
||||||
|
* 同一个异常。只多跑一个点,而且只在 Python 判出运行时错误时才跑。
|
||||||
|
*
|
||||||
|
* 诊断失败(读不到测试点、判题机没回回溯)就不写这个字段,学生看到的和原来一样。
|
||||||
|
*/
|
||||||
|
async function diagnoseRuntimeError(
|
||||||
|
row: {
|
||||||
|
submission: typeof schema.submission.$inferSelect
|
||||||
|
problem: typeof schema.problem.$inferSelect
|
||||||
|
},
|
||||||
|
source: string,
|
||||||
|
prependLines: number,
|
||||||
|
failed: JudgeCase,
|
||||||
|
): Promise<RuntimeErrorInfo | null> {
|
||||||
|
if (row.submission.language !== "Python")
|
||||||
|
return nativeRuntimeError(failed.signal, failed.exit_code)
|
||||||
|
|
||||||
|
const info = await readInfo(row.problem.testCaseId)
|
||||||
|
const inputName = info?.test_cases?.[failed.test_case]?.input_name
|
||||||
|
if (!inputName) return null
|
||||||
|
const input = await readFile(
|
||||||
|
resolvePath(config.testCaseDirectory, row.problem.testCaseId, inputName),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
const response = await requestJudge(
|
||||||
|
row.submission.language,
|
||||||
|
source,
|
||||||
|
row.problem.timeLimit,
|
||||||
|
row.problem.memoryLimit,
|
||||||
|
[{ input, output: "" }],
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
const output = Array.isArray(response.data) ? response.data[0]?.output : null
|
||||||
|
if (typeof output !== "string") return null
|
||||||
|
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({
|
||||||
@@ -404,6 +520,35 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
)?.error_message
|
)?.error_message
|
||||||
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage
|
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage
|
||||||
|
|
||||||
|
// 比赛不诊断:ACM 只报对错,和「通过几个测试点」、AI 提示不给比赛提交同一个口径
|
||||||
|
if (
|
||||||
|
result === JudgeStatus.RUNTIME_ERROR &&
|
||||||
|
firstFailure &&
|
||||||
|
row.submission.contestId === null
|
||||||
|
) {
|
||||||
|
const prependLines = template ? template.prepend.split("\n").length : 0
|
||||||
|
const diagnosis = await diagnoseRuntimeError(row, source, prependLines, firstFailure).catch(
|
||||||
|
(error: unknown) => {
|
||||||
|
console.warn(`[judge] 运行时错误诊断失败 ${row.submission.id}:`, error)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { StatisticInfo } from "@oj2/contract"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时错误的诊断:把判题机的原始结果收成 `statistic_info.runtime_error`,
|
||||||
|
* 前端再翻成中文(apps/web/src/oj/problem/utils/runtimeError.ts)。
|
||||||
|
*
|
||||||
|
* 原来学生拿到的只有「运行时错误」四个字:主判题是 `output: false`,Python 的回溯
|
||||||
|
* 根本没回来;C 的信号值倒是有,但在 `info` 里,而 `info` 只给管理员。
|
||||||
|
*
|
||||||
|
* Python 的回溯要靠**重跑一次**拿(run.ts 的 diagnoseRuntimeError):只拿第一个
|
||||||
|
* 失败的测试点、打开 output 单独跑。不给主判题开 output —— 那样每个测试点的完整
|
||||||
|
* 输出(每个最多 16MB)都会回到 worker,一个死循环打印就能把内存撑爆。
|
||||||
|
*
|
||||||
|
* **原文消息一律不存**。Python 的异常消息里常带着测试点的输入,比如
|
||||||
|
* `invalid literal for int() with base 10: '1 2'`,给学生看就等于把隐藏数据放出去 ——
|
||||||
|
* 故意写 `raise Exception(input())` 就能一个点一个点地套。所以只存:
|
||||||
|
* - 异常类型和归好类的 `kind`(按消息的**句式**判,不取消息里的值)
|
||||||
|
* - NameError / AttributeError 点名的名字,而且只在它**原样出现在学生代码里**时才存
|
||||||
|
*/
|
||||||
|
export type RuntimeErrorInfo = NonNullable<StatisticInfo["runtime_error"]>
|
||||||
|
|
||||||
|
const IDENTIFIER = /^[A-Za-z_]\w*$/
|
||||||
|
|
||||||
|
/** 按消息句式细分。键是异常类型,值是 [句式, kind],从上往下取第一个对上的 */
|
||||||
|
const KINDS: Record<string, [RegExp, string][]> = {
|
||||||
|
ValueError: [
|
||||||
|
[/^invalid literal for int\(\)/, "int-parse"],
|
||||||
|
[/^could not convert string to float/, "float-parse"],
|
||||||
|
[/^(not enough|too many) values to unpack/, "unpack"],
|
||||||
|
[/^math domain error/, "math-domain"],
|
||||||
|
],
|
||||||
|
TypeError: [
|
||||||
|
[/^can only concatenate str/, "str-concat"],
|
||||||
|
[/^unsupported operand type/, "operand"],
|
||||||
|
[/cannot be interpreted as an integer/, "not-int"],
|
||||||
|
[/not supported between instances of/, "compare"],
|
||||||
|
[/object is not callable/, "not-callable"],
|
||||||
|
[/object is not subscriptable/, "not-subscriptable"],
|
||||||
|
[/missing \d+ required positional argument/, "missing-arg"],
|
||||||
|
[/^(list|string|tuple) indices must be integers/, "index-type"],
|
||||||
|
],
|
||||||
|
IndexError: [[/out of range/, "index"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名字在学生自己的代码里原样出现过才算数,否则它可能来自输入(`eval(input())`) */
|
||||||
|
function nameFromCode(name: string | undefined, code: string) {
|
||||||
|
if (!name || !IDENTIFIER.test(name)) return undefined
|
||||||
|
return new RegExp(`\\b${name}\\b`).test(code) ? name : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从程序输出里解析 Python 回溯。stdout 和 stderr 在判题机里写的是同一个文件,
|
||||||
|
* 所以回溯接在正常输出后面;只看末尾,取最后一段 Traceback(链式异常取最外层)。
|
||||||
|
*
|
||||||
|
* `prependLines` 是题目模板拼在学生代码前面的行数:判题跑的是
|
||||||
|
* `${prepend}\n${code}\n${append}`,回溯里的行号要扣掉它才对得上编辑器。
|
||||||
|
*/
|
||||||
|
export function parsePythonTraceback(
|
||||||
|
output: string,
|
||||||
|
code: string,
|
||||||
|
prependLines: number,
|
||||||
|
): RuntimeErrorInfo | null {
|
||||||
|
const tail = output.slice(-16_000)
|
||||||
|
const start = tail.lastIndexOf("Traceback (most recent call last):")
|
||||||
|
if (start < 0) return null
|
||||||
|
const lines = tail.slice(start).trimEnd().split("\n")
|
||||||
|
|
||||||
|
const last = lines[lines.length - 1]!
|
||||||
|
const head = last.match(/^([A-Za-z_][\w.]*)(?::\s?(.*))?$/)
|
||||||
|
if (!head) return null
|
||||||
|
const type = head[1]!.split(".").pop()!
|
||||||
|
const message = head[2] ?? ""
|
||||||
|
|
||||||
|
// 最后一个落在 solution.py 里的栈帧:错误发生在标准库里(statistics.mean([]) 之类)时,
|
||||||
|
// 最后一帧是库文件,学生要看的是自己调它的那一行
|
||||||
|
let sourceLine: number | null = null
|
||||||
|
for (const line of lines) {
|
||||||
|
const frame = line.match(/^\s*File "([^"]*)", line (\d+)/)
|
||||||
|
if (frame && frame[1]!.endsWith("solution.py")) sourceLine = Number(frame[2])
|
||||||
|
}
|
||||||
|
const codeLines = code.split("\n").length
|
||||||
|
const lineInCode = sourceLine === null ? null : sourceLine - prependLines
|
||||||
|
const info: RuntimeErrorInfo = {
|
||||||
|
line: lineInCode !== null && lineInCode >= 1 && lineInCode <= codeLines ? lineInCode : null,
|
||||||
|
type,
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = KINDS[type]?.find(([pattern]) => pattern.test(message))?.[1]
|
||||||
|
if (kind) info.kind = kind
|
||||||
|
|
||||||
|
if (type === "NameError" || type === "UnboundLocalError") {
|
||||||
|
const match = message.match(/^(?:name|cannot access local variable) '([^']+)'/)
|
||||||
|
const name = nameFromCode(match?.[1], code)
|
||||||
|
if (name) info.name = name
|
||||||
|
const suggestion = message.match(/Did you mean: '([^']+)'\?/)?.[1]
|
||||||
|
if (suggestion && IDENTIFIER.test(suggestion)) info.suggestion = suggestion
|
||||||
|
} else if (type === "AttributeError") {
|
||||||
|
const name = nameFromCode(message.match(/has no attribute '([^']+)'/)?.[1], code)
|
||||||
|
if (name) info.name = name
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C / C++:没有回溯,信号和退出码就是全部线索。两个都为 0 时没什么可说的 */
|
||||||
|
export function nativeRuntimeError(signal: unknown, exitCode: unknown): RuntimeErrorInfo | null {
|
||||||
|
const info: RuntimeErrorInfo = {}
|
||||||
|
if (typeof signal === "number" && signal > 0) info.signal = signal
|
||||||
|
if (typeof exitCode === "number" && exitCode !== 0) info.exit_code = exitCode
|
||||||
|
return info.signal || info.exit_code ? info : null
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ onUnmounted(clearErrorMark)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card embedded>
|
<n-card embedded class="explain-card">
|
||||||
<n-flex vertical :size="12">
|
<n-flex vertical :size="12">
|
||||||
<template v-if="explanation">
|
<template v-if="explanation">
|
||||||
<div class="explain">
|
<div class="explain">
|
||||||
@@ -95,6 +95,11 @@ onUnmounted(clearErrorMark)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 结果弹窗不限宽,长句子不折行会把弹窗撑出屏幕 */
|
||||||
|
.explain-card {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
.explain {
|
.explain {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { clearErrorMark, showErrorMark } from "oj/problem/utils/errorMark"
|
||||||
|
import { explainRuntimeError, type RuntimeErrorInfo } from "oj/problem/utils/runtimeError"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时错误的中文说明卡片(诊断由后端 judge/runtime-diagnosis.ts 给)。
|
||||||
|
* 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
info: RuntimeErrorInfo
|
||||||
|
/** 这次提交的代码,用来摆出出错的那一行 */
|
||||||
|
code: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const message = computed(() => explainRuntimeError(props.info))
|
||||||
|
|
||||||
|
const sourceLine = computed(() => {
|
||||||
|
const line = props.info.line
|
||||||
|
if (!line) return null
|
||||||
|
const text = props.code.split("\n")[line - 1]
|
||||||
|
return text?.trim() ? text.trim() : null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.info.line,
|
||||||
|
(line) => {
|
||||||
|
if (line) showErrorMark(line, null, null)
|
||||||
|
else clearErrorMark()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(clearErrorMark)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card embedded class="explain-card">
|
||||||
|
<n-flex vertical :size="12">
|
||||||
|
<div class="explain">
|
||||||
|
<b v-if="info.line">第 {{ info.line }} 行运行时出错:</b>{{ message }}
|
||||||
|
</div>
|
||||||
|
<pre v-if="sourceLine" class="explain-code">{{ sourceLine }}</pre>
|
||||||
|
</n-flex>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 结果弹窗不限宽,长句子不折行会把弹窗撑出屏幕 */
|
||||||
|
.explain-card {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-code {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
background-color: rgba(208, 48, 80, 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,8 @@ import type { Submission } from "utils/types"
|
|||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
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 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"
|
||||||
@@ -102,21 +104,41 @@ const pythonCompileError = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 错误信息格式化
|
// 错误信息格式化
|
||||||
|
/**
|
||||||
|
* 运行时错误的诊断(行号 + 归类),交给 RuntimeErrorExplain。比赛提交、诊断失败的
|
||||||
|
* 没有这个字段(见后端 judge/run.ts 的 diagnoseRuntimeError),走下面 msg 的通用提示。
|
||||||
|
*/
|
||||||
|
const runtimeError = computed(() => {
|
||||||
|
const submission = props.submission
|
||||||
|
if (!submission || submission.result !== SubmissionStatus.runtime_error) return 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 那张中文卡片,英文原文在它的折叠区里
|
// 走 PythonErrorExplain / RuntimeErrorExplain 那两张中文卡片
|
||||||
if (pythonCompileError.value) return ""
|
if (pythonCompileError.value || runtimeError.value) return ""
|
||||||
|
|
||||||
let msg = ""
|
let msg = ""
|
||||||
const result = props.submission.result
|
const result = props.submission.result
|
||||||
|
|
||||||
// 编译错误或运行时错误时给出提示;
|
// 编译错误或运行时错误时给出提示;
|
||||||
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
|
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
|
||||||
if (
|
if (props.submission.language !== "SQL") {
|
||||||
(result === SubmissionStatus.compile_error || result === SubmissionStatus.runtime_error) &&
|
if (result === SubmissionStatus.compile_error) {
|
||||||
props.submission.language !== "SQL"
|
|
||||||
) {
|
|
||||||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||||||
|
} else if (result === SubmissionStatus.runtime_error) {
|
||||||
|
// 原来和编译错误共用「代码格式写错了」那句,可运行时错误恰恰是格式没错、跑起来才出事
|
||||||
|
msg +=
|
||||||
|
"程序运行到一半出错,停下来了。检查一下输入是怎么读的、下标有没有越界、除数是不是 0。\n\n"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result !== SubmissionStatus.ast_check_failed && props.submission.statisticInfo?.err_info) {
|
if (result !== SubmissionStatus.ast_check_failed && props.submission.statisticInfo?.err_info) {
|
||||||
@@ -270,12 +292,21 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
|||||||
vertical
|
vertical
|
||||||
v-if="
|
v-if="
|
||||||
pythonCompileError ||
|
pythonCompileError ||
|
||||||
|
runtimeError ||
|
||||||
|
sampleCheck ||
|
||||||
msg ||
|
msg ||
|
||||||
infoTable.length ||
|
infoTable.length ||
|
||||||
submission.statisticInfo?.ast_results?.length
|
submission.statisticInfo?.ast_results?.length
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<PythonErrorExplain v-if="pythonCompileError" :err-info="pythonCompileError" />
|
<PythonErrorExplain v-if="pythonCompileError" :err-info="pythonCompileError" />
|
||||||
|
<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>
|
||||||
@@ -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,89 @@
|
|||||||
|
import type { StatisticInfo } from "@oj2/contract"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把运行时错误的诊断(`statistic_info.runtime_error`,后端 judge/runtime-diagnosis.ts
|
||||||
|
* 写的)翻成给中职学生看的中文。界面上不出现英文的异常名。
|
||||||
|
*
|
||||||
|
* 诊断里只有异常类型、归好类的 kind 和学生代码里的名字,**没有异常的原文消息**
|
||||||
|
* (原文里常带着隐藏测试点的输入),所以这里的说明都是按类型写的通用话,
|
||||||
|
* 定位靠行号和编辑器里的标红。
|
||||||
|
*/
|
||||||
|
export type RuntimeErrorInfo = NonNullable<StatisticInfo["runtime_error"]>
|
||||||
|
|
||||||
|
/** 说得最多的一句:input() 读进来的是文字。学生栽在类型上,十有八九是这个 */
|
||||||
|
const INPUT_IS_TEXT = "input() 读进来的是文字,要计算先用 int() 或 float() 转成数字。"
|
||||||
|
|
||||||
|
const BY_KIND: Record<string, string> = {
|
||||||
|
"int-parse":
|
||||||
|
"把输入转成整数的时候失败了:读到的内容不是一个整数。常见原因:一行里输入了好几个数,要先用 split() 拆开再转换;或者输入的是小数,要用 float()。",
|
||||||
|
"float-parse":
|
||||||
|
"把输入转成数字的时候失败了:读到的内容不是一个数。常见原因:一行里输入了好几个数,要先用 split() 拆开再转换。",
|
||||||
|
unpack:
|
||||||
|
"等号左边的变量个数,和右边拆出来的值的个数对不上。比如 a, b = input().split() 要求这一行正好有两个值,对照题目的输入格式看看一行里有几个数。",
|
||||||
|
"math-domain": "数学函数拿到了不合法的值,比如给负数开平方。",
|
||||||
|
"str-concat": `文字和数字不能直接用 + 连在一起。${INPUT_IS_TEXT}要拼成一句话,就用 str() 把数字转成文字。`,
|
||||||
|
operand: `这两个值的类型不能做这种运算,多半是拿文字和数字一起算。${INPUT_IS_TEXT}`,
|
||||||
|
"not-int":
|
||||||
|
"这里需要一个整数,给的却是文字或小数。比如 range() 里要放整数,input() 读进来的要先用 int() 转换。",
|
||||||
|
compare: `文字和数字不能比大小。${INPUT_IS_TEXT}`,
|
||||||
|
"not-callable":
|
||||||
|
"把一个不是函数的东西当成函数用了。常见原因:乘法漏写了 *(比如 2(a+b) 要写成 2*(a+b)),或者拿 print、input、sum 这类名字当了变量名。",
|
||||||
|
"not-subscriptable": "对一个数字用了下标 [ ]。只有列表、字符串这类值才能用 [ ] 取出其中一个。",
|
||||||
|
"missing-arg": "调用函数的时候少给了参数。",
|
||||||
|
"index-type": "下标要用整数。input() 读进来的是文字,要先用 int() 转换。",
|
||||||
|
}
|
||||||
|
|
||||||
|
const BY_TYPE: Record<string, string> = {
|
||||||
|
ValueError: "这里用到的值不符合要求。",
|
||||||
|
TypeError: `这里的值类型不对,比如把文字当成数字来用。${INPUT_IS_TEXT}`,
|
||||||
|
IndexError:
|
||||||
|
"下标越界了:要取的位置超出了列表或字符串的长度。下标从 0 开始,长度是 n 的话,最后一个是 [n-1]。",
|
||||||
|
UnboundLocalError: "函数里的这个变量还没赋值就先用了。",
|
||||||
|
ZeroDivisionError: "除数是 0 了(除法 / 、整除 // 或者取余 %)。做除法之前先判断除数是不是 0。",
|
||||||
|
EOFError:
|
||||||
|
"程序读的输入比题目给的多:input() 用的次数超过了输入的行数。看看输入格式,如果几个数在同一行,要用一次 input() 读进来,再用 split() 拆开。",
|
||||||
|
KeyError: "字典里没有这个键。取之前先用 in 判断一下键在不在字典里。",
|
||||||
|
RecursionError: "函数自己调用自己的层数太多了,检查一下递归有没有写结束条件。",
|
||||||
|
ModuleNotFoundError: "导入的模块不存在,判题的环境里只有 Python 自带的模块。",
|
||||||
|
ImportError: "导入的模块或者名字不存在,判题的环境里只有 Python 自带的模块。",
|
||||||
|
MemoryError: "程序占用的内存太多了。",
|
||||||
|
OverflowError: "算出来的数太大了,超出了能表示的范围。",
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainPython(info: RuntimeErrorInfo) {
|
||||||
|
if (info.type === "NameError") {
|
||||||
|
if (!info.name)
|
||||||
|
return "用到了一个没有定义过的名字:检查拼写和大小写,或者是不是还没赋值就先用了。"
|
||||||
|
if (info.suggestion)
|
||||||
|
return `「${info.name}」这个名字没有定义过,是不是想写「${info.suggestion}」?`
|
||||||
|
return `「${info.name}」这个名字没有定义过:检查拼写和大小写,或者是不是还没赋值就先用了。`
|
||||||
|
}
|
||||||
|
if (info.type === "AttributeError") {
|
||||||
|
const what = info.name ? `「${info.name}」这个` : "你用的这个"
|
||||||
|
return `这个值没有${what}方法或属性:可能是拼错了,或者这个值的类型不对。`
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(info.kind && BY_KIND[info.kind]) ||
|
||||||
|
(info.type && BY_TYPE[info.type]) ||
|
||||||
|
"程序运行到这里出错,停下来了。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainNative(info: RuntimeErrorInfo) {
|
||||||
|
switch (info.signal) {
|
||||||
|
case 11:
|
||||||
|
return "程序访问了不该访问的内存。常见原因:scanf 的变量前面忘了写 &;数组下标越界了;数组开得太大(可以挪到 main 外面定义)。"
|
||||||
|
case 8:
|
||||||
|
return "整数除以 0 了:除法 / 或者取余 % 的右边是 0。"
|
||||||
|
case 6:
|
||||||
|
return "程序被强制终止了,常见原因是内存访问出错、数组越界,C++ 里也可能是有异常没有处理。"
|
||||||
|
}
|
||||||
|
if (info.signal) return "程序运行的时候崩溃了。"
|
||||||
|
if (info.exit_code)
|
||||||
|
return `程序结束时返回的是 ${info.exit_code} 而不是 0:main 函数的最后要写 return 0;`
|
||||||
|
return "程序运行到一半出错,停下来了。"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function explainRuntimeError(info: RuntimeErrorInfo) {
|
||||||
|
return info.type ? explainPython(info) : explainNative(info)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -77,6 +77,50 @@ export const statisticInfoSchema = z.looseObject({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/**
|
||||||
|
* 运行时错误的诊断,judge/runtime-diagnosis.ts 写,前端翻成中文。只在非比赛提交上有。
|
||||||
|
*
|
||||||
|
* **不存异常的原文消息**:Python 的消息里常带着测试点的输入(`invalid literal for
|
||||||
|
* int() with base 10: '1 2'`),给学生看就等于放出隐藏数据。所以只存归好类的
|
||||||
|
* `kind`,和确实出现在学生自己代码里的名字。
|
||||||
|
*/
|
||||||
|
runtime_error: z
|
||||||
|
.object({
|
||||||
|
/** 学生代码里的行号(已经扣掉题目模板的前置代码),对不上时为 null */
|
||||||
|
line: z.number().int().nullable().optional(),
|
||||||
|
/** Python 异常类型,如 ValueError */
|
||||||
|
type: z.string().optional(),
|
||||||
|
/** 细分,如 int-parse / index / str-concat,见 runtime-diagnosis.ts 的 KINDS */
|
||||||
|
kind: z.string().optional(),
|
||||||
|
/** NameError / AttributeError 点名的那个名字,只在它出现在学生代码里时才存 */
|
||||||
|
name: z.string().optional(),
|
||||||
|
/** NameError 的「Did you mean」,Python 从作用域里的名字挑的,不来自输入 */
|
||||||
|
suggestion: z.string().optional(),
|
||||||
|
/** C / C++:进程收到的信号(11 段错误、8 除零……)和退出码 */
|
||||||
|
signal: z.number().int().optional(),
|
||||||
|
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(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user