feat(后台): 编程题出题页重做——左边写,右边是学生看到的样子;例子和测试数据的输出都由标准答案跑出来,改老题能直接看、改测试数据
- 例子就是测试数据里勾了「当例子」的几组,不再手抄输出;老题的例子不在测试数据里的补到最后 - 改了答案,输出变了的那组标黄、写清原来存的是什么;答案在某组上报错就挡住保存 - 没存标准答案的老题输出手写;太大的测试数据(>100 组或单文件 >32KB)只能导入 / 下载 zip - 预制代码只写学生看得到的那段,前后藏起来的代码收进折叠;语法要求先拿标准答案自测 - 流程图两个互斥开关改成三选一;时间 / 内存限制能改了;草稿只给新建页 - 后端:读回测试点原文、语法要求自测、AI 想测试输入三个接口 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
033a245abb
commit
ba5b9ada0e
15 files changed
+3043
-1068
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
addContestProblemRequestSchema,
|
||||
astCheckRequestSchema,
|
||||
generateTestInputsRequestSchema,
|
||||
isSqlProblem,
|
||||
TEST_CASE_EDIT_MAX_CASES,
|
||||
TEST_CASE_EDIT_MAX_FILE_BYTES,
|
||||
createProblemRequestSchema,
|
||||
generateSqlTestCaseRequestSchema,
|
||||
makeProblemPublicRequestSchema,
|
||||
@@ -9,11 +13,14 @@ import {
|
||||
type AdminProblem,
|
||||
type AdminProblemList,
|
||||
type AdminProblemListItem,
|
||||
type AstCheckResponse,
|
||||
type AstRules,
|
||||
type GenerateTestInputsResponse,
|
||||
type GenerateSqlTestCaseResponse,
|
||||
type SqlConfig,
|
||||
type SqlDisplay,
|
||||
type SqlTestCaseScript,
|
||||
type TestCaseFiles,
|
||||
type UploadTestCaseResponse,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, ilike, inArray, isNull, ne, not, or, sql } from "drizzle-orm"
|
||||
@@ -23,15 +30,17 @@ import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema, type DbOrTx } from "../../db"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { astRulesError, pickAstRules } from "../../judge/ast"
|
||||
import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast"
|
||||
import { buildSqlDisplay } from "../../judge/sql"
|
||||
import { completeChat } from "../../services/ai"
|
||||
import { plainText } from "../../services/hint-diagnosis"
|
||||
import { contestStatus } from "../../services/contest"
|
||||
import {
|
||||
packTestCaseZip,
|
||||
processTestCaseZip,
|
||||
readInfo,
|
||||
readSqlScripts,
|
||||
readTestCaseFiles,
|
||||
TestCaseError,
|
||||
} from "../../services/test-case"
|
||||
import { config } from "../../config"
|
||||
@@ -863,6 +872,95 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 回显编程题已有的测试点原文,出题页就地改。太大的(见契约 TEST_CASE_EDIT_*)只给数量和大小。
|
||||
* SQL 题的测试点是脚本,走上面的 sql-scripts。
|
||||
*/
|
||||
adminProblemRoutes.get("/problems/:id/test-case-files", requireProblemPermission, async (c) => {
|
||||
const [problem] = await db
|
||||
.select()
|
||||
.from(schema.problem)
|
||||
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
||||
.limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!(await canEdit(c.get("user")!, problem))) {
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
}
|
||||
const info = await readInfo(problem.testCaseId)
|
||||
if (info?.sql) return failure(c, 409, "sql-test-case", "该题的测试点是 SQL 脚本")
|
||||
try {
|
||||
const files = await readTestCaseFiles(problem.testCaseId, {
|
||||
maxCases: TEST_CASE_EDIT_MAX_CASES,
|
||||
maxFileBytes: TEST_CASE_EDIT_MAX_FILE_BYTES,
|
||||
})
|
||||
return success(c, files satisfies TestCaseFiles)
|
||||
} catch (error) {
|
||||
if (error instanceof TestCaseError)
|
||||
return failure(c, 404, "test-case-not-found", "测试点文件不存在")
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
/** 语法要求:每条规则学生看到的那句话 + 标准答案过不过,和判题时同一个 checkAst */
|
||||
adminProblemRoutes.post("/ast-check", requireProblemPermission, async (c) => {
|
||||
const parsed = await parseBody(c, astCheckRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { language, code, rules } = parsed.data
|
||||
const error = astRulesError({ [language]: rules })
|
||||
if (error) return failure(c, 400, "invalid-ast-rules", error)
|
||||
const checked = []
|
||||
for (const rule of rules) {
|
||||
const description = describeAstRule(rule, language)
|
||||
if (!code.trim()) {
|
||||
checked.push({ description, passed: null })
|
||||
continue
|
||||
}
|
||||
const [result] = (await checkAst(code, language, [rule])).results
|
||||
checked.push({ description, passed: result?.passed ?? null, actual: result?.actual })
|
||||
}
|
||||
return success(c, { rules: checked } satisfies AstCheckResponse)
|
||||
})
|
||||
|
||||
/**
|
||||
* AI 按题面和标准答案想几组测试输入。只要输入:输出一律由标准答案在判题机上跑,
|
||||
* AI 编的输出不可信。
|
||||
*/
|
||||
adminProblemRoutes.post("/test-inputs/generate", requireProblemPermission, async (c) => {
|
||||
const parsed = await parseBody(c, generateTestInputsRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const body = parsed.data
|
||||
const existing = body.existing.length
|
||||
? body.existing.map((input, i) => `第 ${i + 1} 组:\n${input}`).join("\n\n")
|
||||
: "(还没有)"
|
||||
try {
|
||||
const raw = await completeChat(
|
||||
`你是编程题出题助手,帮老师补测试数据。老师会给你题目描述、输入说明、标准答案和已有的测试输入。
|
||||
请再想 5 组新的测试输入,要和已有的不重复,重点覆盖边界情况(最小值、最大值、0、负数、
|
||||
重复元素、只有一个元素、各个分支都要走到……),但必须严格符合输入说明的格式,标准答案要能正常处理。
|
||||
数据规模保持小,每组不超过 20 行。
|
||||
只回 json:{"inputs": ["第一组输入", "第二组输入", ...]},每组输入是一个字符串,多行用 \\n 分隔。`,
|
||||
[
|
||||
`题目描述:${plainText(body.description).slice(0, 3000)}`,
|
||||
`输入说明:${plainText(body.inputDescription).slice(0, 1000) || "无"}`,
|
||||
`标准答案(${body.language}):\n${body.answer}`,
|
||||
`已有的测试输入:\n${existing.slice(0, 4000)}`,
|
||||
].join("\n\n"),
|
||||
{ json: true },
|
||||
)
|
||||
const value: unknown = JSON.parse(raw)
|
||||
const list =
|
||||
value && typeof value === "object" && Array.isArray((value as { inputs?: unknown }).inputs)
|
||||
? ((value as { inputs: unknown[] }).inputs.filter((x) => typeof x === "string") as string[])
|
||||
: []
|
||||
const known = new Set(body.existing.map((input) => input.trim()))
|
||||
const inputs = list.filter((input) => !known.has(input.trim())).slice(0, 10)
|
||||
return success(c, { inputs } satisfies GenerateTestInputsResponse)
|
||||
} catch (error) {
|
||||
console.error("Test input generation failed", error)
|
||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||
}
|
||||
})
|
||||
|
||||
/** SQL 题测试点预览:跑一遍初始化脚本 + 标准答案,返回题目页要展示的数据表与期望结果 */
|
||||
adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => {
|
||||
const parsed = await parseBody(c, sqlPreviewRequestSchema)
|
||||
|
||||
@@ -88,7 +88,7 @@ function errInfo(row: HintRow) {
|
||||
* 输出说明里的 `<`、` ` 恰恰可能就是格式要求本身,不解码模型会读歪。
|
||||
* 图片直接丢掉(模型看不到图),只留一个占位,让它知道那里原本有东西。
|
||||
*/
|
||||
function plainText(html: string) {
|
||||
export function plainText(html: string) {
|
||||
return (
|
||||
html
|
||||
.replace(/<img\b[^>]*>/gi, "[图片]")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { mkdir, chmod, readdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { mkdir, chmod, readdir, readFile, stat, writeFile } from "node:fs/promises"
|
||||
import { resolve } from "node:path"
|
||||
|
||||
import { unzipSync, zipSync } from "fflate"
|
||||
@@ -250,6 +250,43 @@ export async function readSqlScripts(testCaseId: string) {
|
||||
return scripts
|
||||
}
|
||||
|
||||
/**
|
||||
* 读回编程题测试点的原文(出题页回显、就地改)。超过 `limits` 的只报数量和大小,不读内容。
|
||||
* 目录不存在时抛 TestCaseError。
|
||||
*/
|
||||
export async function readTestCaseFiles(
|
||||
testCaseId: string,
|
||||
limits: { maxCases: number; maxFileBytes: number },
|
||||
): Promise<
|
||||
| { editable: true; cases: { input: string; output: string }[] }
|
||||
| { editable: false; count: number; totalBytes: number }
|
||||
> {
|
||||
const directory = resolve(config.testCaseDirectory, testCaseId)
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(directory)
|
||||
} catch {
|
||||
throw new TestCaseError("Test case does not exist")
|
||||
}
|
||||
const pairs = collectPairs(new Set(entries))
|
||||
let totalBytes = 0
|
||||
let tooLarge = pairs.length > limits.maxCases
|
||||
for (const name of pairs.flat()) {
|
||||
const size = (await stat(resolve(directory, name))).size
|
||||
totalBytes += size
|
||||
if (size > limits.maxFileBytes) tooLarge = true
|
||||
}
|
||||
if (tooLarge) return { editable: false, count: pairs.length, totalBytes }
|
||||
const cases: { input: string; output: string }[] = []
|
||||
for (const [input, output] of pairs) {
|
||||
cases.push({
|
||||
input: await readFile(resolve(directory, input), "utf8"),
|
||||
output: await readFile(resolve(directory, output), "utf8"),
|
||||
})
|
||||
}
|
||||
return { editable: true, cases }
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const bytes = new Uint8Array(32)
|
||||
|
||||
Reference in new issue
Block a user