From 2a4405d2eafd45e54b48c02e8ca6b801e18c655c Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 21:47:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=98=E7=9B=AE):=20Python=20=E8=AF=AD?= =?UTF-8?q?=E6=B3=95=E9=94=99=E8=AF=AF=E6=94=B9=E6=88=90=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=8C=E5=9C=A8=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E9=87=8C=E6=A0=87=E5=87=BA=E4=BD=8D=E7=BD=AE=EF=BC=8C=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=A0=87=E7=82=B9=E5=8F=AF=E4=BB=A5=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=8D=A2=E6=88=90=E8=8B=B1=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中职学生看不懂英文报错。原来提交前用浏览器里的 Skulpt 拦语法错误,只弹一句 「第 N 行存在语法错误」——Skulpt 九成报的是 bad input / bad token,没有列号。 - 提交前的语法检查挪到服务端:/code/format 先用 CPython 编译一遍(只编译不执行), api 镜像加 python3-minimal(3.13,与判题机同版本,+17MB),前端删掉 Skulpt - 报错按规则表翻成中文(pythonError.ts),生产库 2695 条 Python 编译错误覆盖 99.7%; 提交前拦下的和判题回来的编译错误共用同一张卡片 - 编辑器里标红出错行和 ^ 指的位置 - 中文标点(历史编译错误的 22%)一键换成英文,跳过字符串和注释、处理 f 字符串花括号; 594 条里修好 593 条,经 tokenize 核对没有改到字符串内容;走编辑器事务,可 Ctrl+Z Co-Authored-By: Claude Opus 5.5 --- apps/api/src/config.ts | 3 + apps/api/src/routes/submission.ts | 3 +- apps/api/src/services/format-code.ts | 41 ++- apps/web/package.json | 1 - .../oj/problem/components/ProblemEditor.vue | 6 +- .../problem/components/PythonErrorExplain.vue | 125 ++++++++ .../problem/components/SubmissionResult.vue | 29 +- .../src/oj/problem/components/SubmitCode.vue | 53 ++-- .../oj/problem/utils/chinesePunctuation.ts | 94 ++++++ apps/web/src/oj/problem/utils/errorMark.ts | 123 ++++++++ apps/web/src/oj/problem/utils/pythonError.ts | 274 ++++++++++++++++++ .../src/oj/problem/utils/pythonSyntaxCheck.ts | 53 ---- apps/web/vite.config.ts | 8 +- bun.lock | 5 - docker/Dockerfile | 10 +- docker/judge/README.md | 5 + 16 files changed, 733 insertions(+), 100 deletions(-) create mode 100644 apps/web/src/oj/problem/components/PythonErrorExplain.vue create mode 100644 apps/web/src/oj/problem/utils/chinesePunctuation.ts create mode 100644 apps/web/src/oj/problem/utils/errorMark.ts create mode 100644 apps/web/src/oj/problem/utils/pythonError.ts delete mode 100644 apps/web/src/oj/problem/utils/pythonSyntaxCheck.ts diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 1db3115..2f1f842 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -87,6 +87,7 @@ const envSchema = z.object({ AI_HINT_DIAGNOSE: z.preprocess(blankAsUnset, z.enum(["1", "0"]).optional()), RUFF_PATH: text("ruff"), CLANG_FORMAT_PATH: text("clang-format"), + PYTHON_PATH: text("python3"), }) function parseEnv() { @@ -171,4 +172,6 @@ export const config = { aiHintDiagnose: env.AI_HINT_DIAGNOSE === "1", ruffPath: env.RUFF_PATH, clangFormatPath: env.CLANG_FORMAT_PATH, + /** 提交前的 Python 语法检查用(services/format-code.ts),要和判题机同一个大版本 */ + pythonPath: env.PYTHON_PATH, } diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 07b31e5..d54f782 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -239,7 +239,8 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => { return failure( c, error.kind === "syntax" ? 400 : 500, - error.kind === "syntax" ? "format-error" : "format-tool-error", + // syntax-error 的 message 是 CPython 的报错原文,前端拿去翻成中文 + error.kind === "syntax" ? "syntax-error" : "format-tool-error", error.message, ) } diff --git a/apps/api/src/services/format-code.ts b/apps/api/src/services/format-code.ts index 1752cd5..5bf71c2 100644 --- a/apps/api/src/services/format-code.ts +++ b/apps/api/src/services/format-code.ts @@ -47,16 +47,55 @@ function formatSql(code: string) { ) } +/** + * 用 CPython 编译一遍(只编译不执行),有语法错误就返回报错原文,没有返回 null。 + * + * 这一步原来在浏览器里用 Skulpt 做,但 Skulpt 的报错九成是「bad input」「bad token」、 + * 只有行号没有列号,前端没法据此告诉学生错在哪。换成和判题机同版本的 CPython 之后, + * 报错和判题机的编译错误一字不差,前端那张中文翻译表(pythonError.ts)两边通用。 + * + * 输出刻意对齐判题机的 `err_info`:`format_exception_only` 再整体 strip,文件名也叫 + * solution.py。子进程里不 import 标准库之外的东西,python3-minimal 就够。 + * + * 解释器起不来(开发机没装、路径配错)就当没检查,返回 undefined —— 放行之后判题机 + * 照样会判出编译错误,只是慢几秒。 + */ +const SYNTAX_CHECK_SCRIPT = ` +import sys, traceback +source = sys.stdin.buffer.read().decode("utf-8", "replace") +try: + compile(source, "solution.py", "exec") +except SyntaxError as error: + sys.stdout.write("".join(traceback.format_exception_only(error)).strip()) + sys.exit(1) +` + +async function checkPythonSyntax(code: string): Promise { + let result: Awaited> + try { + result = await runFormatter([config.pythonPath, "-I", "-c", SYNTAX_CHECK_SCRIPT], code) + } catch { + return undefined + } + if (result.exitCode === 0) return null + // 退出码 1 且有输出才是语法错误;别的(被超时杀掉、解释器自己报错)都当没检查 + return result.exitCode === 1 && result.stdout ? result.stdout : undefined +} + export async function formatCode(code: string, language: "python" | "c" | "cpp" | "sql") { if (language === "sql") return formatSql(code) if (language === "python") { + const syntaxError = await checkPythonSyntax(code) + if (syntaxError) throw new CodeFormatError(syntaxError, "syntax") const result = await runFormatter( [config.ruffPath, "format", "--stdin-filename", "code.py", "-"], code, ) + // CPython 都编译过了 ruff 还失败,是 ruff 的问题(比如还不认识新语法), + // 不是学生的:按工具故障报,前端会原样提交 if (result.exitCode !== 0) { - throw new CodeFormatError(result.stderr || "Invalid Python syntax", "syntax") + throw new CodeFormatError(result.stderr || "ruff format failed", "tool") } return result.stdout } diff --git a/apps/web/package.json b/apps/web/package.json index 4ec69b6..e148c44 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,7 +47,6 @@ "normalize.css": "^8.0.1", "pinia": "^4.0.3", "pinyin-pro": "^3.29.3", - "skulpt": "^1.2.0", "vue": "^3.5.41", "vue-chartjs": "^5.3.4", "vue-codemirror": "^6.1.1", diff --git a/apps/web/src/oj/problem/components/ProblemEditor.vue b/apps/web/src/oj/problem/components/ProblemEditor.vue index 8f4d466..8f4b20d 100644 --- a/apps/web/src/oj/problem/components/ProblemEditor.vue +++ b/apps/web/src/oj/problem/components/ProblemEditor.vue @@ -9,6 +9,7 @@ import { useBreakpoints } from "shared/composables/breakpoints" import storage from "utils/storage" import type { LANGUAGE } from "utils/types" import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace" +import { errorMarkExtensions } from "oj/problem/utils/errorMark" import EditorToolbar from "./EditorToolbar.vue" const FlowchartEditor = defineAsyncComponent( @@ -79,6 +80,9 @@ watch( const { isDesktop } = useBreakpoints() +// 拼一次、留同一个数组:每次渲染新建会让编辑器反复重配扩展 +const editorExtensions = [...editTraceExtensions, ...errorMarkExtensions] + const contestID = route.params.contestID || null const storageKey = computed( () => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`, @@ -146,7 +150,7 @@ provide("flowchartEditorRef", flowchartEditorRef) :language="codeStore.code.language" :problem-id="problem!._id" :height="editorHeight" - :extra-extensions="editTraceExtensions" + :extra-extensions="editorExtensions" @update:model-value="changeCode" /> diff --git a/apps/web/src/oj/problem/components/PythonErrorExplain.vue b/apps/web/src/oj/problem/components/PythonErrorExplain.vue new file mode 100644 index 0000000..b412de4 --- /dev/null +++ b/apps/web/src/oj/problem/components/PythonErrorExplain.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index 8975059..350991d 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -14,6 +14,7 @@ import { import type { Submission } from "utils/types" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import { useProblemStore } from "oj/store/problem" +import PythonErrorExplain from "./PythonErrorExplain.vue" import { useAIStream } from "shared/composables/aiStream" import { submitHintFeedback } from "oj/api" import { MdPreview } from "md-editor-v3" @@ -84,9 +85,27 @@ function resetHint() { onUnmounted(stopTyping) +/** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */ +function stripJudgePath(text: string) { + return text.replace(/\/judger\/run\/[^/"]+\//g, "") +} + +/** + * Python 的编译错误交给 PythonErrorExplain 翻成中文。只管 Python:C / C++ 的 + * gcc 报错另是一套句式,还没做。 + */ +const pythonCompileError = computed(() => { + const submission = props.submission + if (!submission || submission.result !== SubmissionStatus.compile_error) return "" + if (submission.language !== "Python") return "" + return submission.statisticInfo?.err_info ?? "" +}) + // 错误信息格式化 const msg = computed(() => { if (!props.submission) return "" + // 走 PythonErrorExplain 那张中文卡片,英文原文在它的折叠区里 + if (pythonCompileError.value) return "" let msg = "" const result = props.submission.result @@ -101,7 +120,7 @@ const msg = computed(() => { } if (result !== SubmissionStatus.ast_check_failed && props.submission.statisticInfo?.err_info) { - msg += props.submission.statisticInfo.err_info + msg += stripJudgePath(props.submission.statisticInfo.err_info) } return msg @@ -249,8 +268,14 @@ const columns: DataTableColumn[] = [ + import("./ProblemReaction.vue")) // 结果面板第一次弹出(也就是第一次提交)时才加载:它带着 DataTable,而判题要等 // 好几秒,这点下载时间藏得住。进页面就加载的话,只看题不提交的人也要付这笔 const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.vue")) +const PythonErrorExplain = defineAsyncComponent(() => import("./PythonErrorExplain.vue")) // ==================== 基础状态 ==================== const userStore = useUserStore() @@ -34,7 +34,6 @@ const problemSetId = (route.params.problemSetId as string) ?? "" const router = useRouter() const [commentPanel] = useToggle() -const message = useMessage() function closeCommentPanel() { commentPanel.value = false @@ -52,19 +51,14 @@ const showResult = ref(false) const isFormatting = ref(false) const isSubmittingRequest = ref(false) -// ==================== Python 语法检测器预取 ==================== -// 选中 Python 时就把 Skulpt 拉下来,避免点提交时才开始下载。 -// 但它有 ~226KB gzip,比题面和编辑器加起来还大:没登录的提交不了,不拉; -// 登录了也等浏览器空闲再拉,别和首屏的题面、编辑器抢带宽 -watch( - () => codeStore.code.language === "Python" && userStore.isAuthed, - (needed) => { - if (!needed) return - if ("requestIdleCallback" in window) requestIdleCallback(prefetchPythonSyntaxChecker) - else setTimeout(prefetchPythonSyntaxChecker, 1000) - }, - { immediate: true }, -) +/** + * 提交前语法检查查出来的错误(CPython 的报错原文),有它时结果面板显示中文说明、 + * 不显示上一次的判题结果。这次没交上去,所以不是一条提交、也不数进失败次数。 + * + * 检查在服务端做(`/code/format` 里先用 CPython 编译一遍),原来是浏览器里的 Skulpt: + * 那个只能报「第 N 行有错」,而且要下载 ~226KB。 + */ +const syntaxErrorInfo = ref("") // ==================== 提交冷却 ==================== const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, { @@ -115,16 +109,10 @@ const buttonState = computed(() => async function submit() { if (buttonState.value.disabled) return - // 0. Python 语法检测 - if (codeStore.code.language === "Python") { - const syntaxError = await checkPythonSyntax(codeStore.code.value) - if (syntaxError) { - message.warning(`第 ${syntaxError.line} 行存在语法错误,请修正后再提交`) - return - } - } + syntaxErrorInfo.value = "" - // 0.5 提交前自动格式化(Python 用 ruff,C/C++ 用 clang-format,SQL 用 sqlparse) + // 0. 提交前自动格式化(Python 用 ruff,C/C++ 用 clang-format,SQL 用 sqlparse)。 + // Python 在格式化之前先由服务端的 CPython 查一遍语法,有错就不提交 const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language] if (["python", "c", "cpp", "sql"].includes(formatLang)) { isFormatting.value = true @@ -135,9 +123,10 @@ async function submit() { }) codeStore.setCode(res.code) } catch (e) { - if (errorCode(e) === "format-error") { - // 仅 Python 会出现:代码本身存在语法错误 - message.warning(`代码格式化失败:${errorMessage(e)},请检查代码后重试`) + if (errorCode(e) === "syntax-error") { + // 仅 Python 会出现:message 是 CPython 的报错原文,交给 PythonErrorExplain 翻译 + syntaxErrorInfo.value = errorMessage(e) + showResult.value = true return } // server-error / 网络异常:格式化工具问题,静默降级,提交原代码 @@ -268,15 +257,19 @@ watch( - - + + + + + + diff --git a/apps/web/src/oj/problem/utils/chinesePunctuation.ts b/apps/web/src/oj/problem/utils/chinesePunctuation.ts new file mode 100644 index 0000000..b000eeb --- /dev/null +++ b/apps/web/src/oj/problem/utils/chinesePunctuation.ts @@ -0,0 +1,94 @@ +import { PUNCTUATION_MAP } from "./pythonError" + +export interface PunctuationFix { + from: number + to: number + insert: string +} + +/** + * 找出 Python 代码里**字符串和注释之外**的中文标点,给出换成英文的改动。 + * + * 字符串里的中文标点是合法的,`print("你好,世界")` 里那个逗号动了就改了输出, + * 所以不能整篇替换。中文引号「“ ”」另算:它们出现在代码里,本身就是没切输入法 + * 打出来的字符串引号,要换成英文引号,而**它们中间的内容原样保留** —— 否则 + * `print(“你好,世界”)` 会被改成 `print("你好,世界")`,输出跟着变了。 + * + * f 字符串花括号里的是代码不是文字(`f"{s:.2f}"`、`f"{len(a)}"`),照样要换; + * `{{` 是转义出来的字面花括号,不算。拿生产库 591 条中文标点的编译错误核过: + * 修完之后报错点名的那个字符还在的只剩 1 条,是学生自己的引号就是乱的。 + * + * 不是完整的词法分析:只认引号、三引号、字符串前缀、反斜杠转义和 `#` 注释, + * 这对学生代码够用。单引号字符串遇到换行就当它结束,免得一个没配对的引号把 + * 后面整篇都当成字符串。 + */ +export function findChinesePunctuation(code: string): PunctuationFix[] { + const fixes: PunctuationFix[] = [] + + function fix(at: number) { + const english = PUNCTUATION_MAP[code[at]!] + if (english) fixes.push({ from: at, to: at + 1, insert: english }) + } + + /** 从 start 往后扫一个字符串的内容,返回结束位置(收尾引号之后) */ + function skipString(start: number, triple: boolean, fstring: boolean, closers: string[]) { + let depth = 0 + let j = start + while (j < code.length) { + const ch = code[j]! + if (ch === "\\") { + j += 2 + continue + } + if (!triple && ch === "\n") return j + if (fstring) { + if (ch === "{" && depth === 0 && code[j + 1] === "{") { + j += 2 + continue + } + if (ch === "{") depth++ + else if (ch === "}" && depth > 0) depth-- + else if (depth > 0) fix(j) + if (depth > 0 || ch === "{" || ch === "}") { + j++ + continue + } + } + const closer = closers.find((c) => code.startsWith(c, j)) + if (closer) { + // 中文收尾引号也要换成英文 + if (closer.length === 1) fix(j) + return j + closer.length + } + j++ + } + return j + } + + let i = 0 + while (i < code.length) { + const ch = code[i]! + if (ch === "#") { + const end = code.indexOf("\n", i) + i = end === -1 ? code.length : end + continue + } + if (ch === '"' || ch === "'") { + // 前缀是紧挨着引号的 f / r / b / u 组合,前面不能再连着别的标识符字符 + const prefix = code.slice(0, i).match(/(?() + +const lineDeco = Decoration.line({ class: "cm-error-line" }) +const rangeDeco = Decoration.mark({ class: "cm-error-range" }) + +function build(state: EditorState, mark: Mark): DecorationSet { + if (mark.line < 1 || mark.line > state.doc.lines) return Decoration.none + const line = state.doc.line(mark.line) + const ranges = [lineDeco.range(line.from)] + if (mark.from !== undefined && mark.to !== undefined) { + const from = line.from + Math.min(mark.from, line.length) + const to = line.from + Math.min(mark.to, line.length) + if (to > from) ranges.push(rangeDeco.range(from, to)) + } + return Decoration.set(ranges) +} + +const markField = StateField.define({ + create: () => Decoration.none, + update(deco, tr) { + deco = deco.map(tr.changes) + for (const effect of tr.effects) { + if (effect.is(setMark)) deco = effect.value ? build(tr.state, effect.value) : Decoration.none + } + return deco + }, + provide: (field) => EditorView.decorations.from(field), +}) + +/** + * 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层, + * 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上 + * (ProblemEditor 的扩展数组),自测、流程图那些编辑器不登记。 + */ +let current: EditorView | null = null + +const tracker = ViewPlugin.define((view) => { + current = view + return { + destroy() { + if (current === view) current = null + }, + } +}) + +const theme = EditorView.baseTheme({ + ".cm-error-line": { backgroundColor: "rgba(208, 48, 80, 0.12)" }, + ".cm-error-range": { + textDecoration: "underline wavy #d03050", + textUnderlineOffset: "3px", + backgroundColor: "rgba(208, 48, 80, 0.18)", + }, +}) + +export const errorMarkExtensions = [markField, tracker, theme] + +/** + * 标出第 `line` 行并滚过去。`sourceLine` 是报错里打出来的那一行(去了行首空格), + * 编辑器里这一行对得上才标列范围 —— 学生看结果之前可能已经改过代码了, + * 对不上时列号没有意义,只标整行。 + */ +export function showErrorMark( + line: number, + sourceLine: string | null, + caret: { from: number; to: number } | null, +) { + const view = current + if (!view || line < 1 || line > view.state.doc.lines) return + const text = view.state.doc.line(line).text + const mark: Mark = { line } + if (caret && sourceLine !== null) { + const stripped = text.replace(/^ +/, "") + if (stripped.trimEnd() === sourceLine.trimEnd()) { + const indent = text.length - stripped.length + mark.from = indent + caret.from + mark.to = indent + caret.to + } + } + const pos = view.state.doc.line(line).from + view.dispatch({ + effects: [setMark.of(mark), EditorView.scrollIntoView(pos, { y: "center" })], + }) +} + +export function clearErrorMark() { + current?.dispatch({ effects: setMark.of(null) }) +} + +/** 当前编辑器里的代码;编辑器没挂载时为 null */ +export function currentCode() { + return current?.state.doc.toString() ?? null +} + +/** + * 在编辑器里批量替换。走编辑器事务而不是直接改 store 里的字符串: + * 这样一次 Ctrl+Z 就能撤回,协作中的对方也能同步到。 + * + * 不打 userEvent:editTrace 按 `isUserEvent("input")` 数「自己敲的字」, + * 打成 `input.*` 就会把这次替换算成学生手打的。 + */ +export function applyFixes(fixes: PunctuationFix[]) { + if (!current || !fixes.length) return false + current.dispatch({ changes: fixes, effects: setMark.of(null) }) + return true +} diff --git a/apps/web/src/oj/problem/utils/pythonError.ts b/apps/web/src/oj/problem/utils/pythonError.ts new file mode 100644 index 0000000..5db2f1b --- /dev/null +++ b/apps/web/src/oj/problem/utils/pythonError.ts @@ -0,0 +1,274 @@ +/** + * 把 Python 的编译错误(`statistic_info.err_info`)翻成中文。 + * + * 学生是中职生,英文报错等于没有报错。好在句式非常集中:2025 秋以来 2700 条 + * Python 编译错误里,前 12 种句子占 91%、前 16 种占 97%,所以这里是一张规则表, + * 不是通用翻译。第二名「invalid character」(22%)几乎全是中文标点,另有一键修复, + * 见 chinesePunctuation.ts。 + * + * 判题机给的原文有两种形状: + * + * File "/judger/run/<随机目录>/solution.py", line 7 + * print("{:.0f}"."{:.2f}".format(x)) + * ^^^^^^^^ + * SyntaxError: invalid syntax + * + * Sorry: IndentationError: unexpected indent (solution.py, line 3) + * + * 第一种的代码行是 Python 去掉**行首空格**之后再垫 4 个空格打出来的,`^` 的列号 + * 按去掉之后的算(traceback 的 `_format_syntax_error`,3.10 起如此)。 + */ + +export interface PythonErrorExplanation { + /** 出错的行,1 起 */ + line: number | null + /** 判题机打出来的那一行代码(去掉了行首空格),用来和编辑器里的对位 */ + sourceLine: string | null + /** `^` 在 sourceLine 里标的范围,[from, to) */ + caret: { from: number; to: number } | null + /** 给学生看的中文说明,不含行号 */ + message: string + /** 报错点名的那个中文标点;有它才出「一键换成英文」 */ + punctuation: string | null +} + +const WHERE_RE = /^File "[^"]*", line (\d+)/m +const SHORT_RE = /^Sorry: (\w+Error): (.*) \([^,()]*, line (\d+)\)\s*$/m + +/** + * 中文空格(U+3000)。写成常量而不是字面量:Oxfmt 会把字符串里的 `\u3000` 转义 + * 还原成原字符,那样源码里就是一个看不见的空白。 + */ +const IDEOGRAPHIC_SPACE = String.fromCharCode(0x3000) + +/** 中文标点 → 英文。「。」不在表里:它多半是句末多打的,换成「.」照样错,只能删 */ +export const PUNCTUATION_MAP: Record = { + "(": "(", + ")": ")", + ",": ",", + ":": ":", + ";": ";", + "【": "[", + "】": "]", + "{": "{", + "}": "}", + "!": "!", + "?": "?", + "=": "=", + "+": "+", + "-": "-", + "*": "*", + "/": "/", + "<": "<", + ">": ">", + "“": '"', + "”": '"', + "‘": "'", + "’": "'", + """: '"', + "×": "*", + "÷": "/", + [IDEOGRAPHIC_SPACE]: " ", +} + +/** + * 标点的名字。说明里**只说名字、不拿字形对比**:「:」和「:」、「,」和「,」在正文字体里 + * 几乎一样,「把「:」换成「:」」这句话学生看不出区别。「冒号是中文的,要换成英文的」 + * 看得懂,要换哪一个由卡片里那行代码的标红来指。 + */ +const PUNCTUATION_NAMES: Record = { + "(": "左括号", + ")": "右括号", + ",": "逗号", + ":": "冒号", + ";": "分号", + "【": "左方括号", + "】": "右方括号", + "{": "左花括号", + "}": "右花括号", + "!": "感叹号", + "?": "问号", + "=": "等号", + "+": "加号", + "-": "减号", + "*": "星号", + "/": "斜杠", + "<": "小于号", + ">": "大于号", + "“": "引号", + "”": "引号", + "‘": "单引号", + "’": "单引号", + """: "引号", + [IDEOGRAPHIC_SPACE]: "空格", +} + +type Rule = [RegExp, (match: RegExpMatchArray) => string] + +const RULES: Rule[] = [ + [ + /^invalid character '(.)'/, + ([, char]) => { + // 数学符号不是「中文的」,是写法不对:代码里乘除用 * 和 / + if (char === "×") return "这里用了乘号「×」,代码里的乘法要写成星号「*」。" + if (char === "÷") return "这里用了除号「÷」,代码里的除法要写成斜杠「/」。" + const name = PUNCTUATION_NAMES[char!] + if (name) { + return `这里的${name}是中文输入法打出来的,要换成英文的${name}。写代码的时候先切换到英文输入法。` + } + if (char === "。") return "这里多了一个中文句号「。」,代码里不用句号,删掉它。" + return `这里有一个不能出现在代码里的字符「${char}」,多半是中文输入法打出来的,删掉后用英文输入法重新打。` + }, + ], + [ + /^invalid non-printable character/, + () => "这里有一个看不见的特殊字符,多半是从别处复制过来的。把这一行删掉,自己重新打一遍。", + ], + [ + /^expected ':'/, + () => "这一行末尾少了英文冒号「:」。if、elif、else、for、while、def 这些行的最后都要有冒号。", + ], + [ + /^expected an indented block after '(\w+)' statement on line (\d+)/, + ([, keyword, owner]) => + `这一行要往里缩进(按一下 Tab 键):它是第 ${owner} 行那个 ${keyword} 要管的代码。`, + ], + [ + /^expected an indented block/, + () => "这一行要往里缩进(按一下 Tab 键):上一行以冒号结尾,它下面的代码都要缩进。", + ], + [ + /^unexpected indent/, + () => "这一行前面多了空格。只有 if、for 这类以冒号结尾的行,它下面的代码才要往里缩进。", + ], + [ + /^unindent does not match any outer indentation level/, + () => + "这一行前面的空格数和上面对不齐。同一层的代码,前面的空格要一样多,建议统一用 Tab 键缩进。", + ], + [ + /^inconsistent use of tabs and spaces/, + () => "这一行的缩进混用了 Tab 键和空格键,统一只用 Tab 键缩进。", + ], + [ + /^invalid syntax\. Perhaps you forgot a comma\?/, + () => "这里好像少了逗号。括号里的几样东西之间,要用英文逗号「,」隔开。", + ], + [ + /Maybe you meant '==' instead of '='|Maybe you meant '==' or ':=' instead of '='|perhaps you meant "=="/, + () => + "判断「是不是相等」要用两个等号「==」。一个等号「=」的意思是「把右边的值存进左边的变量」。", + ], + [ + /^cannot assign to /, + () => + "等号「=」的左边只能是一个变量名,意思是「把右边算出来的值存进这个变量」。比如要写成 c = a + b,不能写成 a + b = c。", + ], + [ + /^unterminated (triple-quoted )?(f-)?string literal/, + () => "这一行的引号没有配对。字符串要用一对英文引号括起来,开头一个、结尾一个。", + ], + [ + /^'(.)' was never closed/, + ([, open]) => `这里的「${open}」没有配对的另一半。数一数左右括号是不是一样多。`, + ], + [/^unmatched '(.)'/, ([, close]) => `这里多了一个「${close}」,前面没有和它配对的括号。`], + [ + /^closing parenthesis '(.)' does not match opening parenthesis '(.)'/, + ([, close, open]) => `括号配错了:「${open}」要用和它配对的括号来关,不能用「${close}」。`, + ], + [ + /^Missing parentheses in call to '(\w+)'/, + ([, name]) => `${name} 后面要加括号,写成 ${name}(...)。`, + ], + [ + /^invalid decimal literal/, + () => "数字后面紧跟着字母了。比如「2a」要写成「2*a」;变量名也不能用数字开头。", + ], + [ + /^unexpected character after line continuation character/, + () => "这里的反斜杠「\\」用错了。除法要用「/」,「\\」只能出现在引号里面。", + ], + [ + /^'(break|continue)' (not properly in|outside) loop/, + ([, keyword]) => `${keyword} 只能写在 for 或 while 循环里面(要缩进在循环的下面)。`, + ], + [ + /^'return' outside function/, + () => "return 只能写在 def 定义的函数里面(要缩进在 def 的下面)。", + ], + [ + /^f-string: (expecting '}'|single '}' is not allowed)/, + () => "f 字符串里的花括号「{ }」没有配对,每个「{」都要有一个「}」。", + ], + [ + /^f-string: valid expression required/, + () => "f 字符串的花括号「{ }」里要写变量名或算式,不能空着。", + ], + [ + /^leading zeros in decimal integer literals/, + () => "整数不能用 0 开头,比如「07」要写成「7」。", + ], + [ + /^invalid syntax/, + () => + "标出来的地方写法不对。对照教程检查这一行:括号、引号、冒号、运算符有没有写错、漏写或多写。", + ], +] + +function translate(message: string) { + for (const [pattern, render] of RULES) { + const match = message.match(pattern) + if (match) return render(match) + } + return null +} + +/** 翻不出来(判题机自己的报错、没见过的句式)就返回 null,界面照旧显示原文 */ +export function explainPythonCompileError(errInfo: string): PythonErrorExplanation | null { + const text = errInfo.trim() + + const short = text.match(SHORT_RE) + if (short) { + const [, , detail, line] = short + const message = translate(detail!) + if (!message) return null + return { + line: Number(line), + sourceLine: null, + caret: null, + message, + punctuation: null, + } + } + + const where = text.match(WHERE_RE) + const lines = text.split("\n") + const last = lines[lines.length - 1]! + const detail = last.replace(/^\w+Error: /, "") + if (detail === last) return null + const message = translate(detail) + if (!message) return null + + let sourceLine: string | null = null + let caret: PythonErrorExplanation["caret"] = null + if (where) { + const index = lines.findIndex((line) => WHERE_RE.test(line)) + const code = lines[index + 1] + const marks = lines[index + 2] + if (code?.startsWith(" ")) sourceLine = code.slice(4) + if (sourceLine !== null && marks?.startsWith(" ")) { + const from = marks.indexOf("^") + if (from >= 4) caret = { from: from - 4, to: marks.lastIndexOf("^") - 4 + 1 } + } + } + + const char = detail.match(/^invalid character '(.)'/)?.[1] + return { + line: where ? Number(where[1]) : null, + sourceLine, + caret, + message, + punctuation: char && PUNCTUATION_MAP[char] ? char : null, + } +} diff --git a/apps/web/src/oj/problem/utils/pythonSyntaxCheck.ts b/apps/web/src/oj/problem/utils/pythonSyntaxCheck.ts deleted file mode 100644 index 7cdb295..0000000 --- a/apps/web/src/oj/problem/utils/pythonSyntaxCheck.ts +++ /dev/null @@ -1,53 +0,0 @@ -export interface PythonSyntaxError { - line: number -} - -let skulptPromise: Promise | null = null - -/** - * 按需加载 Skulpt(约 233KB gzip),只在提交 Python 代码时才下载。 - * 结果缓存,同一页面只加载一次。 - */ -function loadSkulpt(): Promise { - if (!skulptPromise) { - // @ts-ignore - skulpt has no type definitions - skulptPromise = import("skulpt").then((m) => m.default ?? m) - } - return skulptPromise -} - -/** - * 提前把 Skulpt 拉下来,避免点提交时才开始下载。 - * 失败不影响功能,提交时会再试一次。 - */ -export function prefetchPythonSyntaxChecker() { - loadSkulpt().catch(() => { - skulptPromise = null - }) -} - -/** - * 用 Skulpt 检测 Python 代码中的语法错误。 - * 只编译不执行,不受 input() 等 IO 调用影响。 - * 加载失败时返回 null(放行提交),交给后端判题兜底。 - */ -export async function checkPythonSyntax(code: string): Promise { - let Sk: any - try { - Sk = await loadSkulpt() - } catch { - skulptPromise = null - return null - } - - Sk.configure({ output: () => {} }) - try { - Sk.compile(code, "prog.py", "exec") - return null - } catch (e) { - // Skulpt 的语法错误对象,不是 Error 的子类 - const traceback = (e as { traceback?: { lineno?: number }[] }).traceback - const line = traceback?.[0]?.lineno ?? 1 - return { line } - } -} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index f859b1e..36f6d30 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -136,10 +136,10 @@ export default defineConfig(() => { tsconfigPaths: true, }, build: { - // 超过 500 kB 的三个 chunk 都是懒加载的:skulpt(Python 语法检查,产物名叫 - // main-*.js,是 skulpt 自己入口的名字,不是应用入口)、wangeditor(后台富文本)、 - // @mermaid-js/parser。默认阈值下这条警告每次构建都在报,真涨了反而没人看, - // 所以抬到比它们略高。首屏入口 index-*.js 在 175 kB 左右 + // 超过 500 kB 的两个 chunk 都是懒加载的:wangeditor(后台富文本,TextEditor-*.js)、 + // @mermaid-js/parser,各 660 kB 上下。默认阈值下这条警告每次构建都在报,真涨了 + // 反而没人看,所以抬到比它们略高。首屏入口 index-*.js 在 175 kB 左右。 + // (原来还有 Python 语法检查用的 skulpt,语法检查挪到服务端的 CPython 之后删了) chunkSizeWarningLimit: 1000, }, server: { diff --git a/bun.lock b/bun.lock index a74a8be..b491534 100644 --- a/bun.lock +++ b/bun.lock @@ -76,7 +76,6 @@ "normalize.css": "^8.0.1", "pinia": "^4.0.3", "pinyin-pro": "^3.29.3", - "skulpt": "^1.2.0", "vue": "^3.5.41", "vue-chartjs": "^5.3.4", "vue-codemirror": "^6.1.1", @@ -1295,8 +1294,6 @@ "js-tokens": ["js-tokens@4.0.0", "https://registry.npmjs.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "jsbi": ["jsbi@3.2.5", "https://registry.npmjs.com/jsbi/-/jsbi-3.2.5.tgz", {}, "sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ=="], - "jsesc": ["jsesc@3.1.0", "https://registry.npmjs.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "https://registry.npmjs.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -1525,8 +1522,6 @@ "sirv": ["sirv@3.0.2", "https://registry.npmjs.com/sirv/-/sirv-3.0.2.tgz", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], - "skulpt": ["skulpt@1.2.0", "https://registry.npmjs.com/skulpt/-/skulpt-1.2.0.tgz", { "dependencies": { "jsbi": "^3.1.4" } }, "sha512-T0cv0sdSOXLlIJTuyXSeYJ3TFdWYSZfX2PcBLpRKrKZ3dTbVQXRMiYudSuko2xzcyMif47DS2ShatCwjCbsSsA=="], - "slate": ["slate@0.124.1", "https://registry.npmjs.com/slate/-/slate-0.124.1.tgz", {}, "sha512-ii7DwezgvbLAyKtHBIunjTR1kzbNfYLCUKLMzJELlbTZkvHzX4DzN7HKIwcakf6dPxO6AoeT/P7kHOcyTym/hA=="], "slate-history": ["slate-history@0.115.0", "https://registry.npmjs.com/slate-history/-/slate-history-0.115.0.tgz", { "peerDependencies": { "slate": ">=0.114.3" } }, "sha512-QdUm9aVyQFz6JG4a84Z6Um+tJpZJmsh9bjXwTVTvYiN4rdKbqL6+/4HT94on1WYxe10Q4vY6mA6BCpoYxgF3tQ=="], diff --git a/docker/Dockerfile b/docker/Dockerfile index 4991456..2e92d54 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -114,6 +114,12 @@ ENV NODE_ENV=production # clang-format 给 C 代码格式化用,对应旧镜像 apt 装的那个。 # +# python3-minimal 给提交前的 Python 语法检查用(services/format-code.ts 的 +# checkPythonSyntax,只编译不执行)。trixie 自带的是 3.13,和判题机同一个大版本 —— +# 这一点是要的:报错原文要和判题机的编译错误一字不差,前端才能用同一张翻译表。 +# **判题机换 Python 大版本时这里要一起换**(Debian 换代或者改装 python3.x-minimal)。 +# 实测只让 /usr 多 17MB。 +# # **不装 ca-certificates。** AI 接口那条 https 出站不需要它 —— Bun 和 Node 一样 # 内嵌了一份根证书,走自己那份,不读系统的 `/etc/ssl/certs`。实测:把探针二进制 # 丢进裸 debian:trixie-slim(没有 ca-certificates)请求 api.deepseek.com, @@ -133,11 +139,11 @@ ENV NODE_ENV=production ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn RUN sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources \ && apt-get update \ - && apt-get install -y --no-install-recommends clang-format \ + && apt-get install -y --no-install-recommends clang-format python3-minimal \ && rm -rf /var/lib/apt/lists/* # ruff 给 Python 代码格式化用。旧后端是 pip 装的 ruff==0.15.12,这里取同版本的 -# 官方静态二进制 —— 不为它引入整个 Python 运行时。 +# 官方静态二进制(上面那个 python3-minimal 没有 pip,也不该为 ruff 装)。 COPY --from=ghcr.io/astral-sh/ruff:0.15.12 /ruff /usr/local/bin/ruff COPY --from=artifacts /artifacts/oj2-api /usr/local/bin/oj2-api diff --git a/docker/judge/README.md b/docker/judge/README.md index f936f96..8115e45 100644 --- a/docker/judge/README.md +++ b/docker/judge/README.md @@ -22,6 +22,11 @@ registry 上的 `oj-image/judge:latest` 和 `:1.6.1` 是同一份镜像(config | base | debian:trixie-slim(2024 年的) | debian:trixie-slim(当前) | | 镜像体积 | 1.1 GB | **433 MB** | +⚠️ **换 Python 大版本时,api 镜像(`docker/Dockerfile` 的 `python3-minimal`)要一起换。** +提交前的语法检查在 api 那边用 CPython 编译一遍,前端再按报错原文翻成中文(翻译表在 +`apps/web/src/oj/problem/utils/pythonError.ts`)。两边版本不一致的话,同一份代码 +在提交前和判题时报的句式可能不同,翻译表只能对上其中一边。 + 砍语言的依据:前端的题目语言复选框从来只给 Python / C / C++ / SQL, 生产库 12 万条提交里 Java 44 条、Golang 15 条、JavaScript 3 条,全是很早以前的。 契约 `judgeLanguageSchema` 里那几个键留着(渲染历史提交要用),只是判题机不再认。