批量批改作业并回填成绩登记表
这个 skill 解决什么
教师手上通常是这样一堆东西:
25计网1班/
├── 25计算机网络1班_作业登记表.xlsx ← 学校给的模板,格式不能动
├── 1安装centos7-73859/ ← 第1个作业(数字=序号,-后是平台编号,无意义)
│ └── <可能还有一层哈希目录>
│ ├── 202503710102_林文湛/ ← 学号_姓名
│ │ └── 1_1_02lwz【作业】安装.docx
│ └── 202503710103_蔡炫匡/
├── 2基本命令1(练习1)-03763/
├── 3find命令-90438/
└── 4grep与正则表达式-50722/
学生交的 docx 往往正文全是同一个题面模板(字数几乎相同),真正的答题内容是一张张终端截图。
所以评分的核心动作是"看图",不是"读文字"。这决定了整个流程必须围绕截图来设计。
产出:登记表里每人每作业一个分数 + 三张子表(明细 / 标准 / 异常查重)。
标准流程
第 0 步:先摸清登记表模板的真实结构
不要相信 openpyxl 读出来的列宽数字。 openpyxl 把 <col min="4" max="28" width="5.17"/> 这种区间定义读成"第 4 列 5.17、第 5 列起默认 13",会和实际显示完全不同。一律直接读 XML:
python -c "import zipfile,re;x=zipfile.ZipFile('模板.xlsx').read('xl/worksheets/sheet1.xml').decode();print(re.search(r'<cols>.*?</cols>',x,re.S).group(0))"
同时确认:名单从第几行开始、姓名在第几列、作业名称从哪格开始(通常是 D6)、行高、合并单元格、是否有 drawing(斜线表头)。
详见 references/xlsx-format-pitfalls.md。
让用户提供一份"原件"副本。 后续所有格式都以原件为准,原件全程不改动。
第 1 步:扫描提交 + 查重 + 分块拼图
python scripts/scan_submissions.py --root "D:/25计网1班" --out inventory.json --montage .work/montage
脚本会:
- 自动识别作业目录、跳过平台多套的那层哈希目录、按学号自然排序
- 解出每个 docx 的图片清单(MD5、尺寸、体积)、正文字数和绝对路径(
docs[].path,压缩包里的 docx 解在临时目录,下游靠它才找得到);.rar/.zip自动解压(Windows 上必须用C:\Windows\System32\tar.exe,Git Bash 的 GNU tar 解不了 rar) - 跨学生比对图片 MD5,输出重复对——这是查抄袭最硬的证据,整份 100% 重合基本可以认定
- 为每位学生拼一张"所有截图竖排"的长图(
--montage,只适合每人 ≤8 张的情况,见下)
不要用长拼图去评阅 10 张以上截图的作业。 一个学生 13~17 张截图,竖排拼图会有 5000~10000px 高,视觉模型接收时按长边 ~1500px 整体缩放,缩放比 0.2 左右,文字糊成一片——看了等于没看,还容易写出"看不清但像是做了"的假评语。改成按组切短图:
python scripts/chunk_montage.py --inventory inventory.json --out .work/chunks --per 5
- 每组 5 张、单张宽 1050 是通用档;题目 12 个以上用 4~5,只有 6~7 题可用 6~7 张
- 输出
2_2数据库基本操作DDL__202500000101_张三_p1.png+manifest.json,按 manifest 逐组看 - 压缩包提交的学生在 inventory 里没有可用路径,脚本会自动再解一次压(别让它变成 0 张图漏评)
--hw 2,3只处理指定作业,--only 关键词只处理某个学生,便于补看
第 2 步:逐份看图评阅
必须真的看图。 只按"截图张数"打分是不负责任的:张数够但命令全报错、张数是别人的、同一张重复粘贴,都很常见。
操作要点:
- 一次并行看 4~6 张分块图(同一段 query 模板、只换文件路径),效率最高
- 先用中性提问拿素材:"逐张照抄执行的关键 SQL 和 ERROR,不要评论"——比一上来就问"这题做得对吗"准确得多,覆盖度自己判断
- 再按题目清单核对:覆盖了哪几题、哪题明显不对、有无缺题、有无雷同
- query 里写明题号与要求,并要求"字小也尽力辨认""看不清就说看不清"
- 同一班同一套题,学生截图高度相似,模型返回的描述也会高度雷同——这不是调用出错或卡住,判断依据要落在报错、表结构差异、有没有缺步上
- 先看数量异常的(0 张、1~2 张、远超题数、只有文字没有图);数量正常的按组看完再定分
- 一个班 4 个作业 × 40 人 × 6~17 张图 ≈ 700+ 张截图,分块后约 150~200 次看图调用,属正常量级;按作业分批推进,别指望抽样蒙过去
第 3 步:定评分标准(可复用)
参照 references/grading-rubric.md。通用骨架:
- 按题给分:每题固定分值 × 题数,留 10~12 分给"截图规范"
- 未提交 = 0,并在备注标记
- 关键硬性要求单独占分(如"用户名必须是姓名拼音首字母",占 30 分)
- 抄袭不自动判 0:先按实际完成度给分,在"异常与查重"表里标红提示,由教师核实后决定
第 4 步:写回登记表
先自动生成评分骨架(省掉手抄名单,也避免漏人):
python scripts/make_skeleton.py --inventory inv.json --template "原件.xlsx" --out data.json
它会按目录名自动生成作业列表、从模板读出全班名单、把查重结果转成现成的异常条目,所有分数预填 0。
只需在 data.json 的 scores 里填分数和评语,然后:
python scripts/fill_gradebook.py --template "原件.xlsx" --out "25计算机网络1班_作业登记表.xlsx" --data data.json
完整字段见 references/data-schema.md。
填分数的脚本必须幂等。 实际做法通常是"读 data.json → 往 anomalies 里追加查重/名单外条目 → 写回 data.json",这个结构重跑一次就会多一条重复异常(第一次跑完文件里已经有了,第二次又追加一遍)。追加前先按 type(或 type+who+hw)过滤掉旧条目:
D["anomalies"] = [a for a in D["anomalies"] if a.get("type") != "名单外学生"]
D["anomalies"].append({...}) # 再追加本次的
名单外学生别硬塞进网格。 重修生/插班生的学号前缀常和本届不同(如 2024 级插在 2025 级班里),他们不在模板名单中;fill_gradebook.py 按 students 顺序逐行写,多一个人就会把分数写到表格下方的空白行(那行没有边框)。正确做法:不写进 students,把"某生已评:各次分数"记进「异常与查重」表,由教师决定补录。
脚本做的事:
- 从模板加载 → 在
header_row行写作业名称(默认 8 号字 + 自动换行)→ 学生行写分数(0 分红字) - 新建「评分明细」(每人一行:学号/姓名/各作业得分+说明/总分/均分/备注)
- 新建「评分标准」(按作业分组,逐题分值 + 评分要点)
- 新建「异常与查重」(抄袭对、未交、内容不符、缺关键步骤等,抄袭行标红)
- 保存后用模板原件的 XML 覆盖回
<worksheet>根标签、<cols>、<sheetFormatPr>、每个<row>属性,并把 drawing(斜线表头)、headerFooter、Content_Types 补回去 - 写入目标文件(若被 Excel/预览器占用会重试并提示)
第 5 步:交付前自检
先跑交叉核对,把"提交记录"和"评分记录"对一遍(脚本查不出的串行错误全靠它):
python scripts/check_scores.py --inventory inventory.json --data data.json --strict
它会报:漏评(交了却记未提交)、张冠李戴(没交却有评语)、分数越界、备注未交但分数非 0、异常条目重复、提交了但不在名单里,并把各次均分/缺交数打出来。
再逐项确认格式:
- [ ]
<cols>与原件逐字符相同 - [ ]
<worksheet>根标签与原件相同(否则<drawing r:id>会 unbound prefix) - [ ] 每个
<row>属性与原件相同(行高没被改) - [ ] 合并单元格数量与原件一致(只多不少要能解释)
- [ ] drawing 部件存在(
xl/drawings/drawing1.xml+xl/worksheets/_rels/sheet1.xml.rels) - [ ] 四张表的分数合计与明细一致
- [ ] 分数行数 = 名单人数(不能有 None)
- [ ]
check_scores.py的 ERROR 为 0,WARN 已逐条向用户说明
换班级 / 换作业数能不能用
能。所有会影响布局的量都在 data.json 里,不用改代码:
| 会变的 | 在哪配 | 说明 |
|---|---|---|
| 班级、学生名单 | 模板本身 | 名单从模板的姓名列读;模板没写名单时自动从提交目录汇总 |
| 作业个数 | assignments 数组长度 |
4 个、6 个、10 个都行,列自动向右排;超出模板列数会明确报错而不是覆盖"学期总评" |
| 每作业满分 | assignments[].full |
可以不都是 100 |
| 作业名 | assignments[].title |
make_skeleton.py 按目录名自动生成,可手改 |
| 表头行/首行/起始列 | layout.* |
模板布局不一样时改这里(如作业名在 E5、名单从第 8 行) |
| 评分标准 | rubric |
换课程就重写,参考文件里的是 Linux 类样例 |
已验证:3 个作业 / 6 个作业 / 26 人 / 2 人的场景都能正常出表,30 个作业会被拦下并提示。
关键坑位(都踩过)
| 现象 | 原因 | 对策 |
|---|---|---|
| 列宽全变了 | openpyxl 重写 <cols> |
保存后用原件 XML 覆盖 <cols> |
| 斜线表头消失 | openpyxl 不保留 drawing 形状 | 补回 drawing 部件 + <drawing> 引用 |
| openpyxl 报 unbound prefix | 加了 <drawing r:id> 但根标签没 xmlns:r |
根标签也换回原件的 |
| 写文件报 WinError 5/32 | 文件被 Excel/WPS/预览器占用 | 见下方"文件被占用" |
| rar 解不开 | Git Bash 的 tar 是 GNU tar | 用 C:\Windows\System32\tar.exe |
| 作业名换行后显示不全 | 行高不够或字号不是 8 | 字号设 8 + 自动换行,别去改行高 |
| 拼图里字全糊、只能猜 | 13~17 张竖排成 5000px 长图,被整体缩放 | 用 chunk_montage.py 按 5 张一组切短图 |
| 某学生分块后 0 张图 | 用 .rar/.zip 交的,inventory 里 docx 在临时目录 | 分块脚本再解一次压;scan_submissions.py 已记 docs[].path |
| 异常与查重表出现重复行 | 追加型脚本重跑,读到自己上次写的条目 | 追加前按 type 过滤,保持幂等 |
| 评语张冠李戴 | 手写/复用评语时串到相邻学生 | 跑 check_scores.py,未提交却有评语的会直接报出来 |
| 名单外学生的分数跑到表格外 | 重修生不在模板名单,students 多一个就整体下移 | 不写进 students,记进「异常与查重」表 |
| 交了但全无截图 | 纯文字作答(把 SQL 当答案抄进 Word) | 按"无操作截图"处理:命令写对但未验证,给半分左右并在评语说明 |
文件被占用(Windows 上极高频)
目标 xlsx 被预览面板/Excel 打开时,shutil.copyfile 直接报 Permission denied。
如果占用方是 WorkBuddy 自己的 editor_sdk,用 skill workbuddy-local-office-edit 释放:
cd "<skill目录>/workbuddy-local-office-edit"
python edsdk.py call get_pool_status # 拿 file_id
python edsdk.py call close_file file_id=xxx # 关掉实例
释放后写入,最后用 present_files 重新打开给用户看。若是 Excel/WPS 占用,请用户关闭即可。
涉及文件
scripts/scan_submissions.py— 扫描提交、查重、生成拼图(docx 记录带path)scripts/chunk_montage.py— 每 N 张截图切一张短拼图,解决长图看不清(评阅主力)scripts/make_skeleton.py— 由清单 + 模板自动生成 data.json 骨架(含名单、异常条目)scripts/fill_gradebook.py— 写分数 + 三张子表 + 保格式scripts/check_scores.py— 提交记录 vs 评分记录交叉核对(漏评/串行/重复/名单外)references/data-schema.md— data.json 字段说明references/grading-rubric.md— 命令类实训作业的评分口径参考references/xlsx-format-pitfalls.md— xlsx 保格式的技术细节与 XML 片段
环境与依赖
| 项 | 要求 |
|---|---|
| Python | 3.9+(在 3.13 上实测) |
| 第三方库 | pip install openpyxl python-docx pillow |
| 系统 | Windows 10/11 最优;解压 .rar 用 C:/Windows/System32/tar.exe(bsdtar),macOS/Linux 装 unrar 并改 unrar_any() 的候选命令 |
| 字体 | 拼图标题用 C:/Windows/Fonts/msyh.ttc,缺失时脚本自动回退默认字体(只影响标题,不影响截图) |
| 输入 | .docx(含嵌入截图)或 .rar/.zip 打包的 docx;.doc 需先另存为 docx |
| 输出 | .xlsx,可用 Excel / WPS 打开 |
脚本不联网、不改动学生原文件,只读取 docx 与压缩包,写目标 xlsx 与工作目录下的临时拼图。
适用范围与已知限制
- 目录结构必须是「一个作业一个目录 → 一个学生一个文件夹(
学号_姓名)→ docx」;平台导出的那层哈希目录会自动下钻 - 分数写在模板的固定网格里,作业数超过模板可用列会直接报错(不会覆盖"学期总评")
- 评分口径是"看图判覆盖度 + 命令/结果是否成立",不能替代教师对业务正确性的最终判断;
异常与查重表就是留给人工复核的 - 实测规模:4 次作业 × 40 人 × 754 张截图可跑通;再大建议按作业分批(
chunk_montage.py --hw)
安装与分发
- 自用:把整个目录放到
~/.workbuddy/skills/batch-homework-grading/ - 团队共享:放到项目的
.workbuddy/skills/下,随仓库一起提交 - 分发:目录打包成 zip(含
SKILL.md与scripts/、references/),对方用「技能管理 → 导入 Skill」选中 zip 即可