You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
主观题(解答题 / 填空题)人工阅卷 —— 从制卡端一路做到成绩单与导出。以前这套工具只能判选择题:卷子上有解答题,老师就得把这份卷子从流水线里拿出去、手工加进总分,成绩单和导出的 CSV 里也永远只有客观分。现在主观题是一整条链路: Subjective-question (free-response / fill-in-the-blank) manual grading — end to end, from the card designer all the way to the gradebook and CSV export. Until now the tool could only mark multiple-choice: if a paper had free-response questions, the teacher had to pull that sheet out of the pipeline and add the marks by hand, and neither the gradebook nor the exported CSV ever had anything but objective scores. Subjective questions are now one connected pipeline:
制卡端:选择题 / 填空题 / 解答题块都多了一组「人工阅卷」配置 —— 勾上之后填满分,可选再拆小问(每个小问各自给分)。导出阅卷模板时,勾过的题才会带上 points 和整题一块的作答区坐标region(mm)。没勾的题一个字节都不会多出来,纯选择题的老模板导出结果和 1.3.2 完全一致。 Card designer: the multiple-choice, fill-in-the-blank and free-response blocks all gained a "manual grading" group — tick it, set the max score, and optionally split it into sub-questions (each scored separately). When exporting the OMR template only the ticked questions carry points and a single whole-question regionregion (in mm). Unticked questions add not a single byte, so a pure multiple-choice template exports byte-identically to 1.3.2.
扫描端:识别时按模板的 region 从校正后的干净图上裁出每道主观题的作答区,落盘成 DATA/<rid>_q<题号>.jpg,用 GET /api/region/<rid>/<题号>.jpg 取;题号没裁过就老老实实 404,不猜不编。批量上传时每个页记录里带着 regions 列表,工作台据此知道哪道题有裁剪图。 Scanner: during recognition, each subjective question's answer area is cropped from the clean deskewed image using the template's region, written to DATA/<rid>_q<no>.jpg, and served at GET /api/region/<rid>/<no>.jpg; an uncropped question number returns an honest 404 rather than guessing. Batch uploads record a regions list on every page, which is how the workbench knows a crop exists.
两种给分模式,由模板决定:设了小问就按小问逐项给分再求和,没设就是整题给一个分。这条规则在制卡端 grading.js、扫描端 scoring.py、工作台 UI 三处逐字对齐 —— 三边各写一套口径迟早会分叉,那正是老师最不能接受的那种错。每项分值都会夹到自己的满分([0, 满分],scoring._num 顺手挡掉 NaN / ±inf),老师手滑把 8 打成 80 不会把整份卷子的总分炸掉。 Two scoring modes, decided by the template: if sub-questions are defined, each is scored and summed; otherwise the whole question gets one mark. This rule is kept character-for-character identical in the designer's grading.js, the scanner's scoring.py, and the workbench UI — three independently-written versions of the same rule would eventually drift, and that is exactly the kind of error a teacher cannot tolerate. Every mark is clamped to its own max ([0, max], with scoring._num screening out NaN / ±inf), so fat-fingering 8 into 80 cannot blow up the whole paper's total.
成绩单与导出:有主观题的考试,成绩单自动从两列(原始分 / 复核分)变成三列(客观分 / 主观分 / 总分),顶部汇总条写明「满分:客观 19 + 主观 16 = 35」;导出的 CSV 同样分三列,并在 X-Score-Max 头里给出 auto/subjective/total。纯选择题考试的行为一个字没变 —— 仍是两列、仍不带 X-Score-Max,老用户的脑子和脚本都不用改。主观题还会从客观题分布统计里排除(拿主观题的「填涂」去做选项分布本来就没有意义)。 Gradebook and export: an exam with subjective questions automatically switches from two columns (raw / reviewed) to three (objective / subjective / total), and the summary bar spells out "max: objective 19 + subjective 16 = 35"; the CSV export splits the same way and reports auto/subjective/total in the X-Score-Max header. A pure multiple-choice exam behaves exactly as before — still two columns, still no X-Score-Max header, so neither the teacher's habits nor any existing script needs to change. Subjective questions are also excluded from the bubble-distribution statistics, where a "fill" pattern would be meaningless.
阅卷工作台:主观题打分卡片 + 裁剪图/整页一键切换。每道主观题一张卡片:题目满分、每个小问一个输入框、卡片右上角「本题得分 X / 满分」小计(满分变绿、零分变红)。右边看图区默认显示这一题的作答区裁剪图(够大,看得清字),一键切「整页校对」回整张卷子核对;点别的题卡片就切到那一题。打分、改判、复核分任一改动,弹窗底部合计预览立刻重算,格式是 客观 19 + 主观 13 = 32 / 35 —— 老师要能一眼看出「这 32 分里有 13 分是我手打的」。 Grading workbench: subjective scoring cards with one-click crop/full-page switching. Each subjective question gets a card: its max score, one input per sub-question, and a "this question: X / max" subtotal in the corner (green at full marks, red at zero). The image pane on the right defaults to that question's cropped answer area (large enough to actually read the handwriting) with a one-click switch to full-page review; clicking another question's card jumps to it. Any change to scores, overrides or the manual total immediately recomputes the footer preview, shown as objective 19 + subjective 13 = 32 / 35 — the teacher must be able to see at a glance that 13 of those 32 marks were typed by hand.
测试:新增扫描端 tests/test_subjective.py(模板解析 → 裁剪落盘 → 路由 → 打分落库 → 成绩单 / 导出 / 夹取 / NaN / 老考试隔离,端到端一条龙),制卡端 dev/verify_subjective.cjs + dev/check_subjective_template.py,以及工作台 UI 的 dev/verify_subjective_ui.cjs(真 Flask 服务 + 真 Chromium 点 43 项:三列表头、两种给分模式、裁剪/整页切换、实时合计、保存后重开还在、未打分不计入总分)。 Tests: new scanner-side tests/test_subjective.py (template parsing → crop persistence → route → score persistence → gradebook / export / clamping / NaN / legacy-exam isolation, end to end), designer-side dev/verify_subjective.cjs + dev/check_subjective_template.py, and workbench UI dev/verify_subjective_ui.cjs (real Flask server + real Chromium, 43 assertions: three-column header, both scoring modes, crop/full-page switching, live totals, scores surviving a save-and-reopen, and ungraded questions not counting toward the total).
Fixed / 修复
制卡端:填空题配置「人工阅卷」时面板直接报错、并且会吃掉小问树。normalizeGrade 原来用 .map((s) => ({label, points})) 重建 subs,把填空题嵌套的 blanks / 子级结构整个丢掉,于是配置面板一打开就 Cannot read properties of undefined (reading 'forEach')。改为就地改 subs(只做 String(label) / 数值化),嵌套原样保留。 Designer: the fill-in-the-blank panel crashed when configuring manual grading, and silently ate the sub-question tree.normalizeGrade used to rebuild subs via .map((s) => ({label, points})), dropping fill-in-the-blank's nested blanks / child structure entirely, so the config panel threw Cannot read properties of undefined (reading 'forEach') the moment it opened. Now it mutates subs in place (only String(label) / numeric coercion), preserving nesting as-is.
阅卷工作台:切到「整页校对」后,再点「当前题裁剪」没反应。整页模式下当前题号会被清成 null,切回裁剪时不知道该回哪一题,于是按钮点了像是坏了。改为记住最近看过的那道主观题(GBIMG.lastQ),「当前题裁剪」永远回得到那一题。 Grading workbench: after switching to "full-page review", the "current question crop" button did nothing. Full-page mode clears the current question number to null, so switching back had no idea which question to return to and the button just looked broken. It now remembers the last subjective question viewed (GBIMG.lastQ), so "current question crop" always goes back to it.
工作台的图片缩放复位钩子在切图时丢失。setGbImg 原来用 Object.assign({}, GBIMG, …) 换了一个新对象,而看图器把「默认缩放复位」的钩子挂在这个对象上 —— 一换对象钩子就没了,切图后缩放会停在上一张的值。改为原地修改GBIMG。 The workbench's zoom-reset hook was lost on every image switch.setGbImg used Object.assign({}, GBIMG, …), producing a new object, while the viewer attached its "reset to default zoom" hook to that object — so the hook vanished and the zoom stayed at the previous image's value. It now mutates GBIMG in place.
dev/verify_settings.cjs 把当前版本号写死成 v1.0.3,导致每次发版这个脚本都会变红,而人的第一反应是「测试坏了」而不是「版本号改对了」。改为从 assets/js/core/version.js 读APP_VERSION —— 于是它只验真正该验的那件事:页面显示的版本号和源码一致。 dev/verify_settings.cjs hardcoded the current version as v1.0.3, so the script went red on every release and the natural reaction was "the test is broken" rather than "I bumped the version correctly". It now reads APP_VERSIONfrom assets/js/core/version.js, so it only checks the thing that actually matters: that the version shown on the page matches the source.
dev/verify_writebox.cjs 的路径从来就没对过,脚本永远跑不到底:它把产物写到 dev/scanner/tests/fixtures/real30/(dev/scanner/ 既不是 scanner/、又不在 .gitignore 里),Python 那一步的解释器也指向 dev/scanner/.venv/...(不存在);而 execFileSync 在本机沙箱里起不了任何子进程,所以「跑过了」这件事本身就不成立。产物改落 dev/.cache/writebox/,并把扫描端那一步拆成 dev/check_writebox_template.py(与 verify_subjective.cjs / check_subjective_template.py 同一套两步写法)。现在两步都是真的绿。 dev/verify_writebox.cjs had wrong paths from the start, so the script could never finish: it wrote its artifacts to dev/scanner/tests/fixtures/real30/ (dev/scanner/ is neither scanner/ nor gitignored), and the interpreter for its Python step pointed at dev/scanner/.venv/..., which does not exist; on top of that execFileSync cannot start any child process in this sandbox, so "it ran" was never true. Artifacts now go to dev/.cache/writebox/, and the scanner step is split out into dev/check_writebox_template.py — the same two-step pattern as verify_subjective.cjs / check_subjective_template.py. Both steps are now genuinely green.
验证脚本把 favicon.ico 的 404 当成 JS 错误(dev/verify_writebox.cjs)。静态服务没有 favicon,浏览器会自动去要一个,那条 Failed to load resource: 404 是环境噪音 —— 混进「无页面 JS 错误」这条断言里,会让人习惯性忽略这一行,真出 JS 错误时反而看不见。已按同一口径过滤(dev/verify_subjective.cjs 早就这么做了)。 Verifiers counted the favicon.ico 404 as a JS error (dev/verify_writebox.cjs). A static server has no favicon and the browser asks for one anyway; that Failed to load resource: 404 is environmental noise, and letting it into the "no page JS errors" assertion trains people to ignore the line — so a real JS error would slip past. Filtered by the same rule dev/verify_subjective.cjs already used.