fix(流程图): AI 点评不再让模型在脑子里走图——拼成程序在判题机上跑,同一张图两次结果一样
Deploy / deploy (push) Canceled after 0s

上一版让模型拿例子在图上「走一遍」,真模型一试:同一张错图两次一次全对一次 C
(看得到应该输出时它会照抄),365 圈的循环多算一圈也判对。现在:
- 结构照学生的结构树拼,AI 只把每个框里的字翻成一句 Python,改不了结构、补不了漏的步骤
- 拼好的程序在本站判题机上跑例子和测试数据,逐行比输出:空格、标点、全角半角不算错,
  题目输出里固定的提示文字(「较大的数是: 」)框里没写也不算错
- 走过哪些框、每行是哪个框输出的在程序里记下来,画布照旧亮路径、标出错的框
- 没走对的原因由代码说:多输出了一行「一级果」、输出的是 38.16 应该是 37.78、
  「输入有无瑕疵 f」读到的数据不对、走不到结束(死循环)
- 结构树挪进契约(flowchart-doc.ts),前端画图、后端评分用同一份

用真模型(deepseek-v4-flash)对 9 张图各评两次,结果两次一致、对错都判对,每张一秒左右。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 21:22:15 +08:00
1 parent 099c6d7ba6
commit e4905fcec1
5 files changed
+680 -257

No files matched your search

@@ -1,31 +1,31 @@
/**
* 流程图的数据:一棵结构树,不是「一堆框 + 一堆线」。
*
* 原来的编辑器(vue-flow)存的是自由摆放的节点和学生手拉的连线,能画出断线、孤立的框、
* 一个「是」连出去三根线 —— 学生大半时间花在拖框、对连接点、理线上。现在只有三种结构:
* 顺序、判断(是 / 否两路,走完合回来)、循环(当型,条件成立就走一遍循环体再回来判断),
* 「开始」「结束」固定在两头不进数据。画不出来的只有乱跳的线,中职教的三种结构都画得出。
*
* 存进数据库(flowchart_submission.flowchart_data,压缩过)和本地草稿的都是 FlowDoc。
* 2026-10 之前的提交存的是 vue-flow 的 nodes / edges,parseDoc 认不出来就是 null ——
* 那些只能看 Mermaid 渲染出来的图,载不回画布。
* 画布上改图的那些操作。图的数据结构(结构树)、读回、转 Mermaid 在契约
* `@oj2/contract` 的 flowchart-doc.ts —— 后端评分也要按同一棵树把框对上号。
* 这里转出去一份短名字,编辑器和结果页签都从这里引。
*/
import {
countBlankSteps,
flowChildren,
flowSignature,
flowToMermaid,
FLOW_END_ID,
FLOW_START_ID,
mermaidOrder,
parseFlowDoc,
type FlowDoc,
type FlowStep,
type StepKind,
} from "@oj2/contract"
export type BoxKind = "input" | "output" | "process"
export type StepKind = BoxKind | "if" | "while"
interface StepBase {
id: string
text: string
}
export type BoxStep = StepBase & { kind: BoxKind }
export type IfStep = StepBase & { kind: "if"; yes: FlowStep[]; no: FlowStep[] }
export type WhileStep = StepBase & { kind: "while"; body: FlowStep[] }
export type FlowStep = BoxStep | IfStep | WhileStep
export interface FlowDoc {
version: 2
steps: FlowStep[]
export type { BoxKind, FlowDoc, FlowStep, StepKind } from "@oj2/contract"
export {
countBlankSteps as countBlank,
flowSignature as signature,
flowToMermaid as toMermaid,
FLOW_END_ID as END_ID,
FLOW_START_ID as START_ID,
mermaidOrder,
parseFlowDoc as parseDoc,
}
/** 一个步骤列表在树里的位置:主线,或者某个判断 / 循环的哪一支 */
@@ -43,10 +43,6 @@ export interface FlowHighlight {
bad: string | null
}
/** 开始、结束在画布上的 id(它们不进数据) */
export const START_ID = "__start"
export const END_ID = "__end"
export const STEP_LABEL: Record<StepKind, string> = {
input: "输入",
output: "输出",
@@ -84,7 +80,7 @@ export function findStep(
for (let index = 0; index < steps.length; index++) {
const step = steps[index]!
if (step.id === id) return { step, list: steps, index }
for (const child of children(step)) {
for (const child of flowChildren(step)) {
const found = findStep(child, id)
if (found) return found
}
@@ -92,12 +88,6 @@ export function findStep(
return null
}
function children(step: FlowStep): FlowStep[][] {
if (step.kind === "if") return [step.yes, step.no]
if (step.kind === "while") return [step.body]
return []
}
function listAt(doc: FlowDoc, parent: string | null, branch: Branch): FlowStep[] | null {
if (parent === null) return branch === "main" ? doc.steps : null
const found = findStep(doc.steps, parent)
@@ -127,149 +117,7 @@ export function removeStep(doc: FlowDoc, id: string): number {
export function countSteps(steps: FlowStep[]): number {
return steps.reduce(
(sum, step) => sum + 1 + children(step).reduce((n, c) => n + countSteps(c), 0),
(sum, step) => sum + 1 + flowChildren(step).reduce((n, c) => n + countSteps(c), 0),
0,
)
}
/** 还没写字的框有几个 */
export function countBlank(steps: FlowStep[]): number {
return steps.reduce(
(sum, step) =>
sum + (step.text.trim() ? 0 : 1) + children(step).reduce((n, c) => n + countBlank(c), 0),
0,
)
}
/**
* 一张图「长什么样」,不带 id:载回旧版、和交过的那一版比,只看结构和字。
*/
export function signature(doc: FlowDoc): string {
const strip = (steps: FlowStep[]): unknown[] =>
steps.map((step) => [step.kind, step.text.trim(), ...children(step).map(strip)])
return JSON.stringify(strip(doc.steps))
}
// ==================== 读回来的数据 ====================
const KINDS = new Set<string>(["input", "output", "process", "if", "while"])
function parseSteps(raw: unknown): FlowStep[] | null {
if (!Array.isArray(raw)) return null
const out: FlowStep[] = []
for (const item of raw) {
if (!item || typeof item !== "object") return null
const step = item as Record<string, unknown>
const kind = step.kind
if (typeof kind !== "string" || !KINDS.has(kind)) return null
const id = typeof step.id === "string" && step.id ? step.id : newId()
const text = typeof step.text === "string" ? step.text : ""
if (kind === "if") {
const yes = parseSteps(step.yes ?? [])
const no = parseSteps(step.no ?? [])
if (!yes || !no) return null
out.push({ id, text, kind, yes, no })
} else if (kind === "while") {
const body = parseSteps(step.body ?? [])
if (!body) return null
out.push({ id, text, kind, body })
} else {
out.push({ id, text, kind: kind as BoxKind })
}
}
return out
}
/** 本地草稿、交过的某一版:是新格式就读出来,旧的 vue-flow 格式或者坏数据给 null */
export function parseDoc(raw: unknown): FlowDoc | null {
if (!raw || typeof raw !== "object") return null
const data = raw as Record<string, unknown>
if (data.version !== 2) return null
const steps = parseSteps(data.steps)
return steps ? { version: 2, steps } : null
}
// ==================== Mermaid ====================
/**
* 交给 AI 的、结果页签和提交列表里渲染缩略图的,都是这份 Mermaid。
*
* 形状照国标流程图:起止是圆角跑道、输入输出是平行四边形、处理是矩形、判断是菱形。
* 原来起止画成圆、输入输出和画布上的样子对不上,AI 还会说「输出框应该用矩形」。
* class 行留着给 useMermaid 上色;classDef 不写了 —— 颜色是渲染那边的事,写进来
* AI 会把「用了颜色样式」也当成优点打分。
*
* 学生敲的字直接塞进 "...",一个双引号就会把语法撑破:坏掉的代码还会原样送去打分,
* 学生不知道扣分是因为一个引号。`#` 必须先转,否则本来就有的 `#quot;` 会被当成实体。
*/
function escapeLabel(raw: string) {
return raw.replace(/#/g, "#35;").replace(/"/g, "#quot;").replace(/\r?\n/g, "<br/>")
}
/**
* Mermaid 里第 n 个框(nN)是画布上的哪一步:开始、各步按先序(判断先「是」再「否」)、结束。
* 和下面 toMermaid 建框的顺序一致 —— AI 走例子时报的是 nN,结果页签靠它在画布上亮出路径
*/
export function mermaidOrder(doc: FlowDoc): string[] {
const order = [START_ID]
const walk = (steps: FlowStep[]) => {
for (const step of steps) {
order.push(step.id)
for (const child of children(step)) walk(child)
}
}
walk(doc.steps)
order.push(END_ID)
return order
}
export function toMermaid(doc: FlowDoc): string {
const lines: string[] = ["flowchart TD"]
const classes: string[] = []
let n = 0
const node = (shape: (label: string) => string, text: string, cls: string) => {
const id = `n${n++}`
lines.push(` ${id}${shape(escapeLabel(text.trim() || " "))}`)
classes.push(` class ${id} ${cls}`)
return id
}
/** 还没接上下一步的出口:从哪个框出来、线上写什么 */
type Exit = { from: string; label?: string }
const link = (exits: Exit[], to: string) => {
for (const exit of exits) {
lines.push(
exit.label ? ` ${exit.from} -->|"${exit.label}"| ${to}` : ` ${exit.from} --> ${to}`,
)
}
}
const walk = (steps: FlowStep[], entries: Exit[]): Exit[] => {
let exits = entries
for (const step of steps) {
if (step.kind === "if") {
const id = node((l) => `{"${l}"}`, step.text, "decision")
link(exits, id)
exits = [
...walk(step.yes, [{ from: id, label: "是" }]),
...walk(step.no, [{ from: id, label: "否" }]),
]
} else if (step.kind === "while") {
const id = node((l) => `{"${l}"}`, step.text, "decision")
link(exits, id)
link(walk(step.body, [{ from: id, label: "是" }]), id)
exits = [{ from: id, label: "否" }]
} else {
const shape =
step.kind === "process" ? (l: string) => `["${l}"]` : (l: string) => `[/"${l}"/]`
const id = node(shape, step.text, step.kind)
link(exits, id)
exits = [{ from: id }]
}
}
return exits
}
const start = node((l) => `(["${l}"])`, "开始", "startNode")
const exits = walk(doc.steps, [{ from: start }])
const end = node((l) => `(["${l}"])`, "结束", "endNode")
link(exits, end)
return [...lines, "", ...classes].join("\n")
}