AI 评阅:DeepSeek Flash 给老师评分项出建议分和评语,老师采纳后生效

- 学生提交时浏览器给每个窗体截图(运行时同一套 FormWindow 在屏幕外渲染),随提交保存
- 自动评分后进入 AI 评阅队列:按书上评价表给老师评分项打建议分、写评语(JSON 输出,服务器再校验、限制分数);
  发出的内容不含姓名学号;学生代码里像指令的文字一律当作品内容;学生又交了新的只评最新的
- 提示词按教材命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名不扣分):
  教材参考答案 28/30,默认控件名且没有注释 15/30
- 评阅页:界面截图标签页、AI 建议卡片(采纳、重新评阅)、全部采纳(不覆盖老师已评的分)、评阅后又提交的提醒;
  成绩表显示"AI 建议,待确认";学生在老师确认前看不到 AI 的建议
- 规范提示移到 shared,服务端组装提示词时也用
- 配置 DEEPSEEK_API_KEY、AI_MODEL;部署说明提醒 NPM 调大请求体上限

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 19:37:50 +08:00
1 parent 302eae4f13
commit 14a82a3e24
25 files changed
+700 -44

No files matched your search

+7 -3
View File
@@ -10,6 +10,8 @@ interface Cell {
submitted: boolean
auto: number | null
manual: number | null
/** 老师还没评阅时 AI 的建议分(老师评分项之和) */
aiManual: number | null
total: number | null
}
@@ -39,12 +41,14 @@ watch(
const sum = (row: Row) => book.value.assignments.reduce((a, x) => a + (row.scores[x.id]?.total ?? 0), 0)
const fullMarks = computed(() => book.value.assignments.reduce((a, x) => a + x.total, 0))
/** 单元格:已提交显示总分(老师还没评阅时标"待评"),未提交显示"未交" */
/** 单元格:已提交显示总分(老师还没评阅时:有 AI 建议显示含建议的总分并标"待确认",否则标"待评"),未提交显示"未交" */
function cell(row: Row, assignmentId: number) {
const c = row.scores[assignmentId]
const open = () => router.push({ path: `/teacher/assignments/${assignmentId}`, query: { student: row.userId } })
if (!c?.submitted) return h('span', { class: 'missing' }, '未交')
return h(NButton, { text: true, type: 'primary', onClick: open }, () => (c.manual === null ? `${c.total}(待评)` : String(c.total)))
const text =
c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
return h(NButton, { text: true, type: 'primary', onClick: open }, () => text)
}
const columns = computed<DataTableColumns<Row>>(() => [
@@ -75,7 +79,7 @@ function exportCsv() {
<template>
<div>
<div class="head">
<span class="tip">总分 = 最近一次提交的自动评分 + 老师评分;"待评"表示老师还没评阅,只含自动评分。</span>
<span class="tip">总分 = 最近一次提交的自动评分 + 老师评分;"AI 建议,待确认"是含 AI 建议分的总分,老师采纳后才算数;"待评"只含自动评分。</span>
<NButton size="small" :disabled="book.students.length === 0" @click="exportCsv">导出成绩表(CSV)</NButton>
</div>
<NDataTable
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="bun" />
import { expect, test } from 'bun:test'
import { conventionHints } from './conventions'
import { conventionHints } from '@iot/shared'
const designer = (fields: string) => ({ path: 'Form1.Designer.cs', content: `partial class Form1\n{\n${fields}\n}` })
-46
View File
@@ -1,46 +0,0 @@
// 规范提示:给老师评"注释、命名"时参考,不计分。
// - 控件命名:在代码里用到、却还是默认名字(button1、textBox1……)的控件。书上要求"控件缩写 + 有意义的英文",
// 如 btn_Open;只放在界面上、代码里不用的标签、分组框可以保留默认名字(书上也是这样),
// 教材自带的设备控件(lamp1、alarm1)书上就这样命名,也不提示。
// - 注释:学生自己写的代码文件(不含 Designer.cs、Program.cs)里有多少行注释。
import type { SourceFile } from '@iot/shared'
export interface ConventionHint {
level: 'warn' | 'info'
text: string
}
const DEFAULT_NAME = /^(button|label|textBox|comboBox|checkBox|radioButton|listBox)\d+$/
const studentCode = (files: SourceFile[]) => files.filter((f) => f.path.endsWith('.cs') && !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')
export function conventionHints(files: SourceFile[]): ConventionHint[] {
const hints: ConventionHint[] = []
const code = studentCode(files)
const codeText = code.map((f) => f.content).join('\n')
const designers = files.filter((f) => f.path.endsWith('.Designer.cs')).map((f) => f.content)
const controls = designers.flatMap((text) => [...text.matchAll(/^\s*private\s+[\w.]+\s+(\w+);/gm)].map((m) => m[1]))
// 绑定了事件(this.button1.Click += ...)或在代码里用到,就算"用到"
const withEvents = new Set(designers.flatMap((text) => [...text.matchAll(/this\.(\w+)\.\w+\s*\+=/g)].map((m) => m[1])))
const usedInCode = (name: string) => new RegExp(`(?<![\\w])${name}(?![\\w])`).test(codeText)
const defaults = controls.filter((name) => DEFAULT_NAME.test(name) && (withEvents.has(name) || usedInCode(name)))
if (defaults.length) {
hints.push({ level: 'warn', text: `代码里用到的控件还是默认名字:${defaults.join('、')}(书上要求"控件缩写 + 有意义的英文",如 btn_Open)` })
}
let comments = 0
let lines = 0
for (const line of codeText.split('\n').map((l) => l.trim())) {
if (!line || line === '{' || line === '}' || line.startsWith('using ')) continue
if (line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) comments++
else {
lines++
if (line.includes('//')) comments++
}
}
if (lines > 0) {
hints.push(comments === 0 ? { level: 'warn', text: `没有写注释(代码 ${lines} 行)` } : { level: 'info', text: `注释 ${comments} 行,代码 ${lines} 行` })
}
return hints
}