feat(题目): 学生题目页恢复「要求」展示,走新的 astRequirements 字段
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
旧后端的 ProblemSerializer.Meta.exclude 没排掉 ast_rules,学生拿到的是规则原文,
题目页上那块「要求」(「if 条件 出现 2 次」之类)是显示的。阶段 3 泄露评审刻意
收掉了它,只留 hasAstRules 布尔值 —— 报告里当时就写了:
「新的更安全,但如果 ojnext 有地方读 ast_rules 的具体内容(比如提示"必须用
for 循环"),需要补个专门的字段。」
前端确实有(ProblemContent.vue 的 astRulesForDisplay + 整块渲染),但那个字段
一直没补。结果是这块在 OJ2 上**永远拿不到数据**,代码还在、没人报错。12 道题
受影响;学生只有提交失败后才能从 statistic_info.ast_results 里看到要求。
现在按评审自己的建议补上:oj 侧题目详情(含比赛题)多一个 astRequirements,
**只有渲染要用的两个字段**:
{ description: "if 条件 出现 2 次", kind: "count" }
文案由后端生成,engine / target 这些内部字段不出现在响应里 —— 收紧保留,
展示恢复。实测响应里搜不到 "engine" 也搜不到 "if_statement"。
## 顺带合掉一堆重复
描述文案原来有两份几乎一样的实现:判题机 ast.ts 里一份(写进 ast_results)、
ProblemContent.vue 里一份(题目页用)。现在统一成 ast.ts 的 describeAstRule,
两边共用。差异只在 min/max 同时给出时的措辞(生产库里没有这种规则,实际输出
逐字不变),另外判题机现在也能用上节点类型的中文名 —— 没写 label 的规则以前
判题结果里显示 `必须使用 function_definition`,现在是 `必须使用 函数定义`。
节点类型中文名那 15 条原来在 AstRulesEditor.vue(下拉 options)和
ProblemContent.vue(NODE_TARGET_LABELS)各手抄一份,收进契约的
AST_NODE_TARGET_LABELS,编辑器的下拉现在从它生成。
前端删掉 NODE_TARGET_LABELS / ruleDescription / ruleTagType 共 ~80 行,
`Problem` 类型里那个 oj 侧根本不下发的 astRules 幽灵字段也去掉了。顺带修掉
一处潜在重复渲染:原来 message 非空时 ruleDescription 返回 message、模板里
又单独渲染一次 message(生产库 message 全是空串,所以没露出来)。
## 一个坑:契约里差点搞出循环引用
astRequirements 一开始放在 admin.ts,problem.ts 去 import 它 —— 而 admin.ts
本来就 import problem.ts。**tsc 一声不吭地过了**,运行时才炸:
ReferenceError: Cannot access 'astRequirementsSchema' before initialization
所以整块 AST schema 从 admin.ts 挪到了 problem.ts(本来也是题目域的东西),
admin.ts 反过来从那边取。这类环 tsc 抓不到,加跨文件 schema 引用时得实跑一次。
## 验证
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打:
- 把生产库那条规则种进本地库,匿名请求 /api/problems/1001:astRequirements 是
`{"Python3":[{"description":"if 条件 出现 2 次","kind":"count"}, ...]}`,
响应里没有 astRules、没有 engine、没有 if_statement。
- 浏览器打开题目页,「要求」那块活了:`要求 | if 条件 出现 2 次 | else 子句 出现 2 次`。
- 后台编辑页展开「代码规则检查」,两条规则正常渲染成
`出现次数 | if 条件 | 精确`,下拉选项(现在从契约生成)正确。
- 直接调 describeAstRule / checkAst / astRequirements:三种 kind 分类正确,
engine 认不出的规则被丢掉,null 和非对象都回落成 null。
- oj 侧 3 页 + 后台 4 页走查无重定向、console 无报错。
冒烟改动已还原(problem 2 的 ast_rules 复位成 null)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import type { AstRule } from "@oj2/contract"
|
||||
import {
|
||||
astRuleSchema,
|
||||
AST_NODE_TARGET_LABELS,
|
||||
type AstRequirement,
|
||||
type AstRequirements,
|
||||
type AstRule,
|
||||
} from "@oj2/contract"
|
||||
import { Language, Parser, type Node } from "web-tree-sitter"
|
||||
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`,
|
||||
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root,解析不到 node_modules。见 vendor/jieba.ts
|
||||
@@ -90,16 +96,87 @@ function hasNode(root: Node, type: string): boolean {
|
||||
}
|
||||
|
||||
function targetName(rule: AstRule) {
|
||||
return rule.label || rule.target || "指定语法"
|
||||
const target = rule.target ?? ""
|
||||
return rule.label || AST_NODE_TARGET_LABELS[target] || target || "指定语法"
|
||||
}
|
||||
|
||||
function rangeDescription(subject: string, rule: AstRule) {
|
||||
function countPhrase(verb: string, rule: AstRule) {
|
||||
if (rule.exact !== undefined) return `${verb} ${rule.exact} 次`
|
||||
if (rule.min !== undefined && rule.max !== undefined)
|
||||
return `${verb} ${rule.min}~${rule.max} 次`
|
||||
if (rule.min !== undefined) return `至少${verb} ${rule.min} 次`
|
||||
if (rule.max !== undefined) return `至多${verb} ${rule.max} 次`
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条规则的中文描述。判题结果(statistic_info.ast_results)和题目页的「要求」
|
||||
* 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现,
|
||||
* 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。
|
||||
*/
|
||||
export function describeAstRule(rule: AstRule): string {
|
||||
if (rule.message) return rule.message
|
||||
if (rule.exact !== undefined) return `${subject} 出现 ${rule.exact} 次`
|
||||
const parts: string[] = []
|
||||
if (rule.min !== undefined) parts.push(`至少 ${rule.min} 次`)
|
||||
if (rule.max !== undefined) parts.push(`至多 ${rule.max} 次`)
|
||||
return `${subject} ${parts.join("、")}`.trim()
|
||||
const name = targetName(rule)
|
||||
const target = rule.target ?? ""
|
||||
switch (rule.engine) {
|
||||
case "must_exist_node":
|
||||
return `必须使用 ${name}`
|
||||
case "must_not_exist_node":
|
||||
return `不能使用 ${name}`
|
||||
case "count_node":
|
||||
return `${name} ${countPhrase("出现", rule)}`.trim()
|
||||
case "must_call_function":
|
||||
return `必须调用 ${target}()`
|
||||
case "must_not_call_function":
|
||||
return `不能调用 ${target}()`
|
||||
case "count_function_call":
|
||||
return `${target}() ${countPhrase("调用", rule)}`.trim()
|
||||
case "must_call_method":
|
||||
return `必须调用 .${target}()`
|
||||
case "must_not_call_method":
|
||||
return `不能调用 .${target}()`
|
||||
case "must_use_operator":
|
||||
return `必须使用 ${target} 运算符`
|
||||
case "must_have_nesting": {
|
||||
const outer = rule.outer ?? ""
|
||||
const inner = rule.inner ?? ""
|
||||
return outer === inner
|
||||
? `必须使用 ${outer} 嵌套`
|
||||
: `必须在 ${outer} 中嵌套使用 ${inner}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 标签配色用的粗分类,见契约 astRequirementSchema */
|
||||
function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
|
||||
if (engine.startsWith("must_not")) return "forbid"
|
||||
if (engine.startsWith("count")) return "count"
|
||||
return "require"
|
||||
}
|
||||
|
||||
/**
|
||||
* 把规则原文投影成下发给学生的「代码要求」。规则里的 engine / target 不出现在
|
||||
* 响应里 —— 阶段 3 泄露评审收掉 ast_rules 时要的就是这个,见契约的注释。
|
||||
*/
|
||||
export function astRequirements(value: unknown): AstRequirements | null {
|
||||
const grouped = value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
if (!grouped) return null
|
||||
const out: AstRequirements = {}
|
||||
for (const [language, rules] of Object.entries(grouped)) {
|
||||
if (!Array.isArray(rules)) continue
|
||||
const items = rules.flatMap((rule) => {
|
||||
const parsed = astRuleSchema.safeParse(rule)
|
||||
if (!parsed.success) return []
|
||||
return [{
|
||||
description: describeAstRule(parsed.data),
|
||||
kind: requirementKind(parsed.data.engine),
|
||||
}]
|
||||
})
|
||||
if (items.length > 0) out[language] = items
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null
|
||||
}
|
||||
|
||||
function rangePassed(count: number, rule: AstRule) {
|
||||
@@ -140,51 +217,51 @@ function evaluateRule(
|
||||
switch (rule.engine) {
|
||||
case "must_exist_node":
|
||||
return {
|
||||
description: rule.message || `必须使用 ${targetName(rule)}`,
|
||||
description: describeAstRule(rule),
|
||||
passed: hasNode(root, nodeType),
|
||||
}
|
||||
case "must_not_exist_node":
|
||||
return {
|
||||
description: rule.message || `不能使用 ${targetName(rule)}`,
|
||||
description: describeAstRule(rule),
|
||||
passed: !hasNode(root, nodeType),
|
||||
}
|
||||
case "count_node": {
|
||||
const count = collectNodes(root, nodeType).length
|
||||
return {
|
||||
description: rangeDescription(targetName(rule), rule),
|
||||
description: describeAstRule(rule),
|
||||
passed: rangePassed(count, rule),
|
||||
}
|
||||
}
|
||||
case "must_call_function":
|
||||
return {
|
||||
description: rule.message || `必须调用 ${target}()`,
|
||||
description: describeAstRule(rule),
|
||||
passed: functionCalls(root, target, language).length > 0,
|
||||
}
|
||||
case "must_not_call_function":
|
||||
return {
|
||||
description: rule.message || `不能调用 ${target}()`,
|
||||
description: describeAstRule(rule),
|
||||
passed: functionCalls(root, target, language).length === 0,
|
||||
}
|
||||
case "count_function_call": {
|
||||
const count = functionCalls(root, target, language).length
|
||||
return {
|
||||
description: rangeDescription(`${target}()`, rule),
|
||||
description: describeAstRule(rule),
|
||||
passed: rangePassed(count, rule),
|
||||
}
|
||||
}
|
||||
case "must_call_method":
|
||||
return {
|
||||
description: rule.message || `必须调用 .${target}()`,
|
||||
description: describeAstRule(rule),
|
||||
passed: methodCalls(root, target, language).length > 0,
|
||||
}
|
||||
case "must_not_call_method":
|
||||
return {
|
||||
description: rule.message || `不能调用 .${target}()`,
|
||||
description: describeAstRule(rule),
|
||||
passed: methodCalls(root, target, language).length === 0,
|
||||
}
|
||||
case "must_use_operator":
|
||||
return {
|
||||
description: rule.message || `必须使用 ${target} 运算符`,
|
||||
description: describeAstRule(rule),
|
||||
passed: hasNode(root, nodeType),
|
||||
}
|
||||
case "must_have_nesting": {
|
||||
@@ -195,14 +272,7 @@ function evaluateRule(
|
||||
const passed = collectNodes(root, outerType).some((node) =>
|
||||
node.children.some((child) => hasNode(child, innerType)),
|
||||
)
|
||||
return {
|
||||
description:
|
||||
rule.message ||
|
||||
(outer === inner
|
||||
? `必须使用 ${outer} 嵌套`
|
||||
: `必须在 ${outer} 中嵌套使用 ${inner}`),
|
||||
passed,
|
||||
}
|
||||
return { description: describeAstRule(rule), passed }
|
||||
}
|
||||
default:
|
||||
return null
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Hono } from "hono"
|
||||
import { optionalAuth, requireAuth } from "../auth/middleware"
|
||||
import { setContestPassword } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
import { astRequirements } from "../judge/ast"
|
||||
import { failure, success } from "../http"
|
||||
import {
|
||||
canAccessContest,
|
||||
@@ -180,6 +181,8 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
||||
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { astRequirements } from "../judge/ast"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { objectValue as toObject, queryInteger, sampleUser } from "./helpers"
|
||||
@@ -319,6 +320,8 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
||||
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
})
|
||||
|
||||
return success(c, data)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { AST_NODE_TARGET_LABELS } from "@oj2/contract"
|
||||
import type { AstRule, AstRules, LANGUAGE } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
@@ -51,23 +52,11 @@ const ENGINE_OPTIONS: SelectOption[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const NODE_TARGET_OPTIONS: SelectOption[] = [
|
||||
{ label: "for 循环", value: "for_loop" },
|
||||
{ label: "while 循环", value: "while_loop" },
|
||||
{ label: "if 条件", value: "if_statement" },
|
||||
{ label: "else 子句", value: "else_clause" },
|
||||
{ label: "函数定义", value: "function_definition" },
|
||||
{ label: "return 语句", value: "return" },
|
||||
{ label: "break 语句", value: "break" },
|
||||
{ label: "continue 语句", value: "continue" },
|
||||
{ label: "列表推导式", value: "list_comprehension" },
|
||||
{ label: "列表", value: "list_literal" },
|
||||
{ label: "字典", value: "dict_literal" },
|
||||
{ label: "集合", value: "set_literal" },
|
||||
{ label: "f-string", value: "f_string" },
|
||||
{ label: "try-except", value: "try_except" },
|
||||
{ label: "类定义", value: "class_definition" },
|
||||
]
|
||||
// 选项从契约的 AST_NODE_TARGET_LABELS 生成 —— 这 15 条原来在这里和
|
||||
// ProblemContent.vue 各手抄一份
|
||||
const NODE_TARGET_OPTIONS: SelectOption[] = Object.entries(
|
||||
AST_NODE_TARGET_LABELS,
|
||||
).map(([value, label]) => ({ label, value }))
|
||||
|
||||
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
|
||||
{ label: "+", value: "+" },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import type { AstRule, Problem, ProblemStatus } from "utils/types"
|
||||
import type { Problem, ProblemStatus } from "utils/types"
|
||||
import Copy from "shared/components/Copy.vue"
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
@@ -97,80 +97,18 @@ const samples = ref<Sample[]>(
|
||||
})),
|
||||
)
|
||||
|
||||
const NODE_TARGET_LABELS: Record<string, string> = {
|
||||
for_loop: "for 循环",
|
||||
while_loop: "while 循环",
|
||||
if_statement: "if 条件",
|
||||
else_clause: "else 子句",
|
||||
function_definition: "函数定义",
|
||||
return: "return 语句",
|
||||
break: "break 语句",
|
||||
continue: "continue 语句",
|
||||
list_comprehension: "列表推导式",
|
||||
list_literal: "列表",
|
||||
dict_literal: "字典",
|
||||
set_literal: "集合",
|
||||
f_string: "f-string",
|
||||
try_except: "try-except",
|
||||
class_definition: "类定义",
|
||||
}
|
||||
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
|
||||
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
|
||||
// astRequirementSchema。规则原文(engine / target)不下发给学生。
|
||||
const KIND_TAG_TYPE = {
|
||||
require: "success",
|
||||
forbid: "error",
|
||||
count: "info",
|
||||
} as const
|
||||
|
||||
function ruleDescription(rule: AstRule): string {
|
||||
if (rule.message) return rule.message
|
||||
const target = rule.target || ""
|
||||
const targetLabel = rule.label || NODE_TARGET_LABELS[target] || target
|
||||
const countDesc = () => {
|
||||
if (rule.exact !== undefined) return `出现 ${rule.exact} 次`
|
||||
if (rule.min !== undefined && rule.max !== undefined)
|
||||
return `出现 ${rule.min}~${rule.max} 次`
|
||||
if (rule.min !== undefined) return `至少出现 ${rule.min} 次`
|
||||
if (rule.max !== undefined) return `至多出现 ${rule.max} 次`
|
||||
return ""
|
||||
}
|
||||
const callDesc = () => {
|
||||
if (rule.exact !== undefined) return `调用 ${rule.exact} 次`
|
||||
if (rule.min !== undefined && rule.max !== undefined)
|
||||
return `调用 ${rule.min}~${rule.max} 次`
|
||||
if (rule.min !== undefined) return `至少调用 ${rule.min} 次`
|
||||
if (rule.max !== undefined) return `至多调用 ${rule.max} 次`
|
||||
return ""
|
||||
}
|
||||
switch (rule.engine) {
|
||||
case "must_exist_node":
|
||||
return `必须使用 ${targetLabel}`
|
||||
case "must_not_exist_node":
|
||||
return `不能使用 ${targetLabel}`
|
||||
case "count_node":
|
||||
return `${targetLabel} ${countDesc()}`
|
||||
case "must_call_function":
|
||||
return `必须调用 ${target}()`
|
||||
case "must_not_call_function":
|
||||
return `不能调用 ${target}()`
|
||||
case "count_function_call":
|
||||
return `${target}() ${callDesc()}`
|
||||
case "must_call_method":
|
||||
return `必须调用 .${target}()`
|
||||
case "must_not_call_method":
|
||||
return `不能调用 .${target}()`
|
||||
case "must_use_operator":
|
||||
return `必须使用 ${target} 运算符`
|
||||
default:
|
||||
return rule.engine
|
||||
}
|
||||
}
|
||||
|
||||
function ruleTagType(engine: string): "error" | "success" | "info" {
|
||||
if (engine.startsWith("must_not")) return "error"
|
||||
if (engine.startsWith("must")) return "success"
|
||||
return "info"
|
||||
}
|
||||
|
||||
const astRulesForDisplay = computed(() => {
|
||||
if (!problem.value?.astRules) return []
|
||||
return Object.entries(problem.value.astRules).filter(
|
||||
([, rules]) => rules.length > 0,
|
||||
)
|
||||
})
|
||||
const astRequirements = computed(() =>
|
||||
Object.entries(problem.value?.astRequirements ?? {}),
|
||||
)
|
||||
|
||||
async function test(sample: Sample, index: number) {
|
||||
samples.value = samples.value.map((sample) => {
|
||||
@@ -360,27 +298,20 @@ function type(status: ProblemStatus) {
|
||||
</div>
|
||||
|
||||
<!-- 代码要求(AST 规则) -->
|
||||
<div v-if="astRulesForDisplay.length > 0">
|
||||
<div v-if="astRequirements.length > 0">
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
||||
要求
|
||||
</n-flex>
|
||||
</p>
|
||||
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
|
||||
<p v-if="astRulesForDisplay.length > 1" class="lang-label">
|
||||
<div v-for="[lang, rules] in astRequirements" :key="lang">
|
||||
<p v-if="astRequirements.length > 1" class="lang-label">
|
||||
{{ lang }}
|
||||
</p>
|
||||
<n-list bordered style="margin-bottom: 8px">
|
||||
<n-list-item v-for="(rule, i) in rules" :key="i">
|
||||
<n-flex align="center">
|
||||
<n-tag :type="ruleTagType(rule.engine)">
|
||||
{{ ruleDescription(rule) }}
|
||||
</n-tag>
|
||||
<span v-if="rule.message" class="rule-message">{{
|
||||
rule.message
|
||||
}}</span>
|
||||
</n-flex>
|
||||
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
@@ -520,11 +451,6 @@ function type(status: ProblemStatus) {
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.rule-message {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.sqlTableName {
|
||||
font-weight: 600;
|
||||
margin: 8px 0 4px;
|
||||
|
||||
@@ -116,7 +116,7 @@ export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
|
||||
export type Problem = Omit<ProblemDetail, "languages" | "template"> & {
|
||||
languages: LANGUAGE[]
|
||||
template: { [key in LANGUAGE]?: string }
|
||||
astRules?: AstRules | null
|
||||
// oj 侧不下发 astRules 原文,只有 astRequirements(契约里就有)
|
||||
hasAstRules?: boolean
|
||||
visible?: boolean
|
||||
answers?: { language: LANGUAGE; code: string }[]
|
||||
|
||||
Reference in New Issue
Block a user