diff --git a/AGENTS.md b/AGENTS.md index e66e942..5d2f5ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,9 @@ service identity、workspace、keyring 与 Feishu/provider connection;进程必须由 `HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS 控制面与 Docker adapter 后置(见 ADR-0025)。 +- Agent skill 只来自 Hub release 内审核过的显式 allowlist,以 release-owned 只读 local + plugin 加载;`settingSources: []` 继续禁用项目/用户配置加载。不得把任意 workspace + `.claude` 配置或未审核 skill 变成运行时能力(见 ADR-0018)。 ## 纪律 diff --git a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md index bab84de..82a145b 100644 --- a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md +++ b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md @@ -122,6 +122,14 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox - `settingSources: []` and strict MCP configuration prevent an untrusted workspace or service-user config from widening tools, hooks, MCP servers, or sandbox paths. +- Platform-curated Agent skills are immutable Hub release assets, loaded as a + programmatic local plugin from a release-owned path. The sandbox exposes that + path read-only, and the SDK receives only plugin-qualified allowlist names + through its `skills` option. Filesystem setting sources remain disabled, so a + project cannot register another skill or widen its tools through `.claude` + settings. Requested skill ids are recorded on `run.created`; SDK + initialization/results remain the authoritative evidence that loading + actually succeeded. - Network: open (see Open Questions). `bypassPermissions` is kept (headless server — no interactive prompts); the diff --git a/hub/curated-skills-plugin/.claude-plugin/plugin.json b/hub/curated-skills-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..6201838 --- /dev/null +++ b/hub/curated-skills-plugin/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "cph-curated", + "description": "Reviewed curriculum-production skills shipped with the Curriculum Project Hub.", + "version": "0.0.1" +} diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/SKILL.md b/hub/curated-skills-plugin/skills/data-processing-spec/SKILL.md new file mode 100644 index 0000000..8f13c24 --- /dev/null +++ b/hub/curated-skills-plugin/skills/data-processing-spec/SKILL.md @@ -0,0 +1,72 @@ +--- +name: data-processing-spec +description: 物理竞赛实验「数据处理」的两套作答规范——超严格版与考试版。当用户要出实验数据处理题、或要求题目答案/解析"按考试版写""按超严格版写""按严格规范作答",或问"什么是考试版/严格版""不确定度取几位""不确定度怎么修约""连算代入哪个值""拟合要不要算 B 类"等数据处理口径问题时使用。出题与批改时据此确定唯一口径。 +--- + +# 数据处理作答规范(超严格版 / 考试版) + +物理竞赛实验数据处理里,有效数字取位、不确定度修约、连算代入、拟合是否计 B 类等环节 +**各家做法不一致**。为避免"同一份数据出现多个都对的答案",本课程把这些争议点各拍板成 +两套自洽的口径: + +| 版本 | 用途 | 一句话特征 | +|------|------|-----------| +| **超严格版** | 严格训练 | 每一步贴近误差理论最规范做法,接受较繁的计算量 | +| **考试版** | 考试 / 日常训练 | 在规范前提下简化计算,贴近竞赛复赛阅卷习惯 | + +## 怎么用这个 skill + +1. **先确定版本。** 用户出题或批改时通常会说明"按考试版"还是"按超严格版"。 + - 用户明确指定 → 用该版。 + - 用户没指定 → **必须先问**用户要哪一版,不要自己默认。两版在四处刻意不同, + 选错会给出末位不同的答案。 +2. **读对应规范全文,再动手。** 选定版本后,完整阅读对应文件,按其中每一条口径生成 + 题目答案 / 解析 / 评分点: + - 超严格版 → [strict-spec.md](strict-spec.md) + - 考试版 → [exam-spec.md](exam-spec.md) +3. **全程只认一版。** 一道题(含所有小问)自始至终用同一版口径,不得中途混用。 +4. **需要解释"为什么有两版""某争议点各方怎么做"时** → 读 [disputes.md](disputes.md) + (中立罗列各方做法与依据,不拍板)。 + +## 两版差异一览(仅这四处不同) + +下面四项是两版**唯一的区别**;其余约定两版完全一致(见下一节)。出题/批改时重点核对这四项。 + +| 争议环节 | 超严格版 | 考试版 | +|----------|----------|--------| +| **不确定度取几位有效数字** | 首位为 1/2/3 取 2 位,其余取 1 位(A2) | 一律取 1 位(A1) | +| **不确定度的修约方向** | 只进不舍(偏保守,代表:北大) | 四舍六入五凑偶(代表:中科大、第 42 届复赛) | +| **多小问连算代入哪个值** | 代入前一问**未修约的真实值**,仅终值修约 | 代入前一问**已修约的填空值**,接受逐问舍入 | +| **线性拟合不确定度** | A 类 + B 类合成(需算 `u_Bk = u_By / √Σ(xi−x̄)²`) | 只算 A 类(`u_k = σ_k`) | + +> 测量值(中心值)的修约:**两版都用四舍六入五凑偶**——这一条不是差异项。 + +## 两版共同约定(不随版本变化) + +- **A 类不确定度**:取平均值的实验标准差 `u_A = √[Σ(xi−x̄)² / (n(n−1))]`,**不做 t 因子修正**。 +- **B 类不确定度**:`u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。合成 `u = √(u_A² + u_B²)`。 +- **单次测量**:不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度 + (取 `u = Δ仪 / √3`)。出处:实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组 + 实际指导书为准。 +- **有效数字总原则**:测量值位数必须与不确定度对齐——不确定度精确到哪一位,测量值就写到哪一位。 +- **线性拟合 A 类**:斜率相对不确定度 `σ_k / k = √[ (1/(n−2)) · (1/γ² − 1) ]`(γ 为相关系数)。 + +## 出题/批改自检清单 + +确定版本后,逐项对照所选规范,确保答案在这些点上口径一致: + +- [ ] A 类是否用了"不做 t 修正"的标准差公式 +- [ ] B 类是否 `Δ仪/√3`;单次测量是否用仪器误差限 +- [ ] 不确定度取了几位(A2 还是 A1)—— **按版本** +- [ ] 不确定度末位修约方向(只进不舍 / 四舍六入五凑偶)—— **按版本** +- [ ] 测量值是否与不确定度对齐、是否用四舍六入五凑偶 +- [ ] 多小问连算代入的是真实值还是修约值 —— **按版本** +- [ ] 线性拟合是否计 B 类 —— **按版本** +- [ ] 全卷是否始终只用了这一版口径 + +## 配套 PDF 源码 + +`scripts/` 下保留了两版规范与争议点讨论的 Typst 源码,仅作为内容参考。当前 Educraft Agent +运行时不提供独立 `typst` 命令,不要尝试直接编译这些脚本,也不要安装运行时依赖。用户需要 +成品 PDF 时,明确说明当前能力边界;若内容要进入课程工程,应按 `lesson-project` 的 cph +0.0.2 结构落地并使用 `cph check/build`。 diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/disputes.md b/hub/curated-skills-plugin/skills/data-processing-spec/disputes.md new file mode 100644 index 0000000..f6b9b1f --- /dev/null +++ b/hub/curated-skills-plugin/skills/data-processing-spec/disputes.md @@ -0,0 +1,51 @@ +# 数据处理争议点(中立罗列,不拍板) + +本文件解释"为什么会有超严格版 / 考试版两套口径"——每个环节各家做法不一致,本课程把它们各 +拍板成两版。这里**只中立罗列各方做法与依据**,不评对错。需要给学生/教练讲清来龙去脉时引用。 + +## 共同约定(无争议前提) +- A 类不确定度:实验标准差,**不做 t 因子修正**。 +- B 类不确定度:`u_B = Δ仪 / √3`(均匀分布)。 +- 测量值修约:四舍六入五凑偶。 +- 有效数字总原则:测量值位数跟着不确定度走(对齐)。 +- 单次测量:以仪器误差限估算,不假设 A 类无穷大(出处:实验指导书"杨氏模量"部分)。 + +## 争议点 A:不确定度取几位有效数字 +- **A1(考试版采用)**:一律 1 位。如 `0.034→0.03`、`0.12→0.1`。 +- **A2(超严格版采用)**:首位为 1/2/3 时取 2 位,其余取 1 位。如 `0.123→0.12`、`0.67→0.7`。 +- 分歧本质:修约不确定度本身引入的相对误差能容忍多大;A2 为压低该相对误差而保留 2 位。 + +## 争议点 B:有效数字"反向多取一位"变体 +- 设 `u=0.03`,再看测量值对齐位数字:≥3(如 1.87)正常对齐写 `(1.87±0.03)`;以 1/2/3 等更小 + 数起头(如 1.81)则允许测量值再多取一位、不确定度也反向多取一位 → `(1.812±0.034)`。 +- 与 A1/A2 不完全等价,是 A 的一个更细变体。本课程两版都未采用此变体(统一走 A1 或 A2), + 列出仅供识别学生可能用到的写法。 + +## 争议点 C:不确定度本身如何修约 +- **只进不舍(超严格版采用)**:末位一律进位,报告值偏保守。代表:北京大学。 +- **四舍六入五凑偶(考试版采用)**:与测量值同一规则。代表:中国科学技术大学、第 42 届复赛。 +- 提示:第 42 届全国中学生物理竞赛复赛对不确定度采用四舍六入五凑偶。 + +## 争议点 D:多小问连算代入哪个值 +- **代入未修约真实值(超严格版采用)**:用完整精度中间量,仅终值修约;避免舍入误差传播, + 误差理论上更规范。 +- **代入已修约填空值(考试版采用)**:用前一问写出来的修约值;便于逐问复算、阅卷可追溯。 +- 两者数值通常只差最后一两位,边界情形可能影响终值末位。 + +## 争议点 E:线性拟合是否计入 B 类 +- A 类无争议:`σ_k/k = √[ (1/(n−2))·(1/γ²−1) ]`。 +- **只算 A 类(考试版采用)**:直接 `u_k=σ_k`;相当多题目/教材实际只算 A 类,且常不说明理由。 +- **A 类 + B 类合成(超严格版采用)**:把斜率写成 `k=Σci·yi`,`ci=(xi−x̄)/Σ(xj−x̄)²`, + 得 `u_Bk = u_By / √(Σ(xi−x̄)²)`,再 `u_k=√(σ_k²+u_Bk²)`。 +- 为何常省略 B 类:点多、Σ(xi−x̄)² 大时 u_Bk 往往远小于 σ_k 被淹没——但这只是近似经验,非普遍成立。 + +## 速查对照 + +| 编号 | 争议内容 | 超严格版 | 考试版 | +|------|----------|----------|--------| +| A | 不确定度取几位 | 首位 1/2/3 取 2 位(A2) | 一律 1 位(A1) | +| C | 不确定度修约方向 | 只进不舍 | 四舍六入五凑偶 | +| D | 连算代入值 | 未修约真实值 | 已修约填空值 | +| E | 拟合是否计 B 类 | A 类 + B 类合成 | 只算 A 类 | + +> B 项(反向多取一位变体)两版均不采用,故不在版本差异表内。 diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/exam-spec.md b/hub/curated-skills-plugin/skills/data-processing-spec/exam-spec.md new file mode 100644 index 0000000..1f6d7f1 --- /dev/null +++ b/hub/curated-skills-plugin/skills/data-processing-spec/exam-spec.md @@ -0,0 +1,73 @@ +# 数据处理规范 · 考试版 + +> 用于**考试与日常训练**。在保证规范性的前提下**简化计算**(不确定度一律 1 位、拟合只算 A 类、 +> 逐问代入修约值),贴近竞赛复赛阅卷习惯。评分以本规范为唯一口径。与超严格版在四处刻意不同 +> (有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)——同一份数据两版可能给出 +> 末位不同的答案,**全程只认本版,不可混用**。 + +## 共同约定(两版一致) + +### A 类不确定度 +多次测量,取平均值的实验标准差: + +``` +u_A = √[ Σ(xi − x̄)² / (n(n−1)) ] +``` + +- **不做 t 因子修正**,直接以上式为 u_A。 + +### B 类不确定度 +- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。 +- 合成:`u = √(u_A² + u_B²)`。 + +### 单次测量 +- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`。 +- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。 + +## 有效数字与修约(本版选定口径) + +### 有效数字总原则 +- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。 + +### 不确定度取几位有效数字 —— 统一 1 位 +- **不确定度一律保留 1 位有效数字**(无论首位是几)。测量值随之对齐到该位。 +- 示例:`u=0.123 → 0.1`,测量值 `1.8127 → 1.8`,记为 `(1.8 ± 0.1)`;`u=0.067 → 0.07`,对齐到该位。 + +### 测量值的修约 —— 四舍六入五凑偶 +- 测量值采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。 + +### 不确定度的修约 —— 四舍六入五凑偶 +- 不确定度也采用"四舍六入五凑偶",与测量值同一规则。 +- 提示:第 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近 + 近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。 + +## 多小问连算 —— 代入上一问修约后的结果 +- 后一问用到前一问结果时,**代入前一问已修约、写进答题处的那个值**进行计算。 + 即接受每问修约带来的舍入误差,换取逐问可复算、便于阅卷。 +- 示例:杨氏模量第 1 问报告 `d = 1.8 mm`;第 2 问算 E 时**直接代入 1.8 mm**(而非未修约的 1.8127…)。 + +## 线性拟合 —— 只算 A 类 +设 `y = k x + b`。 + +- **斜率只计 A 类不确定度,不计 B 类。** 斜率相对不确定度由相关系数 γ 给出: + +``` +σ_k / k = √[ (1/(n−2)) · (1/γ² − 1) ] +``` + +- 即 `u_k = σ_k`,直接作为斜率不确定度上报。 +- 说明:数据点多、Σ(xi−x̄)² 较大时拟合的 B 类分量通常远小于 A 类而可忽略,本版据此**只算 A 类** + 以简化计算;如需完整合成请改用超严格版。 + +## 速查(考试版口径) + +| 项目 | 本版做法 | +|------|----------| +| A 类不确定度 | 实验标准差,不做 t 修正 | +| B 类不确定度 | Δ仪 / √3 | +| 单次测量 | 以仪器误差限估算 | +| 有效数字 | 不确定度一律 1 位 | +| 测量值修约 | 四舍六入五凑偶 | +| 不确定度修约 | 四舍六入五凑偶 | +| 连算代入 | 代入上一问修约后的结果 | +| 线性拟合 | 只算 A 类 | diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/conf.typ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/conf.typ new file mode 100644 index 0000000..1ff688e --- /dev/null +++ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/conf.typ @@ -0,0 +1,83 @@ +// 共享样式与语义框:两份规范(超严格版 / 考试版)共用 + +#let rule-color = rgb("#0b4f6c") +#let note-color = rgb("#6a4c00") +#let warn-color = rgb("#b3261e") + +// 规范条目框(蓝色):本规范选定的做法 +#let rule(body) = block( + width: 100%, + inset: 10pt, + radius: 4pt, + fill: rgb("#eaf2f6"), + stroke: (left: 3pt + rule-color), + body, +) + +// 批注 / 出处框(黄色) +#let sidenote(body) = block( + width: 100%, + inset: 9pt, + radius: 4pt, + fill: rgb("#fbf6e8"), + stroke: (left: 3pt + note-color), + text(size: 9.5pt, body), +) + +// 提醒框(红色) +#let warn(body) = block( + width: 100%, + inset: 9pt, + radius: 4pt, + fill: rgb("#fdeeec"), + stroke: (left: 3pt + warn-color), + text(size: 9.5pt, body), +) + +// 例子框(灰色) +#let example(body) = block( + width: 100%, + inset: 9pt, + radius: 4pt, + fill: rgb("#f3f3f3"), + stroke: (left: 3pt + rgb("#999")), + text(size: 9.5pt, body), +) + +// 全局配置 + 封面 +#let conf(title: "", subtitle: "", badge: "", badge-color: rgb("#0b4f6c"), doc) = { + set document(title: title, author: "竞赛实验教研组") + set page( + paper: "a4", + margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm), + numbering: "1 / 1", + number-align: center, + ) + set text(font: ("Noto Serif CJK SC",), size: 10.5pt, lang: "zh", region: "cn") + set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true)) + show heading: set text(font: ("Noto Sans CJK SC",)) + show heading: set block(above: 1.2em, below: 0.7em) + set heading(numbering: "1.1") + show math.equation: set text(font: "New Computer Modern Math") + + // 封面 + align(center)[ + #v(3cm) + #box( + inset: (x: 12pt, y: 6pt), + radius: 6pt, + fill: badge-color, + text(font: ("Noto Sans CJK SC",), size: 13pt, weight: "bold", fill: white, badge), + ) + #v(0.9cm) + #text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold", title) + #v(0.5cm) + #text(size: 13pt, fill: rgb("#555"), subtitle) + #v(1.4cm) + #text(size: 11pt)[竞赛实验数据处理 · 评分口径规范] + #v(0.3cm) + #text(size: 10pt, fill: rgb("#777"))[供教练出题与学生研读使用] + ] + pagebreak() + doc +} diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/disputes.typ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/disputes.typ new file mode 100644 index 0000000..47dad71 --- /dev/null +++ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/disputes.typ @@ -0,0 +1,311 @@ +// 物理竞赛中数据处理的争议点讨论 +// 定位:争议点讨论为主,只中立罗列各方做法,不给本课程拍板结论。 + +#set document(title: "物理竞赛中数据处理的争议点讨论", author: "竞赛实验教研组") + +// ---------- 字体与页面 ---------- +#set page( + paper: "a4", + margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm), + numbering: "1 / 1", + number-align: center, +) + +#set text( + font: ("Noto Serif CJK SC",), + size: 10.5pt, + lang: "zh", + region: "cn", +) +#set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true)) +#show heading: set text(font: ("Noto Sans CJK SC",)) +#show heading: set block(above: 1.2em, below: 0.7em) +#set heading(numbering: "1.1") + +// 数学字体不指定 CJK,公式用默认 New Computer Modern Math +#show math.equation: set text(font: "New Computer Modern Math") + +// ---------- 一些可复用的语义框 ---------- +#let dispute-color = rgb("#b3261e") +#let calm-color = rgb("#1b5e20") +#let note-color = rgb("#6a4c00") + +// 无争议约定框 +#let agreed(body) = block( + width: 100%, + inset: 10pt, + radius: 4pt, + fill: rgb("#eef6ee"), + stroke: (left: 3pt + calm-color), + body, +) + +// 争议点框 +#let dispute(body) = block( + width: 100%, + inset: 10pt, + radius: 4pt, + fill: rgb("#fdeeec"), + stroke: (left: 3pt + dispute-color), + body, +) + +// 批注 / 出处框 +#let sidenote(body) = block( + width: 100%, + inset: 9pt, + radius: 4pt, + fill: rgb("#fbf6e8"), + stroke: (left: 3pt + note-color), + text(size: 9.5pt, body), +) + +// 各方做法的小标签 +#let school(name) = box( + inset: (x: 5pt, y: 1.5pt), + radius: 3pt, + fill: rgb("#e8eef7"), + text(size: 9pt, weight: "bold", name), +) + +// ============================================================ +// 封面 +// ============================================================ +#align(center)[ + #v(3.2cm) + #text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold")[ + 物理竞赛中数据处理的\ + 争议点讨论 + ] + #v(0.6cm) + #text(size: 13pt, fill: rgb("#555"))[—— 不确定度、有效数字与拟合的多种规范对照 ——] + #v(1.4cm) + #text(size: 11pt)[竞赛实验数据处理 · 教研与教学参考] + #v(0.4cm) + #text(size: 10pt, fill: rgb("#777"))[供教练备课与学生研读使用] +] + +#pagebreak() + +// ============================================================ +// 阅读说明 +// ============================================================ += 这份文档怎么读 + +本文档的目的,是把竞赛实验数据处理中那些"两种甚至多种做法都在流传、却没有统一答案"的地方一次性摆清楚。它*不是*一份判定对错的评分标准,而是一份*争议点对照表*: + +- 凡是本领域已有共识、几乎不会引起争论的内容,归入 #text(fill: calm-color)[*"约定"*](绿色框),作为后续讨论的共同前提; +- 凡是各家(教材、命题、竞赛习惯)做法不一致的地方,单列为 #text(fill: dispute-color)[*"争议点"*](红色框),并尽量中立地列出每一方的做法与其依据; +- 个别需要交代来源或加以提醒的内容,用#text(fill: note-color)[*批注框*](黄色框)标出。 + +#sidenote[ + *关于"中立"。* 本文档对每个争议点*只罗列、不拍板*。哪一套规则作为本课程或某次测验的评分口径,由教练在使用时另行约定并提前告知学生——这一点务必在出题或考试前说清楚,否则同一份数据会出现多个"都对"的答案。 +] + +// ============================================================ +// 第一部分:共同约定 +// ============================================================ += 共同约定(基本无争议) + +下面几条在我们的处理体系里是稳定的前提,先固定下来,后面讨论争议时不再反复。 + +== A 类不确定度 + +多次测量下,A 类不确定度按样本标准差给出(贝塞尔公式给出的实验标准差,再除以 $sqrt(n)$ 得到平均值的标准不确定度): + +$ u_A = s(overline(x)) = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $ + +#agreed[ + *约定 1:A 类不确定度不做 $t$ 因子(学生 $t$ 分布)修正。* 即直接以上式作为 $u_A$,不再乘以与测量次数有关的 $t$ 因子或包含因子。这是本体系的固定口径。 +] + +== B 类不确定度(多次测量) + +B 类不确定度由仪器误差限 $Delta_"仪"$ 给出,按均匀分布折算: + +$ u_B = frac(Delta_"仪", sqrt(3)) $ + +#agreed[ + *约定 2:B 类不确定度 $= Delta_"仪" \/ sqrt(3)$。* 这里取 $sqrt(3)$ 对应仪器误差在 $plus.minus Delta_"仪"$ 区间内服从均匀分布的假设。 +] + +合成不确定度按方和根:$u = sqrt(u_A^2 + u_B^2)$。 + +== 测量值的修约方式 + +#agreed[ + *约定 3:测量值(中心值)一律采用"四舍六入五凑偶"修约。* 即逢四舍、逢六入,逢五时看前一位凑成偶数。注意:这一条只针对*测量值*;不确定度本身怎么修约是有争议的(见 @sec:round-u)。 +] + +#agreed[ + *约定 4:测量值的位数必须与不确定度对齐。* 不确定度精确到哪一位,测量值就写到哪一位,不多写也不少写。换言之,*有效数字跟着不确定度走*——这是整个有效数字问题的总原则。争议只在于"不确定度本身取几位"以及"末位怎么修约"。 +] + +// ============================================================ +// 第二部分:单次测量 +// ============================================================ += 单次测量的特别约定 + +多次测量时 A 类、B 类各司其职。但有时只做*单次测量*,此时不能简单地认为"没有重复测量,A 类不确定度就趋于无穷大、结果无法估计"。 + +#agreed[ + *约定 5(单次测量):单次测量时,直接用仪器误差限来估算该次测量的不确定度*,即把 $Delta_"仪" \/ sqrt(3)$(或按所采用规范直接用 $Delta_"仪"$)作为这一次测量结果的不确定度,而*不*假设 A 类不确定度为无穷大。 +] + +#sidenote[ + *出处批注。* 此约定的依据来自*实验指导书中"杨氏模量"实验*的相应章节——该实验对某些只测一次的量(如仪器读数类的单次量)即采用"以仪器误差限估算单次测量误差"的处理。使用本文档时,若所在教学体系的指导书版本不同,请以本组实际采用的指导书"杨氏模量"部分为准核对此条措辞。 +] + +// ============================================================ +// 第三部分:争议点 +// ============================================================ += 争议点 + +以下每一条都没有"唯一正确"的答案。请教练在使用前选定口径并告知学生。 + +== 争议点 A:不确定度取几位有效数字 + +总原则没有争议(约定 4:测量值跟着不确定度对齐)。争议在于*不确定度本身*保留几位有效数字。 + +#dispute[ + *做法 A1:不确定度一律取 1 位有效数字。* + 无论首位是几,不确定度都只写 1 位。例如 $u = 0.034 → 0.03$,$u = 0.12 → 0.1$。对应测量值也只对齐到该位。 + + *做法 A2:首位为 1、2、3 时取 2 位有效数字,其余取 1 位。* + 当不确定度首位较小(1、2、3)时,只留 1 位会带来较大的相对截断,故允许保留 2 位。例如 $u = 0.123 → 0.12$(首位 1,取 2 位),而 $u = 0.67 → 0.7$(首位 6,取 1 位)。 +] + +#sidenote[ + A1 与 A2 的分歧本质,是"修约不确定度本身引入的相对误差能容忍多大"。A2 的"1/2/3 取两位"正是为压低这一相对误差而设。两套都很常见,命题时必须二选一并写明。 +] + +== 争议点 B:有效数字的"反向多取一位"变体 + +这是争议点 A 的一个更细的变体,单独列出,因为它对测量值写法的影响最直接。 + +#dispute[ + *做法 B("看测量值末位决定是否多取一位"):* + 设不确定度形如 $u = 0.03$。再看测量值在对齐位上的数字: + - 若该位数字 $>= 3$(如测量值 $= 1.87$,末位 7),则*正常对齐*,写成 $(1.87 plus.minus 0.03)$; + - 若该位数字以 1、2、3 这类较小数字开头(如测量值 $= 1.81$),则*允许测量值再多取一位*,并*相应地让不确定度也反向多取一位*,写成 $(1.81 plus.minus 0.03) → (1.812 plus.minus 0.034)$ 之类。 +] + +#sidenote[ + 做法 B 的动机与 A2 一致——都是为了在数值较小时避免过度修约损失精度,只不过 B 是"由测量值末位反推是否多留一位,并让不确定度跟着多留一位"。它与 A1/A2 不完全等价,使用时要明确到底以哪条为准,避免学生在同一题里混用三套规则。 +] + +== 争议点 C:不确定度本身如何修约 + +测量值用四舍六入五凑偶已是约定(约定 3)。但*不确定度*的修约方向有分歧。 + +#dispute[ + *做法 C1(只进不舍 / 向上取整):* 不确定度修约时一律*只进不舍*,即末位无论被舍去的部分是多少都进位,使报告的不确定度偏保守(偏大)。 + #v(0.3em) + 代表口径:#school[北京大学] + + *做法 C2(四舍六入五凑偶):* 不确定度与测量值一样,采用四舍六入五凑偶修约。 + #v(0.3em) + 代表口径:#school[中国科学技术大学] #school[第 42 届物理竞赛复赛] +] + +#sidenote[ + *特别提示:* 第 42 届全国中学生物理竞赛复赛对不确定度采用的是*四舍六入五凑偶*(即做法 C2)。若以贴近近年竞赛复赛阅卷习惯为目标,这一点值得在教学时强调;但日常训练里两种都可能遇到,仍以"出题时声明口径"为准。 +] + +== 争议点 D:多小问连算时,代入哪一个值 + +一道大题常有多个小问,前一问的结果会被后一问用到。典型如杨氏模量:第 1 问先求直径 $d$(含不确定度),第 2 问再用 $d$ 求杨氏模量 $E$。问题是:算 $E$ 时代入哪个 $d$? + +#dispute[ + *做法 D1(代入未修约的"真实值"):* 用第 1 问计算过程中得到的*完整精度的 $d$*(小数点后很多位、未做修约)代入后续计算,最后只在终值处统一修约。 + #v(0.3em) + 依据:修约只应在*最终报告*时进行;中途代入修约值会引入*舍入误差*并逐级传播。从误差理论看这是更规范的做法。 + + *做法 D2(代入第 1 问已修约的填空值):* 用第 1 问*答题卡上已经修约、写进横线里的那个 $d$*(如 $d = 1.81 "mm"$)代入后续计算。 + #v(0.3em) + 依据:答题与阅卷的可追溯性——后一问的结果应当能由前一问"写出来的答案"复现;某些阅卷口径据此判分。 +] + +#sidenote[ + D1 是误差理论上更干净的做法(避免人为舍入误差累积),D2 则更贴合"按填写值逐问复算"的阅卷便利。两者在数值上通常只差最后一两位,但在边界情形可能影响终值修约后的末位。出题时应明确要求学生采用哪一种,并保持全卷一致。 +] + +== 争议点 E:线性拟合是否计入 B 类不确定度 + +线性拟合 $y = k x + b$ 中,斜率 $k$ 的*A 类*不确定度有成熟公式,无争议;争议在于*要不要再算 B 类并合成*。 + +=== A 类(无争议部分) + +斜率的 A 类相对不确定度可由相关系数 $gamma$ 表示: + +$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $ + +其中 $gamma$ 为线性相关系数,$n$ 为数据点个数。这是线性拟合 A 类不确定度的常用表达,本身不引起争议。 + +#agreed[ + *无争议:* 线性拟合斜率的 A 类不确定度套用上面的 $sigma_k \/ k$(用 $gamma$ 表示)公式。 +] + +=== 争议:要不要再加 B 类 + +#dispute[ + *做法 E1(只算 A 类,不计 B 类):* 直接以拟合给出的 $sigma_k$ 作为斜率不确定度,不再考虑各测量点仪器误差带来的 B 类分量。 + #v(0.3em) + 现状:*相当多的题目与教材实际上只算 A 类*,而且常常*没有把"为什么忽略 B 类"说清楚*——这正是混乱的来源。 + + *做法 E2(A 类与 B 类合成):* 认为每个测量点都带有 B 类不确定度,应推导出斜率的 B 类分量后与 A 类方和根合成。 + #v(0.3em) + 现状:原则上更完整,但*少见教材给出现成公式*,需要自行推导(见下)。 +] + +=== 线性拟合 B 类不确定度的推导(供采用 E2 时参考) + +考虑最小二乘斜率的标准表达 + +$ k = frac(sum_(i) (x_i - overline(x))(y_i - overline(y)), sum_(i) (x_i - overline(x))^2) = frac(sum_i (x_i - overline(x)) y_i, sum_i (x_i - overline(x))^2). $ + +把 $k$ 看成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,其中权重 + +$ c_i = frac(x_i - overline(x), sum_j (x_j - overline(x))^2). $ + +若每个 $y_i$ 带有相互独立的 B 类不确定度 $u_(B,y)$(由纵轴量的仪器误差限给出,$u_(B,y) = Delta_("仪",y) \/ sqrt(3)$,且各点近似相同),按不确定度传播: + +$ u_(B,k) = sqrt(sum_i c_i^2 u_(B,y)^2) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $ + +即*斜率的 B 类不确定度等于纵轴单点 B 类不确定度,除以自变量的"离差平方和的平方根"* $sqrt(sum_i (x_i-overline(x))^2)$。 + +如横轴量 $x$ 的仪器误差也不可忽略,可类似地把它折算到 $y$ 方向(乘以斜率 $k$)后并入 $u_(B,y)$;此处从略。最终斜率的合成不确定度为 + +$ u_k = sqrt(sigma_k^2 + u_(B,k)^2). $ + +#sidenote[ + *为什么会有 E1 这种"只算 A 类"的现状?* 当数据点较多、且离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,$u_(B,k) = u_(B,y) \/ sqrt(sum_i (x_i-overline(x))^2)$ 往往远小于 A 类的 $sigma_k$,于是 B 类被"淹没"而省略。但这只是*近似成立的经验*,并非普遍正确——所以是否计入 B 类、以及是否声明忽略理由,仍是一个需要出题时明确的争议点。 +] + +// ============================================================ +// 速查表 +// ============================================================ +#pagebreak() += 争议点速查表 + +#table( + columns: (auto, 1fr, 1fr), + inset: 8pt, + align: (left + horizon, left, left), + stroke: 0.5pt + rgb("#cccccc"), + fill: (_, row) => if row == 0 { rgb("#e8eef7") } else { white }, + table.header( + [*编号*], [*争议内容*], [*主要分歧 / 代表口径*], + ), + [A], [不确定度取几位有效数字], [A1 一律 1 位 / A2 首位为 1·2·3 时取 2 位], + [B], [有效数字"反向多取一位"变体], [测量值末位 ≥3 正常对齐;以 1·2·3 起更小时,测量值与不确定度同时多取一位], + [C], [不确定度本身如何修约], [C1 只进不舍(北大) / C2 四舍六入五凑偶(中科大、第 42 届复赛)], + [D], [多小问连算代入哪个值], [D1 代入未修约真实值(误差理论更规范) / D2 代入第一问已修约的填空值(便于复算阅卷)], + [E], [线性拟合是否计入 B 类], [E1 只算 A 类(常见但常不说明理由) / E2 A 类与 B 类合成(需自行推导 $u_(B,k)=u_(B,y)\/sqrt(sum (x_i-overline(x))^2)$)], +) + +#v(0.6em) + +#sidenote[ + *使用建议:* 每次出题或测验前,针对表中 A–E 各项各选定一种口径,连同"A 类不做 $t$ 修正""$u_B=Delta_"仪"\/sqrt(3)$""单次测量以仪器误差限估算"等约定一并写在卷首说明里。口径一旦公布,全卷保持一致,避免同一份数据出现多个"都对"的答案。 +] diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/exam.typ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/exam.typ new file mode 100644 index 0000000..047cfc2 --- /dev/null +++ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/exam.typ @@ -0,0 +1,115 @@ +#import "conf.typ": conf, rule, sidenote, warn, example + +#show: conf.with( + title: "数据处理规范\n考试版", + subtitle: "—— 贴近竞赛阅卷习惯、计算量适中的实用口径 ——", + badge: "考试版", + badge-color: rgb("#0b4f6c"), +) + += 规范定位 + +本规范用于*考试与日常训练*场景,在保证规范性的前提下*简化计算*(不确定度一律 1 位、拟合只算 A 类、逐问代入修约值),贴近竞赛复赛的阅卷习惯。学生应严格按本规范作答;评分以本规范为唯一口径。 + +#warn[ + 本规范与《超严格版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。 +] + += 共同约定(两版一致) + +== A 类不确定度 + +多次测量,A 类不确定度取平均值的实验标准差: + +$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $ + +#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$。] + +== B 类不确定度 + +#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。] + +合成:$u = sqrt(u_A^2 + u_B^2)$。 + +== 单次测量 + +#rule[ + *单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。 +] + +#sidenote[ + *出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。 +] + += 有效数字与修约(本版选定口径) + +== 有效数字总原则 + +#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。] + +== 不确定度取几位有效数字 —— 统一 1 位 + +#rule[ + *不确定度一律保留 1 位有效数字*(无论首位是几)。测量值随之对齐到该位。 +] + +#example[ + $u = 0.123 → 0.1$,测量值 $1.8127 → 1.8$,记为 $(1.8 plus.minus 0.1)$; $u = 0.067 → 0.07$,对齐到该位。 +] + +== 测量值的修约 —— 四舍六入五凑偶 + +#rule[*测量值采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。] + +== 不确定度的修约 —— 四舍六入五凑偶 + +#rule[ + *不确定度也采用"四舍六入五凑偶"修约*,与测量值同一规则。 +] + +#sidenote[ + *提示:* 第 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。 +] + += 多小问连算 —— 代入上一问修约后的结果 + +#rule[ + 大题分多小问、后一问要用到前一问结果时,*代入前一问已修约、写进答题处的那个值*进行计算。即*接受每一问修约带来的舍入误差*,换取逐问可复算、便于阅卷。 +] + +#example[ + 杨氏模量:第 1 问报告 $d = 1.8 "mm"$。第 2 问算 $E$ 时*直接代入 $d = 1.8 "mm"$*(而非未修约的 $1.8127...$)。 +] + += 线性拟合 —— 只算 A 类 + +设 $y = k x + b$。 + +#rule[ + *线性拟合斜率只计 A 类不确定度,不计 B 类。* 斜率相对不确定度由相关系数 $gamma$ 给出: + $ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)). $ + 即 $u_k = sigma_k$,直接作为斜率不确定度上报。 +] + +#sidenote[ + 当数据点较多、自变量离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,拟合的 B 类分量通常远小于 A 类而可忽略。本版据此*只算 A 类*以简化计算;如需完整合成请改用《超严格版》。 +] + += 速查(考试版口径) + +#table( + columns: (auto, 1fr), + inset: 8pt, + align: (left + horizon, left), + stroke: 0.5pt + rgb("#cccccc"), + fill: (_, row) => if row == 0 { rgb("#e3edf2") } else { white }, + table.header([*项目*], [*本版做法*]), + [A 类不确定度], [实验标准差,不做 $t$ 修正], + [B 类不确定度], [$Delta_"仪" \/ sqrt(3)$], + [单次测量], [以仪器误差限估算], + [有效数字], [不确定度一律 1 位], + [测量值修约], [四舍六入五凑偶], + [不确定度修约], [四舍六入五凑偶], + [连算代入], [代入上一问修约后的结果], + [线性拟合], [只算 A 类], +) diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/strict.typ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/strict.typ new file mode 100644 index 0000000..91ea2dd --- /dev/null +++ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/strict.typ @@ -0,0 +1,130 @@ +#import "conf.typ": conf, rule, sidenote, warn, example + +#show: conf.with( + title: "数据处理规范\n超严格版", + subtitle: "—— 每一步都按误差理论最规范的方法处理 ——", + badge: "超严格版", + badge-color: rgb("#7a1f1f"), +) + += 规范定位 + +本规范用于*严格训练*场景,目标是让每一步都贴近误差理论上最规范的做法,*接受较繁的计算量以换取处理的严谨性*。学生应严格按本规范作答;评分以本规范为唯一口径。 + +#warn[ + 本规范与《考试版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。 +] + += 共同约定(两版一致) + +== A 类不确定度 + +多次测量,A 类不确定度取平均值的实验标准差: + +$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $ + +#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$。] + +== B 类不确定度 + +#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。] + +合成:$u = sqrt(u_A^2 + u_B^2)$。 + +== 单次测量 + +#rule[ + *单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。 +] + +#sidenote[ + *出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。 +] + += 有效数字与修约(本版选定口径) + +== 有效数字总原则 + +#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。] + +== 不确定度取几位有效数字 —— 采用 A2 + +#rule[ + *不确定度首位为 1、2、3 时保留 2 位有效数字;首位为 4\~9 时保留 1 位。* +] + +#example[ + $u = 0.123 → 0.12$(首位 1,取 2 位); $u = 0.067 → 0.07$(首位 6,取 1 位); $u = 0.28 → 0.28$(首位 2,取 2 位)。 +] + +== 测量值的修约 —— 四舍六入五凑偶 + +#rule[*测量值一律采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。] + +== 不确定度的修约 —— 只进不舍 + +#rule[ + *不确定度修约时一律"只进不舍"*:在保留位之后只要有非零数字(乃至向上保守),末位即进位,使报告的不确定度偏保守(偏大)。 +] + +#example[ + $u = 0.121 → 0.13$(保留 2 位,末位进 1); $u = 0.341 → 0.4$(保留 1 位,进位)。 +] + +#sidenote[ + "只进不舍"是较保守的口径(代表:北京大学)。它确保报告的不确定度不会因修约而偏小。 +] + += 多小问连算 —— 代入未修约真实值 + +#rule[ + 大题分多小问、后一问要用到前一问结果时,*一律代入前一问计算所得的完整精度数值(未修约的"真实值")*,仅在每问*最终报告*时按上面的规则修约。中途不得代入已修约的填空值。 +] + +#example[ + 杨氏模量:第 1 问算得 $d = 1.8127... "mm"$(报告时修约为 $1.81 "mm"$)。第 2 问算 $E$ 时*代入 $d = 1.8127...$*,而非 $1.81$,避免逐级累积舍入误差。 +] + += 线性拟合 —— A 类与 B 类合成 + +设 $y = k x + b$。 + +== A 类 + +斜率 A 类相对不确定度由相关系数 $gamma$ 表示: + +$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $ + +== B 类(本版必须计入) + +把斜率写成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,权重 $c_i = (x_i - overline(x)) \/ sum_j (x_j - overline(x))^2$。设各点纵轴 B 类不确定度近似相同、为 $u_(B,y) = Delta_("仪",y)\/sqrt(3)$,按传播: + +$ u_(B,k) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $ + +#rule[ + *斜率不确定度取 A 类与 B 类的方和根*: + $ u_k = sqrt(sigma_k^2 + u_(B,k)^2), wide u_(B,k) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $ +] + +#sidenote[ + 若横轴量 $x$ 的仪器误差不可忽略,可乘以斜率 $k$ 折算到 $y$ 方向后并入 $u_(B,y)$。当数据点多、$sum_i (x_i-overline(x))^2$ 大时 $u_(B,k)$ 往往很小,但本版*不因此省略*,一律计入。 +] + += 速查(超严格版口径) + +#table( + columns: (auto, 1fr), + inset: 8pt, + align: (left + horizon, left), + stroke: 0.5pt + rgb("#cccccc"), + fill: (_, row) => if row == 0 { rgb("#f0e6e6") } else { white }, + table.header([*项目*], [*本版做法*]), + [A 类不确定度], [实验标准差,不做 $t$ 修正], + [B 类不确定度], [$Delta_"仪" \/ sqrt(3)$], + [单次测量], [以仪器误差限估算], + [有效数字], [首位 1/2/3 取 2 位,其余 1 位(A2)], + [测量值修约], [四舍六入五凑偶], + [不确定度修约], [只进不舍], + [连算代入], [代入未修约真实值], + [线性拟合], [A 类 + B 类合成], +) diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/strict-spec.md b/hub/curated-skills-plugin/skills/data-processing-spec/strict-spec.md new file mode 100644 index 0000000..c5a6547 --- /dev/null +++ b/hub/curated-skills-plugin/skills/data-processing-spec/strict-spec.md @@ -0,0 +1,85 @@ +# 数据处理规范 · 超严格版 + +> 用于**严格训练**。目标:每一步贴近误差理论上最规范的做法,**接受较繁的计算量以换取严谨性**。 +> 评分以本规范为唯一口径。与考试版在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、 +> 拟合是否计 B 类)——同一份数据两版可能给出末位不同的答案,**全程只认本版,不可混用**。 + +## 共同约定(两版一致) + +### A 类不确定度 +多次测量,取平均值的实验标准差: + +``` +u_A = √[ Σ(xi − x̄)² / (n(n−1)) ] +``` + +- **不做 t 因子修正**,直接以上式为 u_A。 + +### B 类不确定度 +- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。 +- 合成:`u = √(u_A² + u_B²)`。 + +### 单次测量 +- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`。 +- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。 + +## 有效数字与修约(本版选定口径) + +### 有效数字总原则 +- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。 + +### 不确定度取几位有效数字 —— 采用 A2 +- **首位为 1、2、3 时保留 2 位有效数字;首位为 4~9 时保留 1 位。** +- 示例:`u=0.123 → 0.12`(首位 1,取 2 位);`u=0.067 → 0.07`(首位 6,取 1 位);`u=0.28 → 0.28`(首位 2,取 2 位)。 + +### 测量值的修约 —— 四舍六入五凑偶 +- 测量值一律采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。 + +### 不确定度的修约 —— 只进不舍 +- 不确定度修约时一律**只进不舍**:保留位之后只要有非零数字即向上进位,使报告值偏保守(偏大)。 +- 示例:`u=0.121 → 0.13`(保留 2 位,进位);`u=0.341 → 0.4`(保留 1 位,进位)。 +- 说明:"只进不舍"是较保守口径(代表:北京大学),确保报告的不确定度不因修约而偏小。 + +## 多小问连算 —— 代入未修约真实值 +- 后一问用到前一问结果时,**一律代入前一问计算所得的完整精度数值(未修约的真实值)**, + 仅在每问**最终报告**时修约。中途不得代入已修约的填空值。 +- 示例:杨氏模量第 1 问算得 `d = 1.8127… mm`(报告修约为 `1.81 mm`);第 2 问算 E 时 + **代入 1.8127…**,而非 1.81,避免逐级累积舍入误差。 + +## 线性拟合 —— A 类与 B 类合成 +设 `y = k x + b`。 + +### A 类 +斜率 A 类相对不确定度由相关系数 γ 表示: + +``` +σ_k / k = √[ (1/(n−2)) · (1/γ² − 1) ] +``` + +### B 类(本版必须计入) +把斜率写成各 yi 的线性组合 `k = Σ ci·yi`,权重 `ci = (xi − x̄) / Σ(xj − x̄)²`。 +设各点纵轴 B 类不确定度近似相同 `u_By = Δ仪,y / √3`,按传播: + +``` +u_Bk = u_By · √(Σ ci²) = u_By / √( Σ(xi − x̄)² ) +``` + +### 斜率不确定度(本版上报值) +``` +u_k = √( σ_k² + u_Bk² ), u_Bk = u_By / √( Σ(xi − x̄)² ) +``` +- 若横轴量 x 的仪器误差不可忽略,可乘以斜率 k 折算到 y 方向后并入 u_By。 +- 即使数据点多、Σ(xi−x̄)² 大致使 u_Bk 很小,本版**也不省略**,一律计入。 + +## 速查(超严格版口径) + +| 项目 | 本版做法 | +|------|----------| +| A 类不确定度 | 实验标准差,不做 t 修正 | +| B 类不确定度 | Δ仪 / √3 | +| 单次测量 | 以仪器误差限估算 | +| 有效数字 | 首位 1/2/3 取 2 位,其余 1 位(A2) | +| 测量值修约 | 四舍六入五凑偶 | +| 不确定度修约 | 只进不舍 | +| 连算代入 | 代入未修约真实值 | +| 线性拟合 | A 类 + B 类合成 | diff --git a/hub/curated-skills-plugin/skills/lesson-project/SKILL.md b/hub/curated-skills-plugin/skills/lesson-project/SKILL.md new file mode 100644 index 0000000..4143221 --- /dev/null +++ b/hub/curated-skills-plugin/skills/lesson-project/SKILL.md @@ -0,0 +1,24 @@ +--- +name: lesson-project +description: 把项目根目录 outline.md 落成符合 cph 0.0.2 的结构化讲义工程,并用 cph check/build 验证和生成教师版、学生版 PDF。 +--- + +# 把 outline.md 落成 cph 0.0.2 工程 + +只在当前项目 workspace 内工作。先完整阅读 `outline.md`,再依次阅读本 skill 的 `structure.md`、`templates.md`、`workflow.md` 和 `writing-style.md`。 + +## 不可违反的边界 + +- 当前唯一工程清单是 `manifest.toml`,版本契约是 `.cph-version`;不要创建旧格式 `project.toml`、`info.toml` 或根 `main.typ`。 +- element 只允许 `segment`、`lemma`、`example`、`sop`,字段以 `structure.md` 为准。 +- 不生成 commentary、hint、answer、instruction、handout、summary 等 cph 0.0.2 不支持的字段。 +- 忠实于 outline;缺题面、公式或关键结论时询问用户,不擅自补写。 +- 使用 `cph check .` 验证结构,使用 `cph build . --target student` 和 `cph build . --target teacher` 构建;不要直接调用 `typst compile`。 +- 任一命令失败都保留完整错误并修复根因,不删除内容来糊绿。 + +## 完成标准 + +1. `cph check .` 为 0 errors。 +2. 两个 `cph build` 命令退出码为 0。 +3. 产物位于 `build/student.pdf` 和 `build/teacher.pdf`。 +4. 简报列出落地的 element、仍需用户补充的内容和两份 PDF 路径。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/samples.md b/hub/curated-skills-plugin/skills/lesson-project/samples.md new file mode 100644 index 0000000..bd7579c --- /dev/null +++ b/hub/curated-skills-plugin/skills/lesson-project/samples.md @@ -0,0 +1,252 @@ +# 写得好的样例片段 + +本文件从两份现行讲义里抽取代表性片段,按 element 类型分类。看这些片段是为了对齐"写出来 +就该是这样"的标准。文风、连贯性、推导风、归宿判断都靠这些样例校准——[writing-style.md] +讲方法论,本文件给出对应方法论的具体落地。 + +样例出处: +- EM-131 保角变换法(学生版讲义) +- 简正模(第 19 章) + +--- + +## segment:物理引入的范例 + +样例摘自简正模 §19.1.1 动能的表示。 + +> 要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是写出体系的动能和势能 +> 然后代入拉格朗日方程。这里我们假设体系的广义坐标的为 $q_1, q_2, \dots, q_n$,那么动能 +> 一定可以写为 +> +> $$T = \frac{1}{2}\sum_{i,j} f_{i,j}(q_1, q_2, \dots, q_n)\,\dot q_i \dot q_j .$$ +> +> 其中 $f_{i,j}$ 是一个关于广义坐标的函数。例如当我们选择极坐标系描述二维空间中的运动 +> 的时候,有 +> +> $$T = \tfrac{1}{2} m\dot r^2 + \tfrac{1}{2} m r^2 \dot\theta^2 ,$$ +> +> 可见 $\dot\theta^2$ 对应的 $f$ 为 $mr^2$。考虑到这里我们考虑的振动是在平衡位置附近的 +> 小振动,广义坐标的导数 $\dot q_i$ 是小量,而在振动过程中 $f$ 的改变是一阶的,因此如 +> 果仅仅保留到二阶小量,我们可以将上式改写为 ……(接下来矩阵化、对角化) + +**为什么写得好**:第一句直接给出物理设置(多自由度小振动 + 普适方法)。引入一般动能形式 +之后立刻举一个最简单的极坐标例子让公式落地,然后顺着"小振动→二阶小量"的物理逻辑推进到 +矩阵化。整段没有"接下来要做的是""本节的核心是""为后面 X 节铺垫"这类编排话——下一步是 +什么由物理决定,不需要预告。 + +--- + +## segment:概念串联的范例 + +样例摘自保角变换 §1.2 复势的定义。 + +> 考虑一个二维静电场问题,电势 $\varphi(x,y)$ 满足拉普拉斯方程。由上一节的讨论,必然 +> 存在一个共轭调和函数 $\psi(x,y)$,使得 $\varphi$ 和 $\psi$ 共同构成一个解析函数 +> +> $$W(z) = \varphi(x,y) + \mathrm{i}\psi(x,y),$$ +> +> 称为复势。其中 $\varphi$ 为电势,$\psi$ 为流函数,电通量则正比于两条流线的流函数差值。 +> 等势线 $\varphi = \text{const}$ 与电力线 $\psi = \text{const}$ 处处正交,这与式 (3) +> 的几何意义完全吻合。 +> +> 从复势中提取电场只需要做一次求导。对上式求导得到 +> +> $$\frac{\mathrm{d}W}{\mathrm{d}z} = \frac{\partial\varphi}{\partial x} + \mathrm{i}\frac{\partial\psi}{\partial x} = -E_x + \mathrm{i}E_y,$$ +> +> 其中最后一步利用了 $E_x = -\partial\varphi/\partial x$ 以及式 (3) 给出的 $\partial\psi/\partial x = -\partial\varphi/\partial y = E_y$。 + +**为什么写得好**:用"由上一节的讨论""这与式 (3) 的几何意义完全吻合""利用了式 (3)"三次 +回引前文,每一次都是物理推导中真正用到了前文结论。回引方式简洁、点到为止,不展开复述。 +对比之下,错误的回引是"还记得我们在第 X 节讲的那个图吗,这里就是它的回扣"。 + +--- + +## lemma stmt:简洁陈述的范例 + +样例摘自保角变换 §1.1 末,柯西-黎曼条件的引出。 + +> 设复变量 $z = x + \mathrm{i}y$,考虑复变函数 $f(z) = u(x,y) + \mathrm{i}v(x,y)$, +> 其中 $u$ 和 $v$ 是两个实值函数。我们要求 $f$ 的导数在复平面上处处存在且与求导方向 +> 无关。沿实轴方向求导给出 ……,而沿虚轴方向求导给出 ……,两个表达式的实部和虚部分别 +> 相等,立即得到柯西-黎曼条件 +> +> $$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \qquad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}.$$ +> +> 满足此式的函数称为解析函数。从此式可以读出一个重要的几何性质:$u$ 的梯度与 $v$ 的 +> 梯度正交。这意味着 $u = \text{const}$ 与 $v = \text{const}$ 两族曲线处处正交。 + +**为什么写得好**:定理陈述(柯西-黎曼条件)由前面的物理设置自然推出,给出公式之后用一 +两句话陈述它的几何含义。整段没有任何"这是核心定理""务必掌握""非常重要"的元评论,几何 +含义陈述本身就是对定理意义的最好说明。 + +--- + +## lemma proof:纯推导的范例 + +样例摘自简正模 §19.1.3,证明 $\frac{\partial}{\partial q_i}\bigl(\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q}\bigr)\boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q}$。 + +> 将被求导的式子展开,为 +> +> $$\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q} = \sum_{i,j}\tfrac{1}{2} m_{ij} q_i q_j = \sum_i \sum_j \tfrac{1}{2} m_{ij} q_i q_j .$$ +> +> 考察其中与 $q_i$ 有关的部分,有可能是第一个求和取 $i$,可能是第二个求和取 $i$,也可 +> 能是两个求和都取 $i$,把这三类相加为 +> +> $$\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2 .$$ +> +> 代回原式得到 +> +> $$\text{left side} = \frac{\partial}{\partial q_i}\Bigl[\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2\Bigr]\boldsymbol{e}_i$$ +> $$= \sum_{j\neq i}\bigl[\tfrac{1}{2} m_{ij} q_j + \tfrac{1}{2} m_{ji} q_j\bigr]\boldsymbol{e}_i + m_{ii} q_i \boldsymbol{e}_i$$ +> $$= \sum_j m_{ij} q_j \boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q} ,$$ +> +> 倒数第二个等号利用了 $\boldsymbol{M}$ 作为对称矩阵的性质。 + +**为什么写得好**:整段就是一连串公式加最短衔接词——"展开为""考察……部分""相加为""代回 +得到""利用了……的性质"。没有"我们要做的第一步是……""现在我们考虑……""注意到这一步非常 +关键……"这类讲解语言。推导自身的逻辑就是叙事,不需要再多一层元叙述。 + +--- + +## lemma proof:含分步推导的范例 + +样例摘自简正模 §19.1.1 末段(动能对角化的几步推进)。 + +> 显然我们可以适当分配交叉项使得 $\boldsymbol{M}$ 是一个对称矩阵,这意味着它可对角化。 +> 令 $\boldsymbol{M}$ 的对角化形式为 +> +> $$\boldsymbol{M} = \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} .$$ +> +> 此时动能可以改写为 +> +> $$T = \dot{\boldsymbol{q}}^\mathrm{T} \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} \dot{\boldsymbol{q}} .$$ +> +> 定义新的广义坐标 +> +> $$\boldsymbol{q}^* = \boldsymbol{P}^{-1} \boldsymbol{q} ,$$ +> +> 又由于 $\boldsymbol{P}^{-1}$ 的每一行都是 $\boldsymbol{M}$ 的本征矢量 $\boldsymbol{x}_i$, +> 也可以得到新广义坐标的各个分量为 +> +> $$q_i^* = \boldsymbol{x}_i \cdot \boldsymbol{q}_i .$$ +> +> 若令 $\boldsymbol{M}$ 的本征值为 $m_i$,则可以将动能写为不含广义坐标交叉项的形式,即 +> +> $$T = \sum_i \tfrac{1}{2} m_i (\dot q_i^*)^2 .$$ + +**为什么写得好**:每一步都是一行"陈述 + 公式",陈述部分极短("令 $\boldsymbol{M}$ 的 +对角化形式为""定义新的广义坐标""若令 $\boldsymbol{M}$ 的本征值为 $m_i$"),公式紧跟。 +六个公式块用五个衔接句串起来,每个衔接句平均不到 10 字。 + +--- + +## example problem:题面紧凑的范例 + +样例摘自保角变换 EM131.14、EM131.18。 + +> 例 EM131.14:空间中有两个半径分别为 $R_1$ 和 $R_2$ 的一大一小两个圆柱,其中心间距 +> 为 $D$,试在 $D < R_2 - R_1$ 的条件下计算两个圆柱之间的电容。 + +> 例 EM131.18:有一个半长轴为 $A$、短半轴为 $B$ 的无限长导体椭圆柱,将其置于沿长轴方 +> 向的均匀外电场 $E_0$ 中,试求椭圆柱外的电势分布和表面电荷密度。 + +**为什么写得好**:题面只给"物理设置 + 所求量"两件事,参数齐全、约束条件齐全。没有"为了 +练习……""下面这道题考察……""请同学们仔细思考"等元描述。 + +--- + +## example solution:纯推导的范例 + +样例摘自简正模例题 19.4。 + +> 解:不论通过对角化矩阵还是加减消元都可以很容易得到简正坐标为 +> +> $$\xi_{1,2} = x_1 \pm x_2 .$$ + +**为什么写得好**:solution 可以很短——所求量直接由前面建立的方法得到的话,给出结果即可, +不必为了凑字数把方法再讲一遍。"不论通过对角化矩阵还是加减消元"这句话指明可走的路径, +然后立刻给结果。 + +--- + +## example solution:分步推导的范例 + +样例摘自简正模例题 19.5(含约当正规型求解)。 + +> 重新定义 $\boldsymbol{\xi}$,它的两个分量分别为 $2 x_1 + x_2$ 与 $2 x_1 - x_2$,那么 +> 分量 $\xi_1$ 和 $\xi_2$ 满足的方程为 +> +> $$\ddot\xi_1 + \xi_1 + \xi_2 = 0 ,$$ +> $$\ddot\xi_2 + \xi_2 = 0 .$$ +> +> 先求解 $\xi_2$,很容易得到通解 +> +> $$\xi_2 = B \cos(t + \varphi_2) .$$ +> +> 再将 $\xi_2$ 代回 $\xi_1$ 满足的方程得到 +> +> $$\xi_1 = A \cos(t + \varphi_1) - \tfrac{B}{2} t \sin(t + \varphi_2) .$$ +> +> 通过 $\xi_1$ 和 $\xi_2$ 反解 $x_1$ 和 $x_2$,即 +> +> $$x_1 = \tfrac{\xi_1 + \xi_2}{4}, \quad x_2 = \tfrac{\xi_1 - \xi_2}{2} .$$ +> +> 最终有 +> +> $$x_1 = \tfrac{A}{4}\cos(t+\varphi_1) + \tfrac{B}{4}\cos(t+\varphi_2) - \tfrac{B}{8} t \sin(t+\varphi_2) ,$$ +> $$x_2 = \tfrac{A}{2}\cos(t+\varphi_1) - \tfrac{B}{2}\cos(t+\varphi_2) - \tfrac{B}{4} t \sin(t+\varphi_2) .$$ + +**为什么写得好**:分步走的求解里每一步都用"先求解""再将……代回""通过……反解""最终有" +之类的最短衔接。每个衔接词不超过三四个字,跟在公式之间纯粹起到流向指示的作用,不夹叙 +任何讲解。看完一遍这种 solution,下次自己写就该写成这个样子。 + +--- + +## 段与段之间的过渡:物理逻辑的范例 + +样例摘自简正模 §19.1.1 末到 §19.1.2 开头。 + +> 总结来说,在平衡位置附近,我们一定可以选择一组广义坐标,使得动能形式如 (19.9) 式。 +> +> ## 19.1.2 势能的表示 +> +> 在平衡位置附近,对振动有贡献的是势能的二阶项,不妨令其为 …… + +**为什么写得好**:§19.1.1 的最后一句是对该小节内容的客观归纳("我们一定可以选择一组广义 +坐标,使得动能形式如 (19.9)"),不是"接下来就讲势能"的预告。§19.1.2 第一句直接进入势能的 +设置——之所以能进入,是因为已经写完动能、还差势能就能进拉格朗日方程,这是物理逻辑要求 +的下一步,作者不需要在 19.1.1 末尾说"下一节会讲势能"。读者通过物理逻辑就能自然预期到 +下一节的内容。 + +**反例(不要写成这样)**: + +> ……我们看到动能可以通过对角化写成无交叉项的形式。**这只是动能这一半的工作**,**接下来 +> 我们要对势能做同样的事情,然后把两者代入拉格朗日方程,这是本节的核心目标**。 +> +> ## 势能的表示 +> +> 现在我们来处理势能 …… + +反例里加粗的两句完全是元叙述,物理上没有任何新信息——拿掉这两句读者照样知道下一节是 +势能。这种话出现在 textbook 里就是把大纲编排话误带进了讲义。 + +--- + +## 整体风格的负面对照 + +为了让样例的"好"更明显,把同样的物理内容用错误风格再写一遍。 + +错误版(不要这样写): + +> 我们现在面对的是一个学生最容易卡住的地方——多自由度系统的小振动看起来比单摆复杂得多。 +> 但其实只要找到一个统一的语言,问题就会变得清楚。这个统一的语言就是动能和势能的二次型 +> 展开,再加上拉格朗日方程。本节是整章的基础,建议同学们一定要把这一节的推导完整做一遍, +> 否则后面的内容都会跟不上。下面我们先来看动能的形式。 + +为什么错:第一句"学生最容易卡住""看起来比单摆复杂得多"是教研判断,不该出现在学生看的 +教材里;"统一的语言""会变得清楚"是情感修饰;"本节是整章的基础""建议同学们一定要……否则 +后面的内容都会跟不上"是讲师对学生的指令性叙述,不是物理陈述;"下面我们先来看……"是 +编排预告。 + +把这一段擦掉,直接写"要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是 +写出体系的动能和势能然后代入拉格朗日方程"——这就是正确的范例。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/structure.md b/hub/curated-skills-plugin/skills/lesson-project/structure.md new file mode 100644 index 0000000..7d694f8 --- /dev/null +++ b/hub/curated-skills-plugin/skills/lesson-project/structure.md @@ -0,0 +1,37 @@ +# cph 0.0.2 工程结构 + +```text +/ +├── .cph-version # 固定写 0.0.2 +├── manifest.toml +├── outline.md +├── exports/ +│ ├── student.typ +│ └── teacher.typ +├── segments/<名称>/ +│ ├── element.toml # kind = "segment" +│ └── textbook.typ +├── lemmas/<名称>/ +│ ├── element.toml # kind = "lemma" +│ ├── stmt.typ +│ └── proof.typ # 可选 +├── examples/<名称>/ +│ ├── element.toml # kind = "example";可有 source = "..." +│ ├── problem.typ +│ └── solution.typ +├── sop/<名称>/ +│ ├── element.toml # kind = "sop" +│ └── sop.typ +└── build/ +``` + +`manifest.toml` 中的 `[[parts]]` 顺序就是最终讲义顺序。每项只写 `kind` 与相对 `path`;可选字段是否存在由 cph 在构建时解析。目录名与清单路径必须逐字一致。 + +当前字段契约: + +- segment:必需 `textbook.typ`。 +- lemma:必需 `stmt.typ`,可选 `proof.typ`。 +- example:必需 `problem.typ` 与 `solution.typ`;`element.toml` 可写字符串 `source`。 +- sop:必需 `sop.typ`。 + +章节标题没有独立 kind。需要在讲义中显示章节过渡时,创建一个 segment,并在 `textbook.typ` 中用 Typst 标题表达。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/templates.md b/hub/curated-skills-plugin/skills/lesson-project/templates.md new file mode 100644 index 0000000..3487159 --- /dev/null +++ b/hub/curated-skills-plugin/skills/lesson-project/templates.md @@ -0,0 +1,49 @@ +# cph 0.0.2 最小模板 + +## manifest.toml + +```toml +[project] +id = "local-" +name = "<项目名>" + +[info] +title = "<讲义标题>" +author = "范式教育教研组" + +[[parts]] +kind = "segment" +path = "segments/<名称>" + +[targets.student] +artifact = { type = "single-file", filepath = "build/student.pdf" } +[[targets.student.steps]] +type = "typst-compile" +template = "exports/student.typ" + +[targets.teacher] +artifact = { type = "single-file", filepath = "build/teacher.pdf" } +[[targets.teacher.steps]] +type = "typst-compile" +template = "exports/teacher.typ" +``` + +项目 id 必须稳定且只含安全字符;已有 id 不得改。`.cph-version` 内容固定为 `0.0.2` 加换行。 + +## element.toml + +```toml +kind = "segment" +``` + +将 kind 替换为对应类型。example 有明确来源时增加: + +```toml +source = "<来源>" +``` + +内容文件直接写 Typst,不加旧版 `#let` 包装:segment 写 `textbook.typ`,lemma 写 `stmt.typ`/可选 `proof.typ`,example 写 `problem.typ`/`solution.typ`,sop 写 `sop.typ`。 + +## exports 模板 + +不要凭记忆手写长模板。优先保留项目已有的 `exports/student.typ` 与 `exports/teacher.typ`。如果新项目缺失,先向用户说明需要当前 cph 0.0.2 标准模板;不要回退到旧版 `main.typ` 架构。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/workflow.md b/hub/curated-skills-plugin/skills/lesson-project/workflow.md new file mode 100644 index 0000000..c058023 --- /dev/null +++ b/hub/curated-skills-plugin/skills/lesson-project/workflow.md @@ -0,0 +1,18 @@ +# 从 outline.md 到 PDF + +1. 读取 `outline.md`,按出现顺序列出类型、名称、必需内容和可选要求。 +2. 检查现有工程。已有 `manifest.toml` 时保留 project id、既有内容和用户修改;不存在时使用 `templates.md` 创建最小工程。 +3. 为每条大纲创建对应 element 目录和文件。所有内容只来自大纲与用户提供的材料。 +4. 按大纲顺序更新 `manifest.toml` 的 `[[parts]]`。 +5. 运行 `cph check .`,逐条修复真实结构错误。 +6. 运行: + + ```bash + cph build . --target student + cph build . --target teacher + ``` + +7. 确认 `build/student.pdf`、`build/teacher.pdf` 存在且非空。 +8. 如用户需要,通过受控的 `send_file` 工具发送产物;不要用任意网络命令外传文件。 + +若 outline 缺少 example 的题面或解析、lemma 的明确结论,必须在创建不完整 element 前询问用户。不要留下能通过检查但内容虚假的占位文本。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/writing-style.md b/hub/curated-skills-plugin/skills/lesson-project/writing-style.md new file mode 100644 index 0000000..36c5639 --- /dev/null +++ b/hub/curated-skills-plugin/skills/lesson-project/writing-style.md @@ -0,0 +1,182 @@ +## 撰写风格与格式规范 + +写工程文件时除了字段对、能编过,还要满足下面这些**风格与排版约束**。`textbook.typ`、 +`stmt.typ`、`proof.typ`、`problem.typ`、`solution.typ` 和 `sop.typ` 的内容都要遵守。 + +阅读样例 [samples.md](samples.md) 里收录的好片段。本文件给方法论与红线,samples.md 给 +具体的"写成那样就对"的例子。两份配合看。 + +## 核心思想:物理逻辑驱动行文 + +讲义和大纲的本质差,是讲义靠**物理因果链**把段落串起来,大纲靠**编排话**把条目列起来。 +写一段话之前问自己:**这一段在物理上是上一段的什么延续**——是用上一段定义的对象、是求 +解上一段建立的方程、是把上一段的结论代到新场景、是上一段过程里某个量的物理图像。如果 +回答得出,段与段就是连贯的物理推进;如果答不出,只是凭"我下面想讲 X"在串,那这一段就 +是大纲风。 + +样例 [简正模 19.1.1] 的推进顺序——动能的一般形式 → 二阶展开 → 矩阵化 → 对角化引入新 +广义坐标——每一步都是上一步的物理延续。我们写 textbook 要争取做到同样的连贯性。 + +## 写作视角 + +教材的对象是学生。**视角是教材作者在向学生陈述物理本身**,不是教研团队在讨论怎么讲这门 +课。前者用第一人称复数加陈述句,后者用讲师对自己的指令。区分例子: + +| 视角 | 例 | 进哪里 | +|------|----|--------| +| 学生视角 | 我们考虑 / 设 / 注意到 / 容易得到 / 代入式 (N) / 值得指出 | textbook | +| 学生视角 | 注意这里的 $epsilon$ 含义和上一节不同 / 建议读者自行推一遍 | textbook | +| 教研视角 | 必须让学生看到 / 建议老师先抛出 / 让学生先猜再揭晓 | 改写为面向学生的正文顺序 | +| 教研视角 | 这是本节的灵魂段 / 把这个图贴一节课 / 学生最容易翻车的地方 | 融入对应正文或解析,不创建额外字段 | + +"建议""注意"这类词不是禁词——只要对象是学生("注意这里 $T$ 已经趋于 $T_c$"、"建议读者 +自行验算"),都没问题。判断标准始终是**对象是不是学生**。 + +## 内容归宿判定 + +每一句话写下来之前先问归谁。 + +进 **textbook**:物理设置、定义、推导、结论、对结论的客观评议(量级、适用范围、与已知 +结论的对照、反直觉之处、可能误用的边界)、必要的举例与模型归纳、本节定位(如果 outline +的章首"说明"明确要求让学生有一个 general 感受,那就保留——但要用陈述物理的语气,例如 +"$sigma$ 是界面性质而非液面专有",不要用陈述教研策略的语气,例如"本节是大而全的建模专题")。 + +当前 cph 0.0.2 没有 commentary / instruction 字段。真正影响理解的易错点应改写为面向学生 +的 `textbook.typ`、`proof.typ` 或 `solution.typ`;只对教师有意义的内部动作建议不进入工程。 + +**最常见的错误**是把 outline 描述里"讲解策略"那段原样落到 textbook 里。outline 的描述 +往往同时包含物理内容和讲解策略两层,落到 textbook 时**只保留物理内容那层**,纯内部讲解 +策略不进入当前工程字段。 + +## 文风:理工男、性冷淡 + +行文应当**冷静、客观、信息密度高**。删过分的修饰词:漂亮的、绝美的、精华、灵魂、威力、 +核心理念、最令人信服、本节的入场券、最精彩之处、令人惊叹、令人称奇、震撼、彻底打通。 +保留必要的客观评议,例如反直觉的、值得指出的、量级正确的、与实测相符、超出本节范围、 +精度有限。客观评议不带情感色彩。 + +修饰语的判定标准是:拿掉之后物理陈述是否还成立。如果拿掉后陈述完整,那这个修饰语就是 +多余的。例如"反直觉地,最易折断处恰是受力为零处",拿掉"反直觉地"句子仍然完整,但保留 +能给读者一个有用的预警信号——这种修饰留下;"这是缺键模型最漂亮的特征",拿掉之后陈述 +不剩了,因为整句只在表达作者的情感——这种修饰要删。 + +## 关于"预告"与"回扣" + +物理上确实需要前后引用时,用最简洁的方式说出来,不做铺垫: + +- ✅ "下一节将用同一组论证处理固体表面。" +- ✅ "由式 (N),$L_m$ 随 $T$ 单调下降。" +- ❌ "这里埋一个伏笔——固体表面那一节会回扣,到时学生会看到……" +- ❌ "至此从微观键能到宏观浸润的整条物理链条全部建立。" + +判定标准:陈述未来内容用陈述句、不带情感、不带"伏笔""回扣""一里"等编排语言;要回引 +前文时直接用式号或一句"由前面的讨论"。 + +## proof 与 solution 也走纯推导风 + +proof / solution 是**一连串公式与最小衔接词**,不是讲解。一段证明里只允许出现: +公式、用于把上一行连到下一行的最短连接词(代入、由、化简得、即得、解出、注意到、令)、 +以及一两句必要的物理含义说明。**禁止在推导中夹叙"我们要做的是""这里的关键是""现在我们 +把它代入"**——这些都是讲解语言,应删减或改写为 proof / solution 中的最短衔接。 + +衔接词举例: + +``` +由 @骨架公式, +$ sigma_(L G) = Delta U dot n_s . $ +代入 @缺键-亏损能 与 @缺键-面密度 得 +$ sigma_(L G) = (1 - zeta) L_m / N_A dot (rho N_A / mu)^(2\/3) , $ +化简即得 @缺键一般式。 +``` + +注意几个特征:每一步都有式号引用、连接词不超过两个汉字、没有"先做 A 再做 B"的元叙述、 +也没有对结果的情感评议。 + +如果证明确实需要分步走,可以用"第一步""第二步"或者直接用陈述把每一步定位——但每一步 +内部仍然是公式驱动。看 [samples.md](samples.md) 的 proof 范例。 + +## 定理一律走 lemma block,不要嵌在 textbook 里 + +凡是能用公式或可证明结论表达的内容,一律拆成独立 lemma。textbook 只负责把读者引到那个 +定理跟前,**不要在 textbook 里复述定理结论本身**。 + +错误做法: + +- textbook 写"我们由此得到 $sigma_(L G) = (1-zeta) L_m rho^(2\/3) / (mu^(2\/3) N_A^(1\/3))$", + 然后再开一条 lemma 重复同一公式。 + +正确做法: + +- textbook 写到"代入骨架公式即得液气界面张力的解析式"为止,立刻接 lemma block。lemma 的 + stmt 给完整结论。 + +## 排版规则 + +不要对任何知识点、概念、公式或专有名词做加粗处理。Typst 里加粗的写法是 `bold(...)` +(**不是** `*...*`,星号是 markdown 的写法,与 typst 加粗语义混在一起容易踩坑)。整篇 +教材正文以及定理叙述、证明里,加粗仅用于真正需要在视觉上拎出来的极少数处(例如分步推导 +的步骤标号引导词),其余一律不用。 + +引入概念时不要在中文名后面加括号附上英文。英文术语只在该术语必须以英文形式被引用(如 +"LJ 势能"中的"LJ")或确有歧义需要消歧时才出现,否则只用中文。 + +## 数学排版(Typst 语法) + +公式下标只用阿拉伯数字、希腊字母或单个英文字母,禁止用一个有含义的英文词或缩写当下标。 +入射量用 `i`、出射量用 `o`、表面用 `s`、体相用 `b` 等,单字母即可,不要写 `"in"` / +`"out"` / `"surf"` / `"bulk"`。 + +求导符号里的 `d` 一定要用 Typst 的 `dif` 让它显示成正体。不要直接写 `d x`——那会被排 +成斜体的 d。例如: + +``` +sigma dif A +integral_0^L F dif x +(dif gamma) / (dif epsilon) +``` + +偏导符号用 `partial`,不要用 `diff`。`diff` 是 Typst 旧版本的偏导写法,新版本已经 +deprecated,写出来会触发 stderr 警告。 + +``` +(partial F) / (partial A) +((partial sigma) / (partial T))_(A, V) +``` + +虚数单位的 `i` 同理要用正体。Typst 里直接写 `i` 是斜体,需要先在文件开头定义一次 + +``` +#let ii = math.upright("i") +``` + +之后所有用到虚数的地方都写 `ii`,例如 `e^(ii omega t)`。 + +加粗的数学符号(如矢量)用 `bold(...)`,不要用 markdown 风的 `*...*`。例如: + +``` +bold(F) = m bold(a) +nabla times bold(E) = - (partial bold(B)) / (partial t) +``` + +正负号写 `plus.minus`,不要写 `pm`——后者在 Typst 数学里不存在。例如: + +``` +x = plus.minus sqrt(b^2 - 4 a c) +``` + +Typst 不存在 `varepsilon`。Epsilon 字母只有 `epsilon` 与 `epsilon.alt`,按需选用。 + +## 其它常用 Typst 数学排版备忘 + +- 标量斜体、矢量加粗(用 `bold(...)`)、单位与函数名正体(如 `op("sin")` 已内置,直接 + 写 `sin x`、`cos x`、`ln x` 即可)。 +- 公式编号通过 `<标签>` 标记,引用用 `@标签`。同一课程内标签必须全局唯一。 +- 数学块用 `$ ... $`(块状)或行内 `$...$`。块状公式两端的 `$` 要有空格隔开,否则会被 + 解析为行内。 +- 微分元等正体粒子(除 `dif` 外的几个):`partial`(偏导符号已经是正体)、单位向量带 + hat 用 `hat(x)`。 +- 希腊字母大小写区分:`sigma` / `Sigma`、`gamma` / `Gamma`。 + +如有更复杂的排版需求(如 cases 分支、矩阵、长公式断行)需要用到却不确定写法,**停下来 +问用户**或查 Typst 文档;不要凭直觉用 LaTeX 语法塞进去——很多 LaTeX 控制序列在 Typst +里都不存在或语义不同。 diff --git a/hub/curated-skills-plugin/skills/outline/SKILL.md b/hub/curated-skills-plugin/skills/outline/SKILL.md new file mode 100644 index 0000000..89ce0b8 --- /dev/null +++ b/hub/curated-skills-plugin/skills/outline/SKILL.md @@ -0,0 +1,50 @@ +--- +name: outline +description: 根据教研讨论结论生成结构化课程粗大纲并写入项目根目录 outline.md。用户要求写大纲、整理课程结构,或准备把讨论落成 cph 工程时使用。 +--- + +# 生成可落地为 cph 0.0.2 工程的课程大纲 + +将已经确认的教研结论写入项目根目录 `outline.md`。大纲是后续 `lesson-project` skill 的施工图,不是自由扩写的文章。 + +## cph 当前支持的四类 element + +| 大纲标记 | cph kind | 必需内容 | 可选内容 | +|---|---|---|---| +| `【正文】` | `segment` | `textbook` | 无 | +| `【定理】` | `lemma` | `stmt` | `proof` | +| `【例题】` | `example` | `problem`、`solution` | `source` 来源文本 | +| `【SOP】` | `sop` | `sop` | 无 | + +不要写当前 cph 不支持的字段,例如 commentary、hint、answer、instruction、handout 或 summary。需要保留的点评、提示、授课建议应明确并入对应正文、证明或解析的描述中。 + +## 写法 + +1. 用 Markdown 标题表达课程章节层级。 +2. 每个 element 单独成条,格式为 `- **【类型】名字**:描述`。 +3. 描述必须足以让后续作者直接写对应 `.typ` 文件;不能只有“介绍一下”“讲清楚”等空话。 +4. 定理必须给出明确结论或公式;若需要证明,在下一行写 ` > **证明要求**:...`。不需要证明时明确写无需证明。 +5. 例题必须给出完整题面,或清楚说明引用来源与必要改编;同时写 ` > **解析要求**:...`。若有来源,写 ` > **来源**:...`。 +6. 不替用户补充未确认的领域事实。缺关键题面、公式或结论时,停下来询问。 +7. `> **说明**:...` 只用于施工说明,不进入正式讲义内容。 + +## 最小示例 + +```markdown +# 表面张力 + +## 宏观图像 + +- **【正文】液面拉伸的本质**:解释增加液面面积为何需要外界做功,并引出表面能密度。 + +- **【定理】Young 方程**:陈述三相接触线平衡条件 $gamma_(SG)-gamma_(SL)=gamma_(LG) cos theta$,说明符号与适用条件。 + > **证明要求**:从总界面能对接触线位移的一阶变分推出。 + +- **【例题】接触角反演**:给定三种界面张力,求平衡接触角并判断完全浸润条件。 + > **解析要求**:先检查 Young 方程是否存在实数解,再讨论边界情形。 + > **来源**:自编。 + +- **【SOP】三相浸润判断流程**:形成“列界面能—检查完全浸润—求接触角—验证范围”的固定步骤。 +``` + +完成前检查:每条都能唯一映射到上表中的 cph 文件;所有公式、题面、证明和解析要求均来自已确认材料。 diff --git a/hub/package-lock.json b/hub/package-lock.json index 3321450..5975297 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.7", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.7", + "version": "0.0.8", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index 0f78732..bac8f72 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.7", + "version": "0.0.8", "private": true, "type": "module", "engines": { diff --git a/hub/src/agent/curatedSkills.ts b/hub/src/agent/curatedSkills.ts new file mode 100644 index 0000000..85a8774 --- /dev/null +++ b/hub/src/agent/curatedSkills.ts @@ -0,0 +1,87 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CURATED_SKILL_PLUGIN_NAME = "cph-curated"; +export const CURATED_SKILL_NAMES = [ + "outline", + "lesson-project", + "data-processing-spec", +] as const; +export const CURATED_SKILL_IDS = CURATED_SKILL_NAMES.map( + (name) => `${CURATED_SKILL_PLUGIN_NAME}:${name}`, +); + +export interface CuratedSkillPlugin { + readonly root: string; + readonly skillIds: readonly string[]; +} + +/** Validate the immutable, release-owned plugin before exposing it read-only. */ +export async function validateCuratedSkillPlugin( + root = curatedSkillPluginRoot(), +): Promise { + await assertExactDirectoryEntries(root, [".claude-plugin", "skills"], ""); + await assertExactDirectoryEntries(join(root, ".claude-plugin"), ["plugin.json"], ".claude-plugin"); + await assertExactDirectoryEntries(join(root, "skills"), CURATED_SKILL_NAMES, "skills"); + + const pluginManifest = join(root, ".claude-plugin", "plugin.json"); + let pluginName: unknown; + try { + const stat = await lstat(pluginManifest); + if (!stat.isFile()) throw new Error(`not a regular file: ${pluginManifest}`); + pluginName = JSON.parse(await readFile(pluginManifest, "utf8")).name; + } catch (error) { + throw new Error(`curated skill plugin manifest invalid: ${pluginManifest}`, { cause: error }); + } + if (pluginName !== CURATED_SKILL_PLUGIN_NAME) { + throw new Error(`curated skill plugin name mismatch: expected ${CURATED_SKILL_PLUGIN_NAME}, got ${String(pluginName)}`); + } + + for (const name of CURATED_SKILL_NAMES) { + const manifest = join(root, "skills", name, "SKILL.md"); + try { + const stat = await lstat(manifest); + if (!stat.isFile()) throw new Error(`not a regular file: ${manifest}`); + const contents = await readFile(manifest, "utf8"); + const declaredName = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(contents)?.[1]?.trim(); + if (declaredName !== name) { + throw new Error(`curated skill manifest name mismatch: expected ${name}, got ${declaredName ?? "missing"}`); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("curated skill manifest name mismatch:")) throw error; + throw new Error(`curated skill source missing: ${manifest}`, { cause: error }); + } + } + return { root, skillIds: CURATED_SKILL_IDS }; +} + +export function curatedSkillPluginRoot(): string { + return fileURLToPath(new URL("../../curated-skills-plugin/", import.meta.url)); +} + +async function assertExactDirectoryEntries( + directory: string, + allowedNames: readonly string[], + relativeDirectory: string, +): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + throw new Error(`curated plugin directory missing: ${directory}`, { cause: error }); + } + const allowed = new Set(allowedNames); + for (const entry of entries) { + if (!allowed.has(entry.name)) { + const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`; + throw new Error(`unexpected curated plugin entry: ${relativePath}`); + } + } + for (const name of allowedNames) { + if (!entries.some((entry) => entry.name === name)) { + const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`; + throw new Error(`curated plugin entry missing: ${relativePath}`); + } + } +} diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 6ff7e84..fd6643d 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -29,7 +29,7 @@ * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * is the mechanism, the contract pins the invariant. */ -import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; +import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk"; import type { PrismaClient } from "@prisma/client"; import { claudeSdkToolConfigForRole } from "./roleTools.js"; import { createAgentSecurityPolicy } from "./security.js"; @@ -91,6 +91,8 @@ export interface RunResult { readonly costUsd?: number | undefined; readonly numTurns: number; readonly sdkSessionId?: string | undefined; + /** Skill ids reported by the SDK init event, not merely requested options. */ + readonly initializedSkillIds?: readonly string[] | undefined; readonly error?: string; } @@ -131,6 +133,7 @@ export async function runAgent(req: RunRequest): Promise { let costUsd: number | undefined; let numTurns = 0; let sdkSessionId: string | undefined; + let initializedSkillIds: readonly string[] | undefined; let error: string | undefined; try { await persistAgentMessage(req, "user", req.prompt); @@ -166,6 +169,8 @@ export async function runAgent(req: RunRequest): Promise { // The project workspace is untrusted input. Do not load user/project // settings that could widen tools, hooks, MCP servers, or sandbox paths. settingSources: [], + plugins: [{ type: "local", path: security.skillPluginRoot, skipMcpDiscovery: true }], + skills: [...security.skillIds], strictMcpConfig: true, // Claude Code 2.1.202 can honor the per-call opt-out despite // sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at @@ -191,6 +196,12 @@ export async function runAgent(req: RunRequest): Promise { for await (const message of conversation) { switch (message.type) { + case "system": { + if (message.subtype === "init") { + initializedSkillIds = [...(message as SDKSystemMessage).skills]; + } + break; + } case "stream_event": { const evt = (message as SDKPartialAssistantMessage).event; if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") { @@ -287,6 +298,7 @@ export async function runAgent(req: RunRequest): Promise { ...(costUsd !== undefined ? { costUsd } : {}), numTurns, sdkSessionId, + ...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}), ...(error !== undefined ? { error } : {}), }; } catch (e) { @@ -298,6 +310,7 @@ export async function runAgent(req: RunRequest): Promise { ...(costUsd !== undefined ? { costUsd } : {}), numTurns, sdkSessionId, + ...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}), ...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }), }; } diff --git a/hub/src/agent/security.ts b/hub/src/agent/security.ts index f08500c..ec1968d 100644 --- a/hub/src/agent/security.ts +++ b/hub/src/agent/security.ts @@ -1,6 +1,7 @@ import { chmod, lstat, mkdir, realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; +import { validateCuratedSkillPlugin } from "./curatedSkills.js"; const PROVIDER_ENV_KEYS = new Set([ "ANTHROPIC_BASE_URL", @@ -60,6 +61,8 @@ export interface AgentSecurityPolicy { readonly cwd: string; readonly workspaceRoot: string; readonly env: Record; + readonly skillIds: readonly string[]; + readonly skillPluginRoot: string; readonly sandbox: AgentSandboxPolicy; } @@ -79,6 +82,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]); const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]); assertShortAgentTemp(agentTmp); + const skillPlugin = await validateCuratedSkillPlugin(); const path = hostEnv["PATH"]?.trim(); if (path === undefined || path === "") { @@ -119,6 +123,8 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom cwd: workspaceDir, workspaceRoot, env, + skillIds: skillPlugin.skillIds, + skillPluginRoot: skillPlugin.root, sandbox: { enabled: true, failIfUnavailable: true, @@ -134,7 +140,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom // workspace plus the named system runtime needed to execute tools. // SDK allowRead takes precedence over matching denyRead paths. denyRead: ["/"], - allowRead: [workspaceDir, ...runtimeReadPaths], + allowRead: [workspaceDir, skillPlugin.root, ...runtimeReadPaths], }, credentials: { files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })), diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index f35c462..ca990ec 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -37,6 +37,7 @@ import { createPermissionAuthorizer, type AuthorizationDecision, type Permission import { writeAudit } from "../audit.js"; import { formatRunCostLine } from "../agent/cost.js"; import { createAgentSdkStderrSink } from "../agent/diagnostics.js"; +import { CURATED_SKILL_IDS } from "../agent/curatedSkills.js"; import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js"; import { StreamingAgentCard } from "./card/streaming-card.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; @@ -405,6 +406,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { metadata: { roleId, model, + requestedSkills: [...CURATED_SKILL_IDS], prompt: agentPrompt.slice(0, 200), sender: senderMetadata, feishuTriggerContext, @@ -568,7 +570,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { runId: run.id, projectId, action: "run.finished", - metadata: { status: result.status, deliveredFiles }, + metadata: { + status: result.status, + deliveredFiles, + initializedSkills: [...(result.initializedSkillIds ?? [])], + }, }); await removeProcessingReaction(); }) diff --git a/hub/test/integration/agent-sandbox-linux.test.ts b/hub/test/integration/agent-sandbox-linux.test.ts index 6d355e8..95e83ab 100644 --- a/hub/test/integration/agent-sandbox-linux.test.ts +++ b/hub/test/integration/agent-sandbox-linux.test.ts @@ -62,6 +62,9 @@ describe("real Claude SDK sandbox boundary", () => { writeFile(join(sibling, "secret.txt"), "sibling-secret\n"), writeFile(serviceSecret, "platform-secret\n"), ]); + const untrustedSkill = join(workspace, ".claude", "skills", "untrusted"); + await mkdir(untrustedSkill, { recursive: true }); + await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n"); // macOS tmpdir is reached through /var -> /private/var. Exercise the // sandbox with canonical paths, matching the canonical cwd returned by // createAgentSecurityPolicy rather than relying on a host symlink alias. @@ -146,6 +149,11 @@ describe("real Claude SDK sandbox boundary", () => { [result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"), ).toBe("completed"); expect(stub.requestCount()).toBeGreaterThanOrEqual(3); + expect(new Set(result.initializedSkillIds)).toEqual(new Set([ + "cph-curated:outline", + "cph-curated:lesson-project", + "cph-curated:data-processing-spec", + ])); const toolResults = streamEvents.filter((event) => event.type === "tool-result"); expect(toolResults).toHaveLength(2); const rejectedOptOut = toolResults[0]; diff --git a/hub/test/unit/agent-security.test.ts b/hub/test/unit/agent-security.test.ts index e2facfd..96dd344 100644 --- a/hub/test/unit/agent-security.test.ts +++ b/hub/test/unit/agent-security.test.ts @@ -51,6 +51,14 @@ describe("agent subprocess security policy", () => { expect(policy.env.TEMP).toBe(policy.env.TMPDIR); expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t")); expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56); + expect(policy.skillIds).toEqual([ + "cph-curated:outline", + "cph-curated:lesson-project", + "cph-curated:data-processing-spec", + ]); + expect(policy.skillPluginRoot.startsWith(canonicalWorkspace)).toBe(false); + expect(policy.sandbox.filesystem.allowRead).toContain(policy.skillPluginRoot); + expect(policy.sandbox.filesystem.allowWrite).not.toContain(policy.skillPluginRoot); expect(policy.sandbox).toMatchObject({ enabled: true, diff --git a/hub/test/unit/curated-skills.test.ts b/hub/test/unit/curated-skills.test.ts new file mode 100644 index 0000000..7d5f9b4 --- /dev/null +++ b/hub/test/unit/curated-skills.test.ts @@ -0,0 +1,71 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CURATED_SKILL_IDS, + CURATED_SKILL_NAMES, + CURATED_SKILL_PLUGIN_NAME, + validateCuratedSkillPlugin, +} from "../../src/agent/curatedSkills.js"; + +describe("validateCuratedSkillPlugin", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + it("accepts exactly the release-owned plugin and returns qualified skill ids", async () => { + const root = await skillPluginFixture(); + + await expect(validateCuratedSkillPlugin(root)).resolves.toEqual({ + root, + skillIds: CURATED_SKILL_IDS, + }); + }); + + it("fails closed when a curated skill is absent from the release", async () => { + const root = await skillPluginFixture(); + await rm(join(root, "skills", "outline"), { recursive: true }); + + await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated plugin entry missing: skills\/outline/); + }); + + it("fails closed when a skill manifest name does not match the allowlist", async () => { + const root = await skillPluginFixture(); + await writeFile(join(root, "skills", "outline", "SKILL.md"), "---\nname: other\n---\n"); + + await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated skill manifest name mismatch/); + }); + + it("rejects an extra skill directory", async () => { + const root = await skillPluginFixture(); + await mkdir(join(root, "skills", "extra")); + + await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: skills\/extra/); + }); + + it("rejects plugin capabilities outside the reviewed skill catalog", async () => { + const root = await skillPluginFixture(); + await mkdir(join(root, "hooks")); + + await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: hooks/); + }); + + async function skillPluginFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "cph-skills-")); + roots.push(root); + await mkdir(join(root, ".claude-plugin"), { recursive: true }); + await writeFile( + join(root, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: CURATED_SKILL_PLUGIN_NAME }), + ); + for (const name of CURATED_SKILL_NAMES) { + const source = join(root, "skills", name); + await mkdir(source, { recursive: true }); + await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`); + } + return root; + } +}); diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index 1be3bb7..768367d 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -33,6 +33,16 @@ function resultMessage(sessionId: string, costUsd?: number) { }; } +function initMessage(skills: string[]) { + return { + type: "system", + subtype: "init", + skills, + tools: [], + plugins: [], + }; +} + function messages(...items: unknown[]) { return (async function* () { for (const item of items) yield item; @@ -102,6 +112,8 @@ describe("runAgent", () => { permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, settingSources: [], + plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })], + skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"], strictMcpConfig: true, sandbox: expect.objectContaining({ enabled: true, @@ -134,6 +146,26 @@ describe("runAgent", () => { expect(call?.options).not.toHaveProperty("resume"); }); + it("returns the skills actually reported by SDK initialization", async () => { + queryMock.mockReturnValue(messages( + initMessage(["cph-curated:outline"]), + assistantMessage("fresh"), + resultMessage("sdk-session-1"), + )); + + const result = await runAgent({ + prompt: "写一个大纲", + model: undefined, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, + systemPrompt: undefined, + runId: "run-1", + sessionId: "hub-session-1", + prisma: stubPrisma, + }); + + expect(result.initializedSkillIds).toEqual(["cph-curated:outline"]); + }); + it("maps role tool ids to the Claude SDK tool whitelist", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));