学-练-测-评一体化:作业/试卷拍照批改与学情诊断
把一份(或多份)作业/试卷的作答图像,变成一份家长看得懂、教师能讲评、学生能照做的诊断报告,
交付一份可编辑的 Word 文档。
本技能是 kcode-textbook-graph 的下游消费方:图谱写一次,诊断用很多次。
知识点对齐与前置溯源全部走真实图谱数据,不靠模型记忆。
> v1.1.0 的三条口径(改动最大,先记住):
> 1. 报告固定四段式「总体评价 / 优势 / 待提升项 / 建议」,全文 ≤ 1800 字;
> 2. 正文不出现内部编码,一律用中文能力项名称,确需溯源才在名称后加括号小字编号;
> 3. 只交付 DOCX(不再产 PDF),全篇去教学行话。
>
> v1.2.0 新增:性能(详见 §四之二)。核心是「先量再优化」——
> stage_timer.py 出耗时剖面,report_cache.py 三级指纹缓存,
> kcode_lookup.py 图谱编译缓存,slice_for_llm.py 数据裁剪。
> ⚠ 实测结论:本地脚本不是瓶颈(kmap 对齐 40 题仅 0.1–0.2 秒),
> 真正的大头是模型侧的识卷/归因/成稿与无头浏览器自检。
>
> v1.6.0 新增:家长版可视化三表(详见 §三之二,★ 先读那节的口径警告)。
> 四段正文之后追加「成绩与错题一览」区:成绩概览表 / 需要重点补的内容表 / 典型错题表。
> 三张表不计入 1800 字上限,四段正文一字未动。
> ⚠ 其中「薄弱内容」表只报失分分布,不报正确率——原因见 §三之二,这是数据边界不是偷懒。
> 全部缓存可用 --no-cache 关闭,关闭后行为与 v1.1.x 完全一致。
>
> v1.3.0 新增:模型侧并行与流式(详见 §四之三)。既然大头在模型侧,就动模型侧——
> parallel_units.py 把识卷/判分/解析/归因拆成题目级单元并发(实测 4 线程 3.9×),
> stream_report.py 把四段正文分成两批并发并先流式吐核心结论(首屏 2.06s → 0.60s),
> 叠加以 --layers 的分层交付(核心版四段齐全、先发;完整版随后补全)。
> ⚠ 两条红线:流式只推已经算出的确定性数字,先给结论再改比晚给伤害大;
> 核心版必须保持四段齐全,宁可返回占位文案也不许缺段。
>
> v1.4.0 新增:分档模型 + 自检门控(详见 §四之四)。前两阶段把「怎么跑得更快」做完了,
> 这一阶段动的是「哪些活根本不该花这个价」——
> model_tier.py 分档路由:识卷/判分走快档(规则性强,实测 50% 的分派可降档、
> 成本量级省 ~44%),解析/归因/成稿走强档;
> selfcheck_gate.py 自检门控:无头浏览器自检单项占 47–84%,但它检的是
> 生成逻辑而非本次产物,故按「渲染脚本源码哈希 + 浏览器版本」决定是否重跑
> (实测每轮省 2.47 秒 = 自检层的 67%)。
> ⚠ 两条红线:归因阶段永不降档(它是本技能的智力核心);
> 文本级自检不接受门控(它检本次产物且只要毫秒),门控只作用于无头浏览器那一层。
零、开工前必做三件事
- 核实教材版本(最贵的一课)。成都/四川尤其要命:
物理=教科版(非人教版)、英语=外研版、高中数学=人教A版、小学数学=北师大版、
初中数学=北师大版。版本错 → 知识顺序与编码锚点全悬空。
不确定就问用户一句,比返工便宜。 - 确认学段与年级。学段决定归因用语(小学用「数感/量感/运算能力」,不用「数学抽象」)
与图谱文件选型。另注意:初中物理自八年级起开设,别把物理解析挂到七年级作业上。 - 确认是否有参考答案。有答案 → 可判对错给分;无答案 → 该题一律标「待确认」,
绝不用「看起来像错的」来推断(见 §二 红线)。
一、五阶段流水线
① 识卷 图像 → 逐题 {题干, 学生作答, 分值, 可辨度}
② 判分 对照参考答案按分值给分 → 每题得分 + 总分
③ 解析 每题双视角(命题者 / 做题者)
④ 归因 能力项定位 → 四类归因 + 认知层分析 + 前置溯源
⑤ 成稿 补缺优先级 → 四段式报告 → 导出 DOCX
① 识卷(★「不臆造」的落点)
逐题输出:题干要点 / 学生作答(可见痕迹) / 满分 / 可辨度。
可辨度三档 → 直接映射到题目的 status:
| 情形 | status | 处理 |
|---|---|---|
| 作答题清晰可辨 | → 进入 ② 判分 | 正常判定 |
| 图像模糊 / 涂改覆盖 / 拍摄残缺 | unreadable |
标「待确认」,不给分不判错 |
| 无参考答案可核(主观题、涂卡区未下发答案) | pending_answer |
标「待确认」,不给分不判错 |
三条禁令:
- 不允许根据「看起来像错的」推断对错;
- 不允许补全学生没写的步骤来凑得分点;
- 不允许猜分值(分值不清就留 null,报告会显示「—」)。
题干摘录要点即可,不必逐字抄;但学生作答的痕迹要如实记(划掉重写、旁注、涂改位置常常
正是归因的关键证据)。
② 判分
- 按题目分值给分,输出
score_full/score_got。 - 解答题有步骤分 → 用
partial(部分得分),并写清丢的是哪一步。 - 汇总:
sum(score_got) / sum(score_full),得分率由脚本算,别手写。 - ⚠️ 题面满分合计与卷面声明总分不一致时:若差额可被「未批改/待确认的题」解释,属正常;
若题面合计超过声明总分 → 真错误,必须查。
③ 双视角解析(每题)
| 视角 | 写什么 |
|---|---|
| 命题者视角 | 考点是什么;陷阱埋在哪;命题意图(想区分哪种能力);干扰项怎么设计的 |
| 做题者视角 | 审题关键(哪个词决定了方向);解题步骤(可照做的分步流程);规范作答(评分点分布、格式、单位、结论句式) |
写解析时对照图谱节点的错因参考与常见题型字段——若卷面痕迹吻合,归因可信度大幅提高,可直接引用;
若不符,说明是新错法,值得在报告中特别指出。
⚠ 双视角解析进数据与讲评稿,不进家长版报告正文(报告正文只有四段,见 §三)。教师需要讲评时,
从数据契约里取这一步的内容。
④ 归因(本技能的智力核心)
先做知识点定位,再做四类归因。
知识点定位(一律走脚本,不凭记忆写码):
# 列出全库索引
python scripts/kcode_lookup.py --list
# 单条检索(调试用,带出层级/难度/素养/典型错因/前置链)
python scripts/kcode_lookup.py --subject 物理 --stage 初中 --query "欧姆定律"
# 批量对齐:读诊断数据的 questions[].kc_hint,输出 kmap 段
python scripts/kcode_lookup.py --subject 物理 --stage 初中 --in 诊断.json --out kmap.json
# 校验报告里的编码是否都真实存在(⚠ 此模式不要同时传 --subject/--stage)
python scripts/kcode_lookup.py --subject 物理 --stage 初中 --verify 报告数据.json
⚠ 接入新图谱后必做两项断言(脚本不报错,但内容可能整片消失):
_pre/_next 非空(关系键名中英文混用会被静默丢弃)、unit_total > 0
(unit_scope 前缀须与节点键同形,否则覆盖率失真)。详见 §六 踩坑表。
未命中 ≠ 随便编一个码:进 missing[],报告正文不出现该项(家长不关心图谱有没有建全),
只在「依据边界」里说明哪几处没能对上图谱,并建议建校本题库标签与内部编码并行。
绝不为迁就一份卷子新增编码。
四类归因判据(完整版见 references/attribution-rules.md):
| 归因 | 卷面判据 | 补救形态 |
|---|---|---|
| 不会 | 留空/只抄题干/方向无关;同知识点卷内从未答对 | 补前置(回溯到能答对的层级重讲) |
| 不熟 | 方向对、第一步对,中途错;有对有错;限时错不限时对 | 限时重复练 |
| 方法问题 | 用了不该用的方法;方法对但笨重超时 | 方法对比训练(同题两法) |
| 规范/粗心 | 思路答案都对,但步骤跳跃被扣分、抄错数、结论缺比较对象、涂改覆盖 | 流程化习惯(草稿分区、结论模板) |
冲突时取最先命中者:空白→不会 / 有对有错→不熟 / 方法与题不匹配→方法 / 会做但被扣→规范。
⚠️ 判据错了,后面全错:把「枚举漏数」判成「不会」,补救方案就会变成「重讲古典概型」,完全打偏。
另注意:答案本身正确但表述不完整 → 规范问题,不是计算错,不要误导学生去练计算。
认知层分析必写三段:surface(只陈述卷面可见事实)/ root(指向认知结构,不写「态度不端正」)/
pattern(是首次还是反复,引用跨题证据)。—— 这三段是内部推导,报告里只落成人话结论。
前置溯源:若失分能力项的前置项本身也是失分点 → 根因更上游,属「旧疾未清」而非「新知未学」。
这是家长版报告里必须讲清楚的一件事——「这次错的是 A,但根子在上学期的 B」比单说 A 有用得多。
⑤ 成稿与交付
- 补缺优先级:按
命中次数 × 外溢影响数排序,不用「感觉哪块弱」。图谱频度字段可参与加权,
但不得在报告里标注年份/卷别/题号(本项目的铁律)。 - 四段式成稿:严格按 §三 的结构与字数上限写,见那份契约。
- 交付:只出 DOCX(做法见 §四)。
二、红线(违反即报告作废)
- 不臆造:作答复不可辨或参考答案缺失 → 标「待确认」,不计入对错统计,不写入待提升项,
只在报告末尾的补件提示里说明。 - 不臆造编码:图谱没有的节点,进
missing[]并说明,绝不新增编码迁就卷子。 - 数字一律脚本算:报告正文里所有数字(题数/得分/得分率/失分题数/涉及能力项数)由
len()/求和算出后注入。手写常量必错——本项目实拍:手写「3 个主责节点」实算是 7 个。 - 归因必须可观察:证据只能是卷面可见事实,「他大概不懂」不算证据。
- 不改图谱:发现的缺边/缺节点写成建议,交回
kcode-textbook-graph全链路重生成。 - 正文不出现内部编码:报告四段正文里不得出现编码;需要溯源时只在能力项名称后加括号小字。
编码不得出现在任何标题或结论句里(见 §三 硬约束)。 - 只出 DOCX:不再生成 PDF,也不保留 PDF 相关的分支、参数或说明。
三、报告契约(四段式,固定不变)
这是本技能最重要的一张表。报告永远只有以下四段,顺序、用途、句数都不得改。
总字数上限:≤ 1800 字(含小标题,不含卷面信息条与末尾的补件提示)。
建议区间 1000–1500 字。超限时先砍「优势段」与「建议段」的铺陈句,绝不砍事实与动作。
| 段 | 用途(回答家长的什么问题) | 句数 | 写法要求 |
|---|---|---|---|
| 总体评价 | 这次考得怎么样、整体是个什么状态 | 3–4 句 | 首句给结论(得分率 + 一句话定性),其后 1–2 句说清「强在哪、弱在哪」的总体分布,末句给出与上次/与标准的对比(无对比数据则省略,不编)。不出现任何编码。 |
| 优势 | 孩子哪里是真好,不是客套 | 2–4 句 | 每条必须有卷面依据(哪几题、什么表现);只写「确实做对了且不是蒙对的」,答对但步骤跳跃不完整的不算优势。没有可靠优势时改为一句实话(如「本次没有可以确认的稳定优势项」),不硬凑。 |
| 待提升项 | 具体差在哪、差成什么样、根子在哪 | 3–6 句,最多 3 个条目 | 每条固定三件事:① 用中文能力项名称说清是什么(不得用编码);② 卷面上具体是什么表现(错了几题、怎么错的,用可见事实);③ 归到四类原因里的一类,若根子在更早的前置知识,一并点明(「这次错的是 A,根子在上学期的 B」)。确需溯源时,在能力项名称后加括号小字编号,如「分数的加减运算(K2031)」——编号只出现在这里,不得进标题、不得进结论句。 |
| 建议 | 接下来这一两周具体做什么 | 3–5 句,最多 3 条 | 每条必须是可执行动作:做什么、做多少、什么时候做、怎么算做到了。按待提升项的优先级排序(先补根子、再补本次)。禁止出现「多加练习」「认真审题」「培养兴趣」这类没有落点的句子。 |
写作硬约束
- 去行话:不出现「学情诊断」「知识图谱」「认知结构」「元认知」「命题立意」「核心素养水平」
「K 码」等教学/测评术语。等价说法:
「知识图谱定位」→「对照课本」;「认知层分析」→「为什么错」;
「前置溯源」→「根子在哪」;「补缺优先级」→「先补哪个」。 - 结论必须具体:「掌握不牢」是废话,「20 道计算题错了 5 道,都是小数点位置」才是结论。
- 数字口语化:写「19 道题错了 6 道」,不写「失分率 31.6%」;比例只在总体评价段出现一次。
- 称呼一致:面向家长 → 用「孩子」;面向学生 → 用「你」。同一份报告不混用。
- 不评态度:不写「粗心大意」「不认真」「态度不端正」这类评价,只写行为与可改的流程
(如「抄数时抄错了符号,建议草稿本上分区抄写」)。 - 不承诺分数:不写「照这样做能提高 10 分」。
- 待确认题:只在报告末尾的补件提示里一句话交代,不占四段正文的篇幅。
报告骨架(固定)
标题(含卷面信息条:科目 / 年级 / 卷面 / 日期)
总体评价 ← 3–4 句
优势 ← 2–4 句
待提升项 ← 3–6 句 / ≤3 条
建议 ← 3–5 句 / ≤3 条
———(以下不属于四段正文,字数不计入上限)———
补件提示(有未判定题目时才出现)
依据边界声明(固定一句:依哪份课本目录、哪部分未作判定)
空白兜底(每一段都必须自解释)
任何一段写不出来时,不允许留空标题。改为一句如实说明 + 补什么就能有,例:
- 优势段:本次 19 道题中 13 道未作判定,没有可以确认的稳定优势项;补齐参考答案后可重出本报告。
- 待提升项:本次全部题目因缺少参考答案未作判定,暂时无法指出具体差在哪;补齐后本节会自动生成。
三之二、家长版可视化三表(v1.6.0)
四段正文之后追加一个 Heading 1「成绩与错题一览」,下挂三个 Heading 2 小节。
三张表不计入 1800 字上限,四段正文一字未改。
| 小节 | 表头 | 数据来源 |
|---|---|---|
| 1. 本次成绩概览 | 项目 / 本次情况 / 说明 | derive() 的 sum_got、sum_full、rate、mastery、n_err、n_todo |
| 2. 需要重点补的内容 | 考查内容 / 涉及题号 / 失分情况 / 常见错法 | kmap.hits(role=主)聚合 + 节点 err 字段 |
| 3. 典型错题 | 题号 / 考查内容 / 当时怎么做的 / 问题出在哪 | questions[](ERR_STATUS)join kmap.hits 与 diagnosis.attributions |
★ 口径警告:为什么第 2 张表不报正确率(2026-10-06 实测定案)
kcode_lookup.py --in 只为失分题建立「题 → 能力项」映射,答对的题不产生 hit。
即 kmap.hits 天然是失分侧数据(main_nodes = 失分相关节点,unit_covered 6/40)。
⇒ 若照常写 正确率 = 做对题数 / 已判定题数,分母里只会出现失分题,每一行都算出 0%,
六块内容全部显示「明显薄弱」。家长会读成「六块内容全都完全没掌握」——这是彻头彻尾的误导。
实测:sample-junior-physics.json 有 8 题,其中 q1 correct 无任何 hit,
7 条 hit 全部落在 q2–q6 的失分题上。
故本表只报它确实知道的事:哪些内容出了问题、各牵连几道题、对应什么常见错法。
真实正确率需「全卷每道题都做图谱映射」,属上游 kcode_lookup 的能力边界,
在此如实说明,不以估算数字填充(符合 §二 红线 1「不臆造」)。
自检断言(make_docx.py --verify,家长版分支)
VISUAL_PARENT = "成绩与错题一览"
VISUAL_SECTIONS = ("1. 本次成绩概览", "2. 需要重点补的内容", "3. 典型错题")
VISUAL_TABLE_KEYS = ("项目", "考查内容", "题号")
- 区标题与三个小节标题必须出现在落盘 DOCX 里;
- 表格数 ≥ 3;概览表非空;
- 典型错题表数据行数 ==
n_err(空表兜底 1 行); - 薄弱内容表数据行数 == 能力项数(
len(d["kp"]))。
⚠ 用 len(t.rows) - 1 数数据行(len(t.rows) 含表头,实测差 1 行会误判)。
⚠ 表格里不得出现内部编码(家长版三表全是中文名,验证脚本实测为 0 泄漏)。
层级约定
四段正文用 Heading 1;本区整体 Heading 1、三小节 Heading 2,
避免与教师版的「附一~附六」争抢同级编号(否则目录出现两套并行编号)。
四、数据契约与工具链
单一数据源:所有产物(HTML 讲评稿 / DOCX 家长报告)只从一份 JSON 渲染。
完整字段定义见 references/report-schema.md(动手前先看一遍)。
数据骨架:meta / questions[] / kmap / diagnosis / prescription / evidence。
工作目录约定
<工作目录>/
诊断数据.json # 单一数据源(先按 schema 起草)
out/
诊断报告.html # 中间母版(可打印,供教师讲评存档)
家长版.html # 四段式家长版(与 DOCX 同源,便于比对)
诊断报告.docx # ★ 唯一交付件
_kmap.json # 能力项对齐结果(可回填进数据文件)
_llm_slim.json # 生成四段正文用的最小充分集
_profile.json # ★ 耗时剖面(哪一段花了多少秒)
_pipeline.log # 全过程日志
.report_cache/ # 报告指纹缓存(可安全删除,删了只会变慢)
生成命令
PY = <本机 Python 3.10+> # 标准库即可
VENV = <含 python-docx 的 Python 环境> # 或给当前环境 pip install python-docx
# ① 知识点定位 → 回填 kmap(先跑)
$PY scripts/kcode_lookup.py --subject 物理 --stage 初中 --in 诊断数据.json --out out/_kmap.json
# ② HTML 中间母版(含文本级校验)
$PY scripts/build_report.py 诊断数据.json -o out/诊断报告.html --verify
# ③ 真实交互自检(读落盘交付件)
$PY scripts/selfcheck_html.py out/诊断报告.html
# ④ DOCX(唯一交付件,从 JSON 直接重建)
$VENV scripts/make_docx.py 诊断数据.json -o out/诊断报告.docx --verify
一键跑完用 scripts/pipeline.py(已去掉 PDF 环节):
$PY scripts/pipeline.py 诊断数据.json --subject 物理 --stage 初中 --name 诊断报告
四轮性能改造的开关可以叠加使用(v1.4.0 的默认行为已是最优组合):
# 全量最优(缓存 + 分档 + 自检门控 默认全开)
$PY scripts/pipeline.py 诊断数据.json --subject 物理 --stage 初中 --tier --layers
# 回归比对(强制全量重跑 + 强制真自检)
$PY scripts/pipeline.py 诊断数据.json --subject 物理 --stage 初中 --no-cache --force-selfcheck
跑完必看耗时剖面(它告诉你哪一段最贵):
$PY scripts/stage_timer.py out/_profile.json
四之二、性能:缓存与预计算(v1.2.0)
先量,再优化
不要凭"感觉慢了"去优化。 报告生成走的是这条链:
算指纹 → 能力项对齐 → 母版 HTML → 家长版 HTML → 无头浏览器自检 → DOCX → 终检
实测结论(本机、8 题样例、_profile.json 可复现):
| 阶段 | 冷启动 | 缓存命中 | 说明 |
|---|---|---|---|
| 算三层指纹 | 0.7s | 0.7s | 纯 CPU,已是最小 |
| 能力项对齐 | 0.7s | 0.0s | 图谱编译缓存 |
| 母版 HTML | 0.7s | 0.0s | 产物缓存 |
| 家长版 HTML | 0.7s | 0.0s | 产物缓存 |
| 无头浏览器自检 | 5.3s | 5.3s | ★ 单项最大,且不接受缓存 |
| DOCX | 1.2s | 0.0s | 产物缓存 |
| 合计 | 9.7s | 4.7s(跳自检 0.8s) |
三条必须记住的判断:
- 本地脚本不是瓶颈。kmap 对齐在 40 题量级只要 0.1–0.2 秒;就算把
图谱检索优化到零,总时长也只少零点几秒。真正的耗时在模型侧
(识卷 / 归因 / 成稿四段),优化精力要花在那里。 - 无头浏览器自检是最贵的单项,占一半以上。它检查的是"生成逻辑"而非
"本次产物",改了渲染脚本才需要重跑。快速迭代用--no-selfcheck,
交付前必须补跑。 - 缓存省下的是"重复劳动的绝对值",不是百分比。一次全量省 5 秒看着不多,
但真实工作流是同一份卷子改十遍(改称呼、压条数、补答案),
十次全量 = 50 秒纯浪费,缓存后只剩首次。
三级指纹:决定"什么变了要重算什么"
缓存粒度不是"整份报告",而是按产物分别判定。指纹分三层:
| 层 | 覆盖内容 | 变更后果 |
|---|---|---|
| 图谱层 | 图谱文件 size+mtime_ns | 全链失效 |
| 事实层 | 逐题 题干/作答/分值/状态/参考答案 + 归因 + 方案 | 全链失效 |
| 呈现层 | 姓名/日期/卷面名 | kmap 可复用,母版与报告重出 |
| 措辞层 | 四段正文(散句 + 条目两种写法) | 仅家长版/DOCX 重出 |
实测行为(report_cache.py 的 fingerprints() 可验证):
| 场景 | kmap | 母版 | 报告/DOCX |
|---|---|---|---|
| 只改四段措辞(最高频) | 复用 | 复用 | 重出 |
| 改一道题的判定状态 | 失效 | 失效 | 重出 |
| 只改姓名 / 报告日期 | 复用 | 重出 | 重出 |
缓存红线(★ 违反即等于埋雷)
- 事实变了必须失效。
status逐题进指纹 —— 把pending_answer悄悄当
correct处理会让报告凭空多出"优势项",是本技能最严重的一类错误。 - 缓存只跳过生成,绝不跳过校验。命中后仍要过存在性 + 大小断言;
终检(题数 / 四段字数 / 无编码泄漏)照常执行。 - 降级必须安全。缓存损坏 / 不可写 → 静默转为全量生成,不得因此跑不出报告。
- 首跑指纹必须稳定。
resolve_graph_path()必须在算指纹之前调用 ——
否则首跑时kmap.graph_path还未写入,图谱层取到"none",
回填后才变真哈希,导致首跑永远不命中(已踩)。 - 产物必须等价。缓存复用与全量生成的 DOCX 除预期差异外逐部件一致(已验证)。
数据按需裁剪
slice_for_llm.py 把完整数据裁成"生成四段正文的最小充分集":
$PY scripts/slice_for_llm.py 诊断数据.json -o out/_llm_slim.json # 默认档
$PY scripts/slice_for_llm.py 诊断数据.json -o out/_llm_min.json --level min # 极简档
$PY scripts/slice_for_llm.py 诊断数据.json --check out/_llm_slim.json # 口径自检
实测:31.1 KB → 11.4 KB(默认档 42%)/ 8.7 KB(极简档 34%)。
裁剪保留:全部失分题与归因(含 blank)、优势候选、补救方案、待确认计数、
允许出现的编号白名单、四段写作契约(段名/句数/字数上限/禁词表)。
裁剪绝不做的事:不裁待确认计数、不裁 blank 题、不改任何数字口径、
不改 error_type。产出后立刻自检数字与题号是否与完整数据逐项一致。
> ⚠ 裁剪稿只是"喂给模型的输入",不是交付数据源。交付仍以完整 JSON 为准。
性能相关开关
--no-cache 禁用全部缓存(强制全量重跑,用于回归比对)
--rebuild-index 强制重建图谱编译缓存
--no-slim 不生成数据裁剪稿
--no-selfcheck 跳过无头浏览器自检(快速迭代用;交付前必须补跑)
--workers N 题目级并发度(0=CPU 核数,1=串行;默认 4)
--split-units 切分题目级工作单元清单(供逐题分派给模型并发调用)
--plan 打印模型侧生成计划(题目并发 + 四段分批 + 分层交付)
--layers 产出分层交付件(核心版 / 完整版)并自检
--time-travel 时序沙盘:对比「四段齐发」与「按依赖分两批」的首屏与总时长
--tier 产出分档模型路由计划(快档做识卷/判分,强档做解析/归因/成稿)
--no-downgrade 配合 --tier:禁用降档(全部按阶段基线,用于对照)
--no-gate 禁用自检门控(强制每次都跑无头浏览器自检)
--force-selfcheck 强制本轮跑无头浏览器自检,并刷新门控指纹
--solo 单产物模式:交付目录只留 DOCX,中间件落 --work-dir(终检后自动清理)
--work-dir DIR 配合 --solo:指定中间件工作目录(默认 tempfile.mkdtemp())
--keep-work 配合 --solo:保留工作目录不清理(排障复盘用)
--keep-html 配合 --solo:额外保留 HTML 母版与家长版(教师讲评预览用)
单产物模式(--solo):交付目录里只有报告
为什么要它:常规模式下中间件与交付件同目录,一轮跑完 out/ 里会堆
59 张裁图 + 6 个中间 JSON + 4 个 HTML,用户要的「一份报告」被淹在里面。
python pipeline.py 诊断数据.json --subject 物理 --stage 初中 --solo
<交付目录>/ ← 只有报告
学情诊断报告_初中物理.docx
<workdir>/ ← 全部中间件,终检通过后自动删除
_kmap.json _llm_slim.json _tiers.json _profile.json _pipeline.log
<name>_诊断报告.html <name>_家长版.html
四条设计要点(都是踩过坑才定下来的):
- 靠目录分离保证"只有一份",不靠"跑完再挑着删"。后者中途失败会留下半成品残骸。
- 失败时绝不清理(默认行为,非可选项)。中间件是排障的唯一线索。
- 诊断文件要另存:
workdir删掉后剖面/日志会一起消失 → 复制一份到
.report_cache/_last_profile.json与_last_pipeline.log(持久,供事后查阅)。 - ⚠ 缓存产物必须
stash进持久目录。单产物模式下中间件在临时目录、
跑完即删,若按原路径登记缓存,下一轮probe必然「产物已不存在」→ 缓存白做。
ReportCache.stash()把产物复制进.report_cache/后再登记。DOCX 落在交付目录
(持久)无需 stash。
> ⚠ 实测过的两个坑:① 缓存命中时只打印「已复用」却不把产物落到本次目标路径 →
> 单产物模式下报 docx_missing(常规模式因路径恰好相同而侥幸不暴露);
> ② 交付目录里文件名重复后缀(…_初中物理_诊断报告.docx)——
> 单产物模式已改为 <name>.docx,不再重复「诊断报告」四字。
四之三、模型侧并行与流式(v1.3.0)
> ⚠ 本地脚本不是 30 分钟的大头(见 §四之二)。真正的大头在模型侧:
> 识卷、判分、双视角解析、四类归因、四段成稿。本节专治这部分。
三个手段量的是三条不同的时间轴
混为一谈会得出错误的优化结论:
| 手段 | 缩短的是 | 家长感知 |
|---|---|---|
| 题目级并行 | 「全部完成」的总时长 | 最终交付变快 |
| 流式输出 | 「看到第一句有用结论」的时长 | 等待期间有东西看 |
| 分层交付 | 「能拿走成品」的时长 | 5 分钟内拿到核心版 |
一、题目级并行(parallel_units.py)
题目之间彼此完全独立:第 3 题看不清不影响第 7 题怎么判。串行处理 40 题就是 40 倍排队。
① 识卷 8 题并发 ┐
② 判分 8 题并发 ├─ 阶段之间串行(判分需要识卷产物)
③ 解析 8 题并发 │ ★ 并行发生在**同一阶段的题目之间**
④ 归因 8 题并发 ┘
实测加速(8 题 × 2 阶段,单题 0.15 秒):
| 并发度 | 耗时 | 加速 |
|---|---|---|
| 1(串行) | 2.41s | 1.0× |
| 2 | 1.22s | 2.0× |
| 4 | 0.61s | 3.9× |
| 8 | 0.31s | 7.7× |
四条设计约束(违反了会出静默错误):
- 确定性顺序优先于并发效率。合并时强制按题号排序,与完成先后无关。
题号一旦随并发抖动,家长会以为「第 3 题」是另一道题。 - 单题失败必须隔离。一题失败不能让整份报告作废 → 进
failed[]并在报告中标「待确认」,
绝不因为"凑齐"而编造该题结论。这是本技能核心红线的并发版本:宁可少一道,不可编一道。 - 并发度默认保守(4)。模型调用有速率限制,开太高触发限流反而比串行更慢。
- 串行路径必须保留(
workers=1),用于回归比对 —— 并发不得改变业务结果。
# 切分工作单元(供逐题分派给模型并发调用)
$PY scripts/parallel_units.py 诊断数据.json --split -o out/_units.json
# 并发压测:验证加速比 / 顺序稳定 / 失败隔离 / 与串行一致
$PY scripts/parallel_units.py 诊断数据.json --selftest --workers 4
二、四段并发生成(stream_report.py)
四段不是"一起跑"。 依赖关系决定了必须分两批:
| 批次 | 依赖 | 段 | 理由 |
|---|---|---|---|
| 批 1 | 判分完成 | 总体评价、优势 | 只用得上判分结果,不必等归因 |
| 批 2 | 归因完成 | 待提升项、建议 | 要引用具体失分项与前置根因 |
硬把批 1 压到归因之后一起并发,是拿「早出两句」换并发度,总时长反而更差。
实测时序对比(模拟单题 0.6 秒、归因前置等待 1.2 秒):
| 做法 | 首屏可出 | 全部完成 |
|---|---|---|
| ❌ 等归因 → 四段齐发 | 2.06s | 2.06s |
| ✅ 按依赖分两批 | 0.60s | 2.64s |
首屏快 1.45 秒(约 2.4 倍),总时长只多 0.58 秒。 这是感知等待的胜利,
不是总时长的胜利 —— 家长更早看到「这次考得怎么样」。
$PY scripts/stream_report.py 诊断数据.json --plan # 看依赖分组
$PY scripts/stream_report.py 诊断数据.json --demo-parallel # 时序沙盘
$PY scripts/stream_report.py 诊断数据.json --demo-stream # 流式流程演示
三、流式输出的红线
「先流式出核心结论」必须先给已经算出来的东西,不能为了"早点出内容"而先给结论再改。
- ✅ 分数、得分率、失分题数、待确认数 —— 由脚本算出,确定性,可立即推送
- ✅ 模型写的定性句 —— 生成完一段推一段
- ❌ 绝不先给一个估计值再纠正。先给错数字再改,比晚给 20 秒伤害大得多
推送顺序 ≠ 生成顺序。建议按 总体评价 → 待提升项 → 建议 → 优势:
优势是「好消息」,晚一点到不影响家长做决策。
四、分层交付(--layers)
核心版(先行交付) 完整版(随后补上)
├ 总体评价 ├ 核心版全部内容
├ 优势 └ 逐题双视角解析 + 溯源与连带明细
├ 待提升项 (教师讲评 / 存档用)
└ 建议
★ 最重要的一条契约约束:核心版也必须是四段齐全。
分层交付是「细节延后」,不是「结构缺段」。不能因为"优势段还没生成"就让它缺位 ——
那会违反 §三 的报告契约,也会被 build_report 的段数校验判 FAIL。
正确做法:优势段在核心版里返回如实说明(「要等逐题分析完成才能确认,完整版补上」),
而不是留空标题、更不是编一句客套话。占位时置 _strength_placeholder = true 便于追踪。
另外两条:
- 两层四段内容必须逐字一致(不能是"两个版本",否则家长以为看的是两份报告)
- 待确认计数必须带进核心版 —— 它是「诚实」的一部分,不因精简而消失
$PY scripts/stream_report.py 诊断数据.json --layers -o out/
四之四、分档模型与自检门控(v1.4.0)
> 前两阶段回答的是「怎么跑得更快」;这一节回答的是「哪些活根本不该花这个价」。
> 两个手段都不改变任何产出内容 —— 它们只改「用哪个模型」和「哪些检查没必要每轮重做」。
一、分档模型路由(model_tier.py)
核心洞察:五阶段的难度极不均匀,但现在全部用同一个强模型跑。
于是最便宜的活(把字认出来、按分值算个数)和最贵的活(四类归因)同价。
阶段基线档位
| 阶段 | 档位 | 理由 |
|---|---|---|
识卷 read |
快档 | 纯视觉转录,不需要推理 |
判分 grade |
快档 | 按分值比对正误,规则性最强 |
双视角解析 explain |
强档 | 要讲命题意图与陷阱,需要真理解 |
归因 attribute |
强档(红线) | ★ 本技能的智力核心,永不降档 |
成稿 compose |
强档 | 四段式行文,涉及口径与去行话 |
题级分档(细粒度,判据全是卷面可观测量)
| 信号 | 档位 | 理由 |
|---|---|---|
待确认(unreadable/pending_answer) |
→ 快档 | 本来就不判分,没必要花强模型 |
| 空白题(识卷/判分阶段) | → 快档 | 没内容可推理 |
| 客观题(choice/judge/fill) | → 快档 | 规则性强 |
| 主观题(answer/essay)在识卷/判分阶段 | → 强档(反向升级) | 识卷要理解手写推导与图表,比逐字转录难 |
| 多空作答(≥2 空)在判分阶段 | → 强档 | 逐空判分易错 |
| 单题失分 ≥50% 在解析/成稿阶段 | → 强档 | 必须讲清楚为什么错 |
★ 反向升级是必须的:不能想当然认为「识卷=快档」。遇到主观题,识卷也要升级 ——
把学生的推导过程看错,后面全盘皆错。
实测(8 题样例)
合计 40 次分派:快档 20(50%)/ 强档 20(50%)
成本量级估算(权重 快档=1 / 强档=8):全强档 320 → 分档后 180,省下约 44%
$PY scripts/model_tier.py 诊断数据.json --plan # 打印分派计划
$PY scripts/model_tier.py 诊断数据.json --plan --json out/_tiers.json
$PY scripts/model_tier.py --selftest # 离线自检(12 项)
模型名走环境变量(XUELIAN_MODEL_FAST / XUELIAN_MODEL_STRONG),脚本自身不硬编码供应商。
分档红线
- 归因阶段永不降档。降档等于换了个技能。
check()内置断言。 - 分档只改「用哪个模型」,不改判分口径与四段式契约。同一份数据在快档与强档下,
判分结果与报告结构必须一致。 - 降档必须显式记录原因,便于事后审计「这题当时为什么走了快档」。
二、自检门控(selfcheck_gate.py)
核心洞察:无头浏览器自检占总耗时 47–84%,但它检的是「生成逻辑」而非本次产物。
逻辑没变(渲染脚本没改)时,它每一轮都重跑同一套断言 —— 纯浪费。
门控判据(三者任一变化即失效)
| 判据 | 为什么 |
|---|---|
渲染脚本源码 sha256(build_report.py / selfcheck_html.py) |
主判据:脚本改了,断言是否还成立就未知了 |
| 浏览器可执行文件 + 版本号 | Edge/Chrome 升级会改变渲染与 JS 行为 |
harness 版本号(HARNESS_VERSION) |
改断言就必须 +1,否则旧指纹会把新断言跳过 |
实测(8 题样例,本机)
| 环节 | 耗时 |
|---|---|
| 门控判定(哈希比对) | 0.60 秒 |
| 自检·仅文本级 | 0.62 秒 |
| 自检·含浏览器级 | 3.69 秒 |
| 门控命中时自检层合计 | 1.22 秒 |
| 门控关闭时自检层合计 | 3.69 秒 |
→ 每轮省下 2.47 秒,约为自检层的 67%。
门控红线(违反即埋雷)
- 文本级自检不接受门控。它极快(毫秒级)且检的是本次产物,必须每轮照跑。
门控只作用于无头浏览器那一层。--no-browser只跳过浏览器级,文本级必跑。 - 门控命中 ≠ 免检,而是「通常不跑 + 定期抽验」:每
SMOKE_EVERY(默认 10)轮
至少真跑一次,防「脚本没变但行为变了」长期不被发现。 - 只有真自检通过(
ALLPASS)才登记指纹。失败、跳过一律不登记 ——
不许"谎报合格"。 - 指纹由分项重算,不采信存下来的字符串。若采信字符串,一旦分项被改动
(脚本更新、浏览器升级、手工编辑状态文件),字符串不会跟着变,该跑的就被跳过了。
这是本项目实际踩到的一个坑(见 §七 踩坑表)。
$PY scripts/selfcheck_gate.py --data 诊断数据.json --info # 看状态
$PY scripts/selfcheck_gate.py --data 诊断数据.json --should-run # 退出码 0=该跑, 3=可跳过
$PY scripts/selfcheck_gate.py --data 诊断数据.json --force # 强制失效
$PY scripts/selfcheck_gate.py --selftest # 离线自检(12 项)
pipeline 里的分层
⑤a 自检门控判定(毫秒级哈希比对)
⑤b 交付件自检:文本级【必跑】 + 浏览器级【按门控】
⑤c 登记门控指纹(★ 仅当浏览器级真跑且 ALLPASS)
五、交付规范(DOCX)
完整版见 references/delivery-spec.md。要点:
为什么从 JSON 直接重建,不走 HTML→docx
报告含表格与底色,Word 没有 CSS 背景概念,HTML 转过来必然丢图丢色。必须用 python-docx
按四段式骨架重新生成。生成逻辑写在 scripts/make_docx.py,不要在生成器外手改 docx。
必设项
- 中文字体必须写
w:eastAsia(只设font.name中文不生效):
python run.font.name = '宋体' run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体') - 目录域用
w:fldChar三段式插入。交付时必须提示用户
「打开后按 Ctrl+A 再按 F9」更新目录域,否则目录是空的。 - 四段标题用 Heading 级别(供目录与导航窗格识别),段内条目用正文 + 项目符号。
- 页脚自动页码。
- ⚠️ 生成脚本里别把变量命名成
qn—— 会遮蔽docx.oxml.ns.qn导入,
报UnboundLocalError: cannot access local variable 'qn'(已踩)。
中间母版 HTML(仅供讲评存档,不是交付件)
如果要留一份可打印的讲评稿:单文件自包含,宣纸套:#FBF8F1 纸 / #4A5F6B 黛青 / 宋体族。
@page{size:A4 portrait;margin:0} 与 -webkit-print-color-adjust:exact 必须显式声明。
⚠ 母版里也不得出现 PDF 生成步骤——本技能不再产 PDF。
六、自检(交付前必跑)
$PY scripts/build_report.py 诊断数据.json -o out/诊断报告.html --verify # 文本级
$PY scripts/selfcheck_html.py out/诊断报告.html # 真实交互
$VENV scripts/make_docx.py 诊断数据.json -o out/诊断报告.docx --verify # 回读点数
★ v1.4.0:自检分两层,门控只管慢的那层
| 层 | 检什么 | 耗时 | 是否可门控 |
|---|---|---|---|
| 文本级 | 本次产物(占位符、编码泄漏、段数、行话) | 毫秒级 | 否,每轮必跑 |
| 浏览器级 | 生成逻辑(交互、渲染、JS 报错) | 秒级~数十秒 | 是,按指纹 |
pipeline.py 已自动执行这套分层(⑤a 门控判定 → ⑤b 分层自检 → ⑤c 登记指纹)。
手工跑时也可直接利用:
$PY scripts/selfcheck_html.py out/诊断报告.html --no-browser # 只跑文本级(毫秒)
$PY scripts/selfcheck_html.py out/诊断报告.html --json r.json # 落结构化结果
$PY scripts/selfcheck_gate.py --data 诊断数据.json --info # 看门控为何跑/为何跳
> ⚠ 改了渲染脚本(build_report.py)后,门控会自动失效并在下一轮真跑一次浏览器级 ——
> 这是设计如此,不要用 --no-gate 把它绕过去,那会把「脚本改坏了」的窗口一直敞着。
自检覆盖:题数/得分/满分一致性、错题数与归因条数一致、待确认数一致、
四段齐全且总字数 ≤ 1800、正文无编码泄漏、无未替换占位符、
无 >None</>undefined</nan、交付件不含 SELFCHECK 残留、
打印按钮真实 click 后 window.print 被调用、no-js-error 兜底。
两条口径断言必须显式写(这是 v1.1.0 的验收线):
# ① 字数上限
assert total_chars <= 1800, "四段正文超 1800 字"
# ② 正文无编码:四段正文里不得出现编码形态
import re
CODE = re.compile(r"\b[A-Z]{2,3}-[A-Z0-9]{1,3}(-[A-Z0-9]{1,4}){1,3}\b")
for seg in ("overall", "strength", "improve", "advice"):
assert not CODE.search(text_of(seg)), "正文出现内部编码"
# ③ 编码只能出现在括号内
for m in CODE.finditer(all_text):
assert all_text[max(0, m.start()-1)] == "(", "编码未包在括号小字里"
走「一页一容器」的中间母版必须再加两条裁切断言(唯一能查出静默裁切的手段):
var clipped=[], over=[];
document.querySelectorAll('.page').forEach(function(p,i){
if (p.scrollHeight > p.clientHeight + 2)
clipped.push('p'+(i+1)+'('+p.scrollHeight+'>'+p.clientHeight+')');
var pf = p.querySelector('.pf');
var lim = pf ? pf.getBoundingClientRect().top : p.getBoundingClientRect().bottom;
[].slice.call(p.children).forEach(function(c){
if (c.classList.contains('pf')) return;
var b = c.getBoundingClientRect().bottom;
if (b > lim + 1) over.push('p'+(i+1)+'.'+c.className+'+'+Math.round(b-lim));
});
});
别采信脚本的 success,要回读:脚本 exit 0 不等于内容完整。
至少点清业务对象个数(题数、四段字数、待提升项条数)与 JSON 对齐。
终检(必做):从 落盘的 DOCX 里用 python-docx 读回,逐项比对关键数字
(总分/满分/得分率/失分题数)与四段文本是否与数据一致,并确认四段都没有出现编码。
七、踩坑速查
| 坑 | 症状 | 解法 |
|---|---|---|
变量名 qn |
UnboundLocalError: cannot access local variable 'qn' |
改名 q_no 等 |
| 手写数字常量 | 文案「3 个主责节点」实算 7 个 | 全部 len() 算出后注入 |
| 正文混进编码 | 家长看到 SW-G-X1-01-002 一头雾水 |
正文只写中文名;确需溯源才加括号小字,且不得进标题与结论句 |
| 报告写太长 | 八段式铺陈,家长只想知道「哪弱、怎么补」 | 固定四段式,总字数 ≤1800,超限先砍铺陈句不砍事实 |
| 行话泄漏 | 「元认知」「命题立意」「核心素养」 | 换成「为什么错」「出题人想考什么」「这块能力」 |
| 自指陷阱 | 扫「undefined」永远失败 | 断言范围限定内容容器,或先剥 <script> |
| 只点元素数的自检 | 「计数正常但页面全空」查不出 | 加 no-js-error + 真实 click 事件模拟 |
前置链取 [-3:] |
把最远的「抽样调查」当成方差失分的根因 | 取最近三层再反转 |
| 满分不符误报 | 有题未计分时被报错 | 区分「合计 < 声明(有未计分题,正常)」与「合计 > 声明(真错误)」 |
| 图谱文件口径混淆 | 同一知识点两套码 | 用与教材版本匹配的那份;跨项目引用先打印对方字段 |
| 空白区渲染成光标题 | 无失分题时只剩一行标题,读者以为生成失败 | 每一段在无内容时返回自解释文案,说明「为何空 + 补什么就能有」 |
| 空表无兜底行 | <tbody></tbody> 触发自检 table-rows-nonzero 失败 |
空表必须塞一行 colspan 说明;同时保留 .empty 样式 |
| 只凭答题卡判分 | 答题卡只有涂卡痕迹,无题干无答案 | 全部标 pending_answer,不得据「看起来像错的」推断;报告显式声明依据边界 |
用 kmap.hits 算「知识点正确率」→ 全表假 0%(★ v1.6.0 实拍) |
hits 只为失分题建立映射,答对题无 hit → 分母里全是失分题,每行都算出 0%、全部标「明显薄弱」。家长读成「六块内容全都没掌握」——数字能算出来 ≠ 数字有意义 |
该表只报失分分布(题号/题量/常见错法),不报正确率。真正确率需全卷逐题映射,属上游能力边界,如实说明 |
len(t.rows) 当数据行数 |
表头占 1 行,24 个能力项被数成 25 行,断言与肉眼同时误判 | 数据行一律 len(t.rows) - 1 |
| 两个技能副本反向分叉 | ~/.workbuddy/skills/X/ 与 X__skillhub/ 各自更新了不同文件,整目录覆盖必丢修复 |
逐文件 diff 取新:本次 kcode_lookup.py 取上架版(含 unit_scope 前缀补全),build_report.py/make_docx.py 取开发版(含 blank 计入失分) |
上架版漏了 blank 修复 |
线上 v1.0.0 的 ERR_STATUS=("wrong","partial"),孩子整题空着不写这件事从报告里彻底消失 |
两处 ERR_STATUS(build_report.py / make_docx.py)必须同步含 "blank",改完 grep 双验 |
| 单选题答案与卷面不符但痕迹有歧义(v1.4.1 实测) | 学生圈注的选项与参考答案不一致,但涂卡区/题号圈注模糊、可能只是草稿;据此判错就是臆造 | 不判错、不计入失分,改在「依据边界」的 missing_data 里如实说明「因痕迹歧义未据以判定」,并主动索要答题卡清晰照片。宁可少判一题,不可编一题 |
| 待确认题仍占 K 码分母(v1.4.1 实测,自相矛盾) | 把 unreadable 的满分算进该 K 码分母,于是 WL-J-B4-02-001 显示「得分率 50%」而失分清单为空 —— 百分比凭空掉了一半,家长会以为孩子这块很差 |
口径:unreadable/pending_answer 分子分母都不计,只登记 todo_qs。聚合后断言:rate < 90 的项必须有非空 loss_qs,否则口径错了 |
| 指望 DOCX 里有表格(v1.4.1 实测) | 用户要求「统计结果用表格呈现」,但 make_docx.py 按契约只出四段式叙述文档,回读表格数恒为 0,交付后才被发现 |
DOCX 就是纯叙述件。要表格必须另出 Excel + 打印版 HTML 摘要(make_tables.py 模式:6 张表 = 总体/K码掌握度/逐题/归因/待确认/优先级)。开工先问清「要 Word 还是也要表格」 |
溯源编号用 · 连接 |
写成「能力项名·WL-J-B3-05-008」,自检的「编码必须在括号内」断言判 FAIL | 统一写「能力项名(编号 WL-J-B3-05-008)」。不要嵌套括号(会变成「…应用(编号 …)」重复),改用破折号引出名:「……还差一步——欧姆定律在串联电路中的应用(编号 …)」 |
| 打印版 HTML 自检的三项硬形态(v1.4.1 实测) | 自写摘要页在 print-css / kcode-mono / print-button-bound 三项判 FAIL,反复试才对齐 |
① @media print 里必须同时含 page-break(写 .page{page-break-after:always});② K 码等宽字体 CSS 必须字面为 .kd{font-family:Consolas(点+类名+d+紧跟花括号,写 .kcode 或加空格都判 FAIL);③ 打印绑定必须写成 document.getElementById('btnPrint').addEventListener( 连续形态,包一层 (function(){var b=…})() 会 FAIL |
| 关系表键名中英文混用 → 静默丢边 | kcode_lookup.py 曾只认含「前置」二字的关系类型,而 kcode_en_primary 的关系表键是纯英文 prereq/parallel/extend → 556 条前置边全丢,前置链/外溢预警/公共根因三节内容集体消失且 exit 0 不报错 |
脚本已加 REL_TYPE_ALIAS+norm_rel_type()。新增图谱接入后必须断言 _pre/_next 非空,静默丢边比报错危险得多 |
unit_scope 前缀与节点键不同形 |
节点键 YY-X-B1-03,传 B1-03 时 startswith 恒假 → unit_total=0、覆盖率失真且不报错 |
脚本已支持自动补全前缀;接入新图谱后先确认 unit_total > 0 |
| 用集合成员判断替代子串匹配 → 归因静默漂移(v1.2.0 实测) | 预计算索引时把 hit=[x for x in segs if x in name](子串)写成 x in set(分词结果)(成员)→ 长考点名得分从 172 变 194,同分节点排序错位,考点被对齐到错误的能力项上 |
search_idx() 必须复刻 search() 的子串语义。改完索引务必跑 --selftest:它会逐例比对索引与直算结果,本次正是它抓出了这处静默漂移 |
| 首跑永远不命中缓存 | 算指纹时 kmap.graph_path 尚未回填 → 图谱层指纹取到 "none",回填后第二次跑才变真哈希 |
算指纹前先调 resolve_graph_path() 显式解出路径,使首跑与后续跑指纹一致 |
| 指纹层划分错误 → 该失效的没失效 | 把四段正文混进"事实层"→ 改一句措辞就触发全链重算(白跑);或把 status 漏出事实层 → 改了判定却复用旧报告(最危险) |
事实层只收「卷面看得见的事实」,status 必须逐题入指纹;措辞单独成层。改完用 report_cache.py 打印三层指纹验证 |
| 缓存把校验也跳过了 | 命中缓存后不跑终检 → 交付件早被截断也照样"通过" | 缓存只跳过生成;存在性 + 大小断言 + 业务数量点清照跑。pipeline.py 的 §⑦ 无条件执行 |
| 派生数字在定义之前算 | 生成器里用占位 hack 引用后面才定义的列表 → 静默出错 | 派生量一律在被引用对象定义之后算,字段先留 None 再回填 |
overflow:hidden 静默裁切(最隐蔽) |
固定高容器 + overflow:hidden,超出部分被裁掉不留痕迹;测高脚本报「无溢出」因为盒子恒定高度 |
加 §六 的两条裁切断言;测高脚本报 OK ≠ 没裁切 |
| 主表副表同页 | 优先级表 6 行 + 外溢表 15 行同容器 → 溢出 983px | 副表独立成页;表内多项用行内 ; 而非 <br> |
| 自检辅助函数取值写错 → 批量假 FAIL | function Q(s){return ...length} 却按 Q(s).length 用 → 12 项断言集体 undefined 全判 FAIL |
凡「批量 FAIL 且数值显示 undefined」,先怀疑自检脚本自身 |
document.title 分隔符 |
用 \|\| 作分隔符,回读时被「含空格的项」拆断 |
改用 ~~ 等不会出现在内容里的分隔符 |
| 临时目录与交付产物混淆 | 自检验证完的临时 zip 与真交付件分不清 | 交付件固定放工作根,临时件用完即删 |
| 核心版缺段 → 违反四段式硬契约(v1.3.0 实测) | 分层交付初版让核心版只发「总体评价 / 待提升项 / 建议」3 段,把「优势」留给完整版 → 家长拿到手的第一版不是四段式,报告契约当场作废 | 核心版必须四段齐全;优势段暂无内容时返回如实说明的占位文案并置 _strength_placeholder=True。check_layers() 内置「核心版四段齐全」断言 |
| 并发结果顺序漂移 | 多线程回填后题号乱序 → 报告里题目位置随机跳,两次跑结果不一致 | 合并前按 _sort_key() 确定性排序(题号数值优先),不依赖完成顺序。跑两遍比对结果应逐字一致 |
| 失败题混进结果 | 某题并发时抛异常,被当作「未作答」静默计入失分 | 失败与空白是两回事:失败题不进对错统计、不编造判分,显式标记并隔离;自检断言失败数 == 题号 × 参与阶段数 |
| 流式先给估计值再纠正(最伤信任) | 为抢首屏先推「约 60 分」,算完实际 58 分再改口 | 流式只推已经算出的确定性数字。宁可等 1 秒,不可先错后改 —— 先给结论再改比晚给伤害大 |
stream_report 取不存在的派生字段 |
读 derive() 没有的 n_blank(实际只有 blank_qs 列表)→ 流式里显示 None |
派生字段名以其定义为准;取值前先确认键存在,或只从已知键列表里取 |
| 并发参数被当成纯加速开关 | 以为 --workers 越大越好,反而因锁竞争/上下文切换变慢 |
题量少(<8)或阶段本来就快时不要开并发;先跑 stage_timer.py 确认瓶颈真在模型侧 |
| 中文引号里的语法错误 | print("四段不是"一起跑"。") → SyntaxError |
中文语句里的强调引号用「」;英文双引号只做字符串定界 |
| 门控采信「存下来的指纹字符串」(v1.4.0 实测) | 状态文件里存了算好的 fingerprint,decide() 直接比这个字符串 → 分项被改动(脚本更新/浏览器升级/手工编辑)时字符串不会跟着变,该跑的被静默跳过 |
指纹必须由存下来的分项重新合成(_stored_fingerprint()),任何一个分项变化都必然导致指纹不同。本次是靠自检第 6/7/10 项 FAIL 抓出来的 |
| 门控把文本级自检也跳了 | 为省时间把整层自检都门控 → 本次产物的占位符/编码泄漏/四段缺段全漏检 | 门控只作用于无头浏览器那一层;文本级检的是本次产物且只要毫秒,必须每轮照跑 |
| 跳过视为免检,长期不抽验 | 门控命中就一直不跑浏览器级 → 「脚本没变但行为变了」(如浏览器自动更新、字体变化)长期不被发现 | 记 since_smoke 计数,每 SMOKE_EVERY(默认 10)轮主动真跑一次;跳过一轮则计数 +1 |
| 失败也登记门控指纹 | 自检 HASFAIL 仍写了状态 → 下次直接跳过,缺陷固化 | 只有 ALLPASS 才登记。失败/跳过一律不写状态,且下次必跑 |
| 归因阶段被降档 | 为省钱把归因也交给快模型 → 归因质量崩塌,等于换了个技能 | NEVER_DOWNGRADE = {"attribute"},check() 断言归因阶段不得出现任何非强档 |
| 识卷一刀切当快档 | 认为识卷只是「把字认出来」,主观题也走快档 → 手写推导看错,后面全盘皆错 | 分档不能只看阶段:主观题在识卷/判分阶段要反向升级到强档,判据用题面 type 而非模型输出 |
| 分档改变了产出内容 | 快档模型给出与强档不同的判分 → 同一份数据两次跑结果不同 | 分档只改「用哪个模型」;判分口径与四段式契约不变。跑两遍(--tier 与不加)比对判分结果应一致 |
八、环境
| 用途 | 需求 |
|---|---|
| Python | 3.10+(build_report / selfcheck / kcode_lookup 只需标准库) |
| Python 额外依赖 | python-docx(出 DOCX 必需);缺库时降级为 [SKIP] 并指路,不算 NG |
| 无头浏览器(可选) | Edge / Chrome(msedge.exe、chrome.exe);仅中间母版做真实交互自检时需要 |
| 图谱根目录 | 环境变量 KCODE_ROOT 指向存放 kcode_* 文件夹的目录;未设置时从 cwd 及其祖先自动探测 |
> ⚠ 本技能不得写死任何开发者本机绝对路径(如 D:/...、C:/Users/...)。
> 图谱定位走 KCODE_ROOT,脚本自身路径走 __file__,解释器走 sys.executable,
> 用户数据走 cwd 或命令行参数。
> 否则他人安装后会在第一步就全线报「图谱文件不存在」——这是发布审核的常见退回原因。
九、可移植性与发布(★ 若要分享/上架,先读这节)
铁律:技能包内不得出现任何开发者本机的绝对路径(包括 SKILL.md 与 references 正文)。
| 类型 | 正确写法 | 错误写法 |
|---|---|---|
| 图谱数据目录 | 环境变量 KCODE_ROOT,缺失时从 cwd 及祖先自动探测 |
ROOT = "D:/Work Buddy/教育培训" |
| 脚本自身位置 | os.path.dirname(os.path.abspath(__file__)) |
写死安装路径 |
| 解释器 / 外部工具 | sys.executable;外部工具用环境变量 + 候选路径探测,找不到就 [SKIP] 并指路 |
写死 C:\Users\...\python.exe |
| 用户数据 | cwd 或命令行参数 | 写死开发者的工作目录 |
发布到 SkillHub(WorkBuddy 的技能入口检索的就是它):
1. 到 https://skillhub.cn 完成实名认证(人脸核身,不做无法发布)。
2. SKILL.md 的 YAML frontmatter 必填字段以平台报错为准(官方示例文档字段名与实际校验不一致):
| 字段 | 作用 | 长度上限 | 备注 |
|---|---|---|---|
display_name |
中文展示名 | 暂未见限制 | 必填 |
display_name_en |
英文展示名 | ≤ 60 字符 | 必填;实测 65 字符被退回 |
description_zh |
中文描述 | 暂未见限制 | 必填 |
description_en |
英文描述 | ≤ 1000 字符 | 必填;实测 1196 字符被退回 |
slug |
唯一标识 | 2–128 | kebab-case |
version |
版本号 | — | 合法 SemVer,如 1.1.0 |
description |
本机技能发现用 | — | ⚠ 不能删:本机加载器只认这个键,删了就检索不到 |
summary license tags homepage |
建议项 | — | 不阻断发布 |
⚠ 三个描述字段必须并存:description(本机)+ description_zh + description_en(平台)。
(本技能 description 与 description_zh 内容相同,一份文案两处复用。)
⚠ 长度用脚本量,不要靠眼估:写进 YAML 之前先实测字符数。
中英混排时肉眼对字符数的判断极不可靠——display_name_en 写 65、description_en 写 1196
都是这么来的。压到上限的 90% 留余量,便于后续微调措辞。
- ⚠ 所有描述性字段统一用单引号包裹(内部单引号写成
'')。
长英文里出现「英文冒号 + 撇号」(如analysis: an examiner's view)会让 YAML 报
mapping values are not allowed here,加引号一次解决。改完必须yaml.safe_load回验。 - 打包为 zip(顶层放一个同名技能文件夹,包名
slug@version.zip),剔除__pycache__/*.pyc,
SKILL.md 换行统一 LF,确认目录内没有嵌套同名副本。 - 提交后走「内容合规 + 漏洞扫描 + AI 模型安全评估」三线并行审核,1–3 个工作日;
「提交」≠「上架」,通过后还要在技能列表点上架。
更新已上架的技能:改本地技能目录 → 重打 zip → 用同一 slug、递增 version 重新提交
(平台按 slug 识别为「更新」而非新建),无需先下架。版本号规则:删功能/改契约 → 次版本号 +1。
完整操作手册见工作根目录 技能发布指南-SkillHub.md。
十、脚本清单与参考文件
脚本(scripts/)
| 脚本 | 职责 | 何时用 |
|---|---|---|
kcode_lookup.py |
能力项对齐 / 前置溯源 / 校验;内置图谱编译缓存 | 每次:--in 出 kmap;改索引后跑 --selftest |
build_report.py |
渲染中间母版与家长版四段式 HTML | 每次 |
make_docx.py |
从 JSON 直接重建 DOCX(唯一交付件) | 每次 |
selfcheck_html.py |
交付件自检:文本级必跑 + 浏览器级(--no-browser 可只跑文本级) |
每次;浏览器级受门控 |
pipeline.py |
总编排:指纹 → 对齐 → 母版 → 家长版 → 自检 → DOCX → 终检 | 默认入口 |
stage_timer.py |
耗时剖面:哪一段花了多少秒 | 觉得慢时先跑它,别凭感觉优化 |
report_cache.py |
三级指纹缓存 + --info 查看状态 |
想确认「为什么这次没省下来」时 |
slice_for_llm.py |
数据按需裁剪 + 口径自检 | 准备让模型写四段正文之前 |
parallel_units.py |
题目级并行调度:拆单元 / 确定性合并 / 失败隔离 | 题量 ≥8 且逐题阶段慢时;--workers 生效 |
stream_report.py |
四段并发 + 流式 + 分层交付;含 --layers 契约自检 |
报告已算完核心数字、要尽早出结论时 |
model_tier.py |
分档模型路由:快档 / 强档指派 + 契约自检 | 想省成本时;--plan 出分派表 |
selfcheck_gate.py |
自检门控:按渲染脚本指纹决定是否重跑无头浏览器 | 默认启用;改过渲染脚本会自动失效 |
参考文件(references/)
| 文件 | 何时读 |
|---|---|
references/report-schema.md |
动手前必读:数据契约、四段式字段与派生量铁律 |
references/attribution-rules.md |
做 ④ 归因时读:四类判据、认知分析框架、分学段口径 |
references/kcode-graph-index.md |
做知识点定位时读:全库索引、三种存储形态、关系归一 |
references/delivery-spec.md |
做交付时读:DOCX 必设项、版式套、字数校验 |
assets/sample-junior-physics.json |
需要看完整样例数据时(含 4 类归因与四段式成稿) |
> ⚠ 诊断报告生成的耗时瓶颈不在本地脚本(实测 kmap 对齐 40 题仅 0.1–0.2 秒)。
> 真正的大头是模型侧的识卷 / 归因 / 成稿与无头浏览器自检。
> 优化前务必先跑 stage_timer.py 拿到剖面 —— 见 §四之二。
>
> 四轮优化分别治的是不同的病(不要混为一谈):
> | 轮次 | 手段 | 治的是 |
> |---|---|---|
> | v1.2.0 | 缓存与预计算 | 「同一份东西重复算」 |
> | v1.3.0 | 题目级并发 / 四段分批 / 流式 / 分层交付 | 「串行等着算」 |
> | v1.4.0 | 分档模型 | 「便宜的活花了贵价」 |
> | v1.4.0 | 自检门控 | 「没必要每轮做的检查」 |
十一、典型用法
用户:「这是孩子的物理卷子照片,帮我批改并分析一下。」
→ 核实教材版本(成都初中物理=教科版)→ 逐题识卷 → 判分 → 双视角解析 → 知识点定位(脚本)
→ 四类归因 + 上游溯源 → 补缺优先级 → 四段式成稿 → 出 DOCX。
用户:「这几张作业拍糊了,能批吗?」
→ 能。模糊题标「待确认」,只对清晰题判定,报告末尾单列补件提示。
不要因为部分糊就拒单,也不要为了凑数去猜。
用户:「不需要那么细,只想知道错在哪、先补什么。」
→ 正是本技能的默认形态(四段式本来就是「错在哪 + 先补什么」)。
把「待提升项」压到 2 条、建议压到 2 条即可,不改结构。
用户:「把上次那份报告改成给学生自己看的版本。」
→ 只改称呼(「孩子」→「你」)与语气,四段结构与字数上限不变。
用户:(只上传答题卡照片)「批改一下。」
→ 这是高频且最容易做错的一类。答题卡上只有涂卡痕迹与作答笔迹,
没有题干、没有参考答案、没有分值分布 —— 因此:
1. 逐题照实识读作答(涂卡选项、填空文字),一个字都不要猜;
2. 全部标 pending_answer(涂改不可辨的标 unreadable),绝不据「像错的」推断对错;
3. 知识点定位改走 points[] 通道:把卷面可见的作答内容映射到能力项,标 role=主;
这只表示「本题涉及该能力项」,不表示失分;
4. 报告仍出四段式,但「优势」「待提升项」走空白兜底文案(如实说明 + 补什么就能有);
5. 交付后必须主动向用户索要:原卷、参考答案/评分细则、缺失页。
同时说明「补齐后重跑本流程即可自动填充」——让用户知道这不是终点。
> ⚠️ 切勿因为「报告不好看」而去编造判分。一份诚实的空白报告 + 明确的补件清单,
> 远比一份编出来的「诊断」有价值。这也是本技能最核心的红线。