acmer-coach 2.3.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +2 -0
- package/README.md +58 -8
- package/assets/knowledge/COMPETENCIES.yaml +39 -4
- package/assets/knowledge/CONTEST_PROFILES.yaml +61 -0
- package/assets/knowledge/NODE_SCOPES.yaml +201 -0
- package/assets/rules/coach-rules.md +54 -17
- package/dev/catalog-check.mjs +74 -0
- package/dev/coaching-contract-check.mjs +219 -0
- package/dev/curriculum-check.mjs +194 -6
- package/dev/curriculum-evidence-check.mjs +9 -2
- package/dev/parity-check.mjs +23 -5
- package/dev/run-all.sh +9 -2
- package/dev/strategy-check.mjs +179 -11
- package/dev/teaching-check.mjs +191 -0
- package/dev/vp-client-check.mjs +12 -2
- package/dev/vp-sync-check.mjs +122 -0
- package/dev/workspace-migration-check.mjs +92 -20
- package/dev.sh +8 -3
- package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md +79 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +23 -6
- package/docs//351/225/277/346/234/237/350/257/276/347/250/213/346/234/272/345/210/266.md +33 -6
- package/index.js +143 -83
- package/lib/curriculum-evidence.js +16 -2
- package/lib/curriculum-planning.js +75 -6
- package/lib/curriculum.js +44 -16
- package/lib/strategy.js +162 -60
- package/lib/teaching.js +78 -0
- package/lib/vp-client.js +14 -2
- package/lib/workspace-migration.js +38 -0
- package/package.json +8 -2
- package/verify-shape.mjs +35 -5
- package/verify.mjs +35 -16
|
@@ -9,7 +9,22 @@
|
|
|
9
9
|
|
|
10
10
|
### 比赛目标先于知识图
|
|
11
11
|
|
|
12
|
-
*
|
|
12
|
+
* 按“长期能力目标 → 本次比赛目标 → 当前阶段成果 → 今天一个动作”解释取舍。
|
|
13
|
+
比赛目标不能悄悄覆盖长期目标。赛前在剩余实际容量内选择可验收的改善,赛后再按新
|
|
14
|
+
证据修订;日期只提供约束,不证明某专题该学。无法装进预算时说明暂缓什么与代价。
|
|
15
|
+
* `coach_target profiles` 提供有来源和样本限制的配置,再按需设置 `profileId`。
|
|
16
|
+
provisional 表示有限样本或尚待取样,不能称官方考纲、预测本届高频专题或保证奖牌。
|
|
17
|
+
不因某知识点“低频”就永久排除,不把同一道题的某一种解法当唯一算法标签。
|
|
18
|
+
* 原始 VP 判题、耗时和提交是观察;WA 不等于不会,AC 不证明独立完成。先用
|
|
19
|
+
`coach_curriculum assess` 取事实引用,再在 `coach_postmortem findings` 记录 hypothesis。
|
|
20
|
+
查代码/回放核实后才用 confirmed,引用本场真实事实并解释原因;resolved 需要独立成功
|
|
21
|
+
事实。旧文字复盘只是待核实观察。工具核对引用存在,不保证因果解释正确。
|
|
22
|
+
* `coach_focus` 只汇总近30天不同场次:成功起点不算问题,重复提交/复盘不增加场次数,
|
|
23
|
+
修改旧复盘不把它变成最近发生。分数是取舍参考,不是掌握度、出题概率或奖牌概率。
|
|
24
|
+
|
|
25
|
+
* 有比赛目标时,结合 `coach_target read` 和 `coach_focus` 的信息取舍;当前上下文已有
|
|
26
|
+
足够信息时不必重复查询,也不要求固定调用顺序。能力排名是参考,排在前三之外或尚未
|
|
27
|
+
映射到能力的节点可以训练,说明理由即可。没有目标时不能用
|
|
13
28
|
rating、游标或地图后继猜长期方向;先建立比赛、日期、结果目标、团队模式、时间预算
|
|
14
29
|
和能力优先级。
|
|
15
30
|
* `coach_focus` 给的是比赛能力重点,不是知识点清单。每次训练必须说明它服务哪个能力、
|
|
@@ -19,6 +34,9 @@
|
|
|
19
34
|
`coach_postmortem`。需要回放时先用 `coach_vp_contests` 按比赛名称选择,再调
|
|
20
35
|
`coach_vp_replay`;不要让学生提供 Contest ID。VP 的读题、耗时、提交、换题和团队问题优先于 rating;没有 VP 证据时要
|
|
21
36
|
明确说“尚无记录”,不能装作知道短板。手工导入仍可用 `coach_vp_import`,但不能和当前账号记录混用。
|
|
37
|
+
* 同步返回 `hasMore=true` 时继续拉页,`pageCounts` 是本页条数,数组是累计缓存;
|
|
38
|
+
不把累计条数说成新增。核对回放后可用事件级 `measurements` 记录有限数值指标,
|
|
39
|
+
显式填 `passed/independent`;不要从 AC 推断零漏读或沟通良好。显式导入更新整场,保留已有题目和注解。
|
|
22
40
|
* 讲知识点之前调 `coach_scope`。必须说明适用范围、延伸、反例、常见错误、迁移题和退出
|
|
23
41
|
条件;工具返回“没有范围卡片”时不许凭记忆补一段看似完整的范围。
|
|
24
42
|
* 训练重点是比赛能力,不是沿着地图无限往下走。阶段达到目标后可以停在当前节点,转做
|
|
@@ -28,11 +46,19 @@
|
|
|
28
46
|
|
|
29
47
|
* 长期训练先用 `coach_curriculum read` 查看路线,再用 `assess` 查实际编排依据。
|
|
30
48
|
`assess` 从 checks、代码判因、passes 和课表实际完成中给出 `id/eventId`;记录多时
|
|
31
|
-
按 `evidenceNodes` 缩小范围。**rating、节点状态、取消的卷子不能代替短板证据。**
|
|
49
|
+
按 `evidenceNodes` 或 `evidenceEventIds` 缩小范围。**rating、节点状态、取消的卷子不能代替短板证据。**
|
|
32
50
|
* 建立或修订路线前核实目标、起点与可用时间。每个阶段除能力成果、节点和验收条件外,
|
|
33
51
|
必须填写 `basis`、`goalContribution`、`priorityReason`、`estimatedHours`、
|
|
34
52
|
`evidenceRefs`、`finding`、`kind`、`uncertainty`、`diagnosticMethod`。
|
|
35
53
|
说清观察到了什么、它怎样服务目标、为何排在这里、预计投入多少,不套统一算法清单。
|
|
54
|
+
* training 阶段还必须填 `exitChecks`,覆盖每个节点或能力:明确 `minEvents`,按需
|
|
55
|
+
指定 `minDistinctDays`、`independent`、`maxMinutes`,或有真实测量的 `metric/maxValue`。
|
|
56
|
+
不用一句“两场VP表现好”代替可检查条件;看 read 返回的 `exitCheckResults` 逐条解释缺口。
|
|
57
|
+
缺字段不当通过,同一事件不重复计数。旧课程先 revise 补齐,不覆盖旧训练账本。
|
|
58
|
+
* verified 只证明记录日期与检测条件下的表现。隔天迁移、混合题识别和重新检测用于核对
|
|
59
|
+
保持与迁移,不能把历史一次过卷解释成永久掌握;先给未标专题的题,再看能否自选模型。
|
|
60
|
+
* 讲节点优先使用 `coach_scope` 精确节点卡,按“识别条件 → 为什么成立 → 失效边界 →
|
|
61
|
+
陌生题迁移”展开。结合 recognition/proofQuestions 和原始资料,不只给算法名与模板。
|
|
36
62
|
* `kind=training` 的每个节点都要对应真实证据:补短板用 `finding=gap`,引用 gap;读题、决策、团队沟通这类没有单一地图节点的阶段改用 `competencies`,引用 VP/复盘产生的能力证据;
|
|
37
63
|
在已证实能力上进阶用 `finding=extension`,引用 baseline。记录指向什么由你解读,
|
|
38
64
|
但**不能写一段理由替代不存在的记录**,也不能把别的节点的失败贴来凑依据。
|
|
@@ -58,8 +84,8 @@
|
|
|
58
84
|
动作或检测卷未结束时工具会拒绝。机械门槛通过也不等于能力自动达标;
|
|
59
85
|
**刷够题数、讲完内容、到了周末都不能替代能力验收。** 优先级理由仍需教学判断,
|
|
60
86
|
程序没有证明这条路线教育上最优。
|
|
61
|
-
* 旧 v1 路线可查看,但 `needsReview` 时不能继续执行;先 assess,再 revise 补齐当前
|
|
62
|
-
|
|
87
|
+
* 旧 v1/v2 路线可查看,但 `needsReview` 时不能继续执行;先 assess,再 revise 补齐当前
|
|
88
|
+
及后续阶段的依据和 exitChecks,明确迁移 v3。已完成阶段保留,不能自动编造依据或改写旧历史。
|
|
63
89
|
* `coach_plan` 仍只排近期时间块,`coach_curriculum` 保存课程顺序与阶段目标,
|
|
64
90
|
`PROGRESS.yaml` 保存实际学习/检测记录,不复制另一套掌握状态。
|
|
65
91
|
|
|
@@ -68,9 +94,10 @@
|
|
|
68
94
|
|
|
69
95
|
下面九条是硬规则,每条都能机械检查。违反就是没做好本职工作:
|
|
70
96
|
|
|
71
|
-
1.
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
1. **给训练指令前,核对目标、当前重点和地图前置**。
|
|
98
|
+
用 `coach_target`、`coach_focus`、`coach_next` 按需补足信息;上下文已有足够依据时
|
|
99
|
+
不重复读取,不规定调用顺序。不许只凭 rating 或地图后继挑知识点,
|
|
100
|
+
节点必须真实存在,前置必须满足。
|
|
74
101
|
2. **每条指令必须带地图节点 id**。节点 id 只能从地图取到,
|
|
75
102
|
所以这条会自动逼你先读地图。
|
|
76
103
|
3. **一次只给一个动作。** 禁止候选列表、禁止「你可以考虑…」、
|
|
@@ -162,16 +189,18 @@
|
|
|
162
189
|
|
|
163
190
|
### 复习别把新知识挤没
|
|
164
191
|
|
|
165
|
-
|
|
166
|
-
|
|
192
|
+
训练要改善目标比赛中的表现。新知识、复习和迁移都可能带来改善,
|
|
193
|
+
安排哪一个要看目标、已有表现和投入收益。
|
|
167
194
|
|
|
168
195
|
**但他自己会想先复习** —— 复习舒服(做过的东西有把握)、
|
|
169
196
|
开新知识点难受(进入状态有成本)。所以这个平衡**得你把**,
|
|
170
197
|
不能等他主动说"我想学新的":
|
|
171
198
|
|
|
172
199
|
* 排课时,复习块**按火候排**(放得最久的那几个才排),不是按"他提了哪个"排;
|
|
173
|
-
*
|
|
174
|
-
|
|
200
|
+
* **推进不等于开新知识点**:按目标与实际表现选择新课、复习、迁移或 VP,
|
|
201
|
+
不为填课表硬开新点,也不让熟悉题反复练习代替真实改善;
|
|
202
|
+
* **放了一两个月的知识点先核对保持情况**,再决定检测、复习或重讲,
|
|
203
|
+
不把间隔长直接当作已经忘光。
|
|
175
204
|
* **提法按规则 3:一次一个动作** ——「这块我建议验一下,90 分钟三题,做不做?」
|
|
176
205
|
并且**说清你为什么觉得该验了**(拿上面的记录当证据)。
|
|
177
206
|
他说做,你才走下面三步。
|
|
@@ -315,9 +344,16 @@
|
|
|
315
344
|
他说会了就别再给。
|
|
316
345
|
* **注释密度跟他的样例走** —— 样例里注释少就少,别逐行注释。
|
|
317
346
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
347
|
+
**新课动作要留出教学时间**:用 `coach_assign` 布置时带 `teachMinutes`,
|
|
348
|
+
它是计划时长,不证明已经讲过,也不证明学生理解。实际开始学习后可用 `coach_mark`
|
|
349
|
+
记为 studying;是否学会仍需学生表现和检测。讲多久由你根据需要判断。
|
|
350
|
+
|
|
351
|
+
**续课只留必要笔记**:发生值得接着讲的进展或中断时,用 `coach_note write` 保存
|
|
352
|
+
当前判断、尚未确认之处和下一次接续点,不记录整段聊天、不每次回复都写。
|
|
353
|
+
`coach_status` 和新会话状态会带上最近笔记,需要细节再用 `coach_note read`。
|
|
354
|
+
引用学生回应可填 `studentQuote`;工具只核对当前会话里真实学生原话,身份和位置自动记录,
|
|
355
|
+
不要让学生提供消息 ID。核对成功只证明原话存在,不证明理解正确。没有原话依据的笔记
|
|
356
|
+
仍可保存,但必须当作教练判断;笔记不改游标、进度、待办或检测结果,不替代掌握证据。
|
|
321
357
|
|
|
322
358
|
⚠️ **例外:游标自己那个节点不算"新知识点"** —— 接着做,不逼你再讲一遍。
|
|
323
359
|
**但例外有条件:这个节点得真讲过**(有教学痕迹)。游标可以被挪到一个
|
|
@@ -433,7 +469,8 @@
|
|
|
433
469
|
|
|
434
470
|
## 训练数据操作纪律(P0)
|
|
435
471
|
|
|
436
|
-
*
|
|
472
|
+
* 已授权课程内的事实记录、续课笔记和做题用时正常保存,不重复要求确认。
|
|
473
|
+
替换或删除个人数据、大幅改变目标或课程时,先说明将改什么,等确认后执行。
|
|
437
474
|
* 禁止擅自修改他人的训练数据。
|
|
438
475
|
* 挑题之前先查 AC 记录,不重复喂已经会的题。
|
|
439
476
|
|
|
@@ -482,8 +519,8 @@
|
|
|
482
519
|
(一个写、一个验、一个造 hack 数据)。
|
|
483
520
|
末尾并行死磕多道难题是 ICPC 最常见的败因 —— 风险是最后一道都做不出。
|
|
484
521
|
* 每场记一句:**题目是不是全都被读过**(读漏题比做不出更亏)。
|
|
485
|
-
*
|
|
486
|
-
|
|
522
|
+
* **临近比赛通常优先巩固与模拟**:按剩余时间、掌握基础和预期收益取舍;
|
|
523
|
+
必要的新内容可安排,说明收益、风险和验收方式,不按固定两周一律禁止。
|
|
487
524
|
|
|
488
525
|
## 紧急停止(最高优先级)
|
|
489
526
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import YAML from 'yaml'
|
|
5
|
+
|
|
6
|
+
const knowledge = new URL('../assets/knowledge/', import.meta.url)
|
|
7
|
+
const read = name => YAML.parse(readFileSync(new URL(name, knowledge), 'utf8'))
|
|
8
|
+
const mapIds = new Set(read('MAP.yaml').nodes.map(node => node.id))
|
|
9
|
+
const competencies = read('COMPETENCIES.yaml').competencies
|
|
10
|
+
|
|
11
|
+
function sources(value, label) {
|
|
12
|
+
assert.ok(Array.isArray(value) && value.length > 0, `${label}: sources required`)
|
|
13
|
+
for (const source of value) {
|
|
14
|
+
assert.ok(typeof source.title === 'string' && source.title.trim(), `${label}: source title required`)
|
|
15
|
+
const url = new URL(source.url)
|
|
16
|
+
assert.equal(url.protocol, 'https:', `${label}: public HTTPS source required`)
|
|
17
|
+
assert.ok(!url.username && !url.password && !url.search, `${label}: no account credentials or query data`)
|
|
18
|
+
assert.ok(url.pathname !== '/', `${label}: link to evidence, not a homepage`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function textList(value, label) {
|
|
23
|
+
assert.ok(Array.isArray(value) && value.length > 0, `${label}: nonempty list required`)
|
|
24
|
+
assert.ok(value.every(item => typeof item === 'string' && item.trim()), `${label}: text required`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('node teaching cards use exact map nodes and contain sourced transfer boundaries', () => {
|
|
28
|
+
const catalog = read('NODE_SCOPES.yaml')
|
|
29
|
+
assert.equal(catalog.version, 1)
|
|
30
|
+
assert.ok(catalog.nodes.length >= 12)
|
|
31
|
+
assert.equal(new Set(catalog.nodes.map(card => card.id)).size, catalog.nodes.length)
|
|
32
|
+
for (const card of catalog.nodes) {
|
|
33
|
+
assert.ok(mapIds.has(card.id), `${card.id}: unknown MAP node`)
|
|
34
|
+
assert.ok(card.title && card.contestUse)
|
|
35
|
+
for (const field of ['recognition', 'proofQuestions', 'appliesTo', 'extensions', 'notFor', 'commonFailures', 'transferProblems', 'exitEvidence']) {
|
|
36
|
+
textList(card[field], `${card.id}.${field}`)
|
|
37
|
+
}
|
|
38
|
+
sources(card.sources, card.id)
|
|
39
|
+
}
|
|
40
|
+
for (const id of ['最短路', '强连通分量', '线段树', '动态规划基础']) {
|
|
41
|
+
assert.ok(catalog.nodes.some(card => card.id === id), `${id}: important boundary card required`)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('contest profiles identify sampling limits and use only existing competencies', () => {
|
|
46
|
+
const catalog = read('CONTEST_PROFILES.yaml')
|
|
47
|
+
const competencyIds = new Set(competencies.map(card => card.id))
|
|
48
|
+
assert.equal(catalog.version, 1)
|
|
49
|
+
assert.ok(catalog.profiles.length >= 3)
|
|
50
|
+
assert.equal(new Set(catalog.profiles.map(profile => profile.id)).size, catalog.profiles.length)
|
|
51
|
+
for (const profile of catalog.profiles) {
|
|
52
|
+
assert.ok(profile.title && profile.scope)
|
|
53
|
+
assert.ok(['provisional', 'reviewed'].includes(profile.status))
|
|
54
|
+
textList(profile.selectionGuidance, `${profile.id}.selectionGuidance`)
|
|
55
|
+
textList(profile.limitations, `${profile.id}.limitations`)
|
|
56
|
+
assert.ok(profile.competencyIds.length > 0)
|
|
57
|
+
for (const id of profile.competencyIds) assert.ok(competencyIds.has(id), `${profile.id}: unknown ${id}`)
|
|
58
|
+
sources(profile.sources, profile.id)
|
|
59
|
+
assert.match(profile.limitations.join(' '), /样本|采样|未核验/)
|
|
60
|
+
assert.doesNotMatch(JSON.stringify(profile), /金牌.*(?:必须|至少)\s*\d+\s*题|必考|命中率/)
|
|
61
|
+
}
|
|
62
|
+
const xian = catalog.profiles.find(profile => profile.id === 'xian-pending')
|
|
63
|
+
assert.ok(xian)
|
|
64
|
+
assert.equal(xian.status, 'provisional')
|
|
65
|
+
assert.match(xian.limitations.join(' '), /不是.*考纲|不能.*预测/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('general competency mappings reference real nodes and disclose their sources', () => {
|
|
69
|
+
assert.equal(new Set(competencies.map(card => card.id)).size, competencies.length)
|
|
70
|
+
for (const card of competencies) {
|
|
71
|
+
for (const id of card.nodes) assert.ok(mapIds.has(id), `${card.id}: unknown MAP node ${id}`)
|
|
72
|
+
sources(card.sources, card.id)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
function shape(schema, value) {
|
|
8
|
+
if (schema.type === 'object') {
|
|
9
|
+
assert.ok(value && typeof value === 'object' && !Array.isArray(value))
|
|
10
|
+
if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected field ${key}`)
|
|
11
|
+
for (const [key, field] of Object.entries(schema.properties ?? {})) {
|
|
12
|
+
if (field.required === true || (Array.isArray(schema.required) && schema.required.includes(key))) assert.ok(key in value, `missing field ${key}`)
|
|
13
|
+
if (key in value) shape(field, value[key])
|
|
14
|
+
}
|
|
15
|
+
} else if (schema.type === 'array') {
|
|
16
|
+
assert.ok(Array.isArray(value))
|
|
17
|
+
if (schema.items) value.forEach(item => shape(schema.items, item))
|
|
18
|
+
} else if (schema.type === 'integer') assert.ok(Number.isInteger(value))
|
|
19
|
+
else assert.equal(typeof value, schema.type)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('registered coaching tools expose cited findings, sourced teaching and complete refusal shapes', async () => {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), 'coach-contract-'))
|
|
24
|
+
const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes: []\n')
|
|
27
|
+
process.env.COACH_DATA_DIR = dir
|
|
28
|
+
process.env.COACH_DB = join(dir, 'unused.db')
|
|
29
|
+
const { apply } = await import('../index.js?coaching-contract')
|
|
30
|
+
const tools = new Map()
|
|
31
|
+
apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on() {} })
|
|
32
|
+
const call = async (name, args) => {
|
|
33
|
+
const tool = tools.get(name)
|
|
34
|
+
const result = await tool.execute(args)
|
|
35
|
+
shape(tool.output.schema, result)
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
assert.ok(tools.get('coach_postmortem').parameters.properties.findings)
|
|
39
|
+
assert.ok(tools.get('coach_focus').output.schema.properties.profile.properties?.id, 'SDK needs explicit nested profile fields')
|
|
40
|
+
assert.ok(tools.get('coach_vp_import').parameters.properties.events.items.properties.measurements)
|
|
41
|
+
assert.equal((await call('coach_grade', { results: [] })).ok, false, 'an absent paper has a complete refusal shape')
|
|
42
|
+
assert.ok((await call('coach_target', { action: 'profiles' })).availableProfiles.length)
|
|
43
|
+
const missing = await call('coach_postmortem', { eventId: 'unknown', findings: [] })
|
|
44
|
+
assert.equal(missing.ok, false)
|
|
45
|
+
const target = await call('coach_target', { action: 'set', expectedRevision: 0, contest: '样本训练', date: '2026-10-17', result: '稳定发挥', weeklyHours: 25,
|
|
46
|
+
priorities: [{ id: 'graph-modeling', weight: 10 }] })
|
|
47
|
+
assert.equal(target.ok, true, target.reject)
|
|
48
|
+
await call('coach_vp_import', { events: [{ eventId: 'vp-contract', contest: 'VP', date: '2026-10-10', problems: [{ problemId: 'A', status: 'WA' }] }] })
|
|
49
|
+
const facts = await call('coach_curriculum', { action: 'assess' })
|
|
50
|
+
const fact = facts.evidence.find(entry => entry.source === 'vp')
|
|
51
|
+
assert.ok(fact)
|
|
52
|
+
const saved = await call('coach_postmortem', { eventId: 'vp-contract', findings: [{ competency: 'graph-modeling', status: 'confirmed',
|
|
53
|
+
cause: '对照题面和代码确认建图方向写反了', evidenceRefs: [fact.id] }] })
|
|
54
|
+
assert.equal(saved.ok, true, saved.reject)
|
|
55
|
+
const focus = await call('coach_focus', {})
|
|
56
|
+
assert.equal(focus.focus[0].gapCount, 1)
|
|
57
|
+
assert.match(focus.text, /已确认|确认短板/)
|
|
58
|
+
const measurementImport = await call('coach_vp_import', { events: [{ eventId: 'measured-contract', contest: 'VP', date: '2026-10-10',
|
|
59
|
+
problems: [{ problemId: 'A', status: 'AC' }], measurements: [{ competency: 'contest-reading', metric: 'missed-reading', value: 0, passed: true, independent: true }] }] })
|
|
60
|
+
assert.equal(measurementImport.ok, true, measurementImport.reject)
|
|
61
|
+
const measured = await call('coach_curriculum', { action: 'assess', evidenceEventIds: ['measured-contract'] })
|
|
62
|
+
assert.equal(measured.evidence.find(entry => entry.metric === 'missed-reading')?.value, 0)
|
|
63
|
+
const gap = (await call('coach_curriculum', { action: 'assess', evidenceEventIds: ['vp-contract'] })).evidence.find(entry => entry.signal === 'gap')
|
|
64
|
+
const created = await call('coach_curriculum', { action: 'create', expectedRevision: 0, goal: '区域赛稳定建图', reason: '针对已经确认的建图错误,用两场零错误指标验收', phases: [{
|
|
65
|
+
id: 'graph-measured', title: '稳定建图', kind: 'training', finding: 'gap', nodes: [], competencies: ['graph-modeling'],
|
|
66
|
+
outcome: '两场不同的VP都能独立正确建模', exitCriteria: '两场独立验收无建模错误',
|
|
67
|
+
basis: '对照题面和代码已确认建图方向错误', goalContribution: '减少区域赛建模题错误提交', priorityReason: '现有事实显示建模方向错误需要先修', estimatedHours: 5,
|
|
68
|
+
evidenceRefs: [gap.id], uncertainty: '', diagnosticMethod: '', exitChecks: [{ competency: 'graph-modeling', minEvents: 2, independent: true, metric: 'model-errors', maxValue: 0 }],
|
|
69
|
+
}] })
|
|
70
|
+
assert.equal(created.ok, true, created.reject)
|
|
71
|
+
const now = new Date()
|
|
72
|
+
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
73
|
+
for (let i = 0; i < 2; i++) {
|
|
74
|
+
assert.equal((await call('coach_vp_import', { events: [{ eventId: `metric-exit-${i}`, contest: 'VP', date, problems: [{ problemId: 'B', status: 'AC' }],
|
|
75
|
+
measurements: [{ competency: 'graph-modeling', metric: 'model-errors', value: 0, passed: true, independent: true, occurredAt: new Date().toISOString() }] }] })).ok, true)
|
|
76
|
+
const state = await call('coach_curriculum', { action: 'read' })
|
|
77
|
+
assert.equal(state.exitCheckResults[0].matchedEvents, i + 1)
|
|
78
|
+
if (i === 0) assert.equal((await call('coach_curriculum', { action: 'advance', expectedRevision: state.revision, reason: '只有一场,不能通过两场的验收条件' })).ok, false)
|
|
79
|
+
}
|
|
80
|
+
const finished = await call('coach_curriculum', { action: 'advance', expectedRevision: created.revision, reason: '两场不同的新VP都有独立且已验收的零建模错误测量' })
|
|
81
|
+
assert.equal(finished.status, 'complete', finished.reject)
|
|
82
|
+
const scope = await call('coach_scope', { id: '最短路' })
|
|
83
|
+
assert.equal(scope.id, '最短路')
|
|
84
|
+
assert.ok(scope.sources.length)
|
|
85
|
+
assert.ok(scope.recognition.length)
|
|
86
|
+
await call('coach_scope', { id: '不存在的卡片' })
|
|
87
|
+
} finally {
|
|
88
|
+
for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
|
|
89
|
+
if (value === undefined) delete process.env[key]
|
|
90
|
+
else process.env[key] = value
|
|
91
|
+
}
|
|
92
|
+
rmSync(dir, { recursive: true, force: true })
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('a planned lecture is not reported as past teaching', async () => {
|
|
97
|
+
const dir = mkdtempSync(join(tmpdir(), 'coach-lecture-plan-'))
|
|
98
|
+
const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes:\n - {id: A, name: 基础, depends: []}\n - {id: B, name: 新课, depends: [A]}\n')
|
|
101
|
+
writeFileSync(join(dir, 'PROGRESS.yaml'), 'version: 1\ncursor: A\nnodes:\n A: {status: learned}\n')
|
|
102
|
+
process.env.COACH_DATA_DIR = dir
|
|
103
|
+
process.env.COACH_DB = join(dir, 'unused.db')
|
|
104
|
+
const { apply } = await import('../index.js?lecture-plan-contract')
|
|
105
|
+
const tools = new Map()
|
|
106
|
+
apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on() {} })
|
|
107
|
+
const assigned = await tools.get('coach_assign').execute({ node: 'B', deliverable: '独立写出状态与转移', teachMinutes: 20, why: '先解释状态为何保留这些信息' })
|
|
108
|
+
assert.equal(assigned.accepted, true, assigned.reject)
|
|
109
|
+
assert.equal((await tools.get('coach_set_cursor').execute({ node: 'B' })).ok, true)
|
|
110
|
+
const status = await tools.get('coach_status').execute({})
|
|
111
|
+
assert.equal(status.cursorTaught, '', 'planning teaching must not create a teaching date')
|
|
112
|
+
assert.equal(status.pendingActions.find(item => item.node === 'B').teachMinutes, 20)
|
|
113
|
+
} finally {
|
|
114
|
+
for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
|
|
115
|
+
if (value === undefined) delete process.env[key]
|
|
116
|
+
else process.env[key] = value
|
|
117
|
+
}
|
|
118
|
+
rmSync(dir, { recursive: true, force: true })
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('teaching notes survive reload, reach the native hook and never certify mastery', async () => {
|
|
123
|
+
const dir = mkdtempSync(join(tmpdir(), 'coach-note-contract-'))
|
|
124
|
+
const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes:\n - {id: A, name: 基础, depends: []}\n')
|
|
127
|
+
writeFileSync(join(dir, 'PROGRESS.yaml'), 'version: 1\ncursor: A\nnodes: {}\n')
|
|
128
|
+
process.env.COACH_DATA_DIR = dir
|
|
129
|
+
process.env.COACH_DB = join(dir, 'unused.db')
|
|
130
|
+
const quote = '这里的状态只需要保留容量,不需要保留选取顺序。'
|
|
131
|
+
const query = { readSession: async (id) => {
|
|
132
|
+
assert.equal(id, 'session-1')
|
|
133
|
+
return { session: { id }, inheritedEventCount: 0, events: [{ type: 'user/message', seq: 7, data: {
|
|
134
|
+
id: 'message-7', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: quote }],
|
|
135
|
+
} }] }
|
|
136
|
+
} }
|
|
137
|
+
const register = async (suffix) => {
|
|
138
|
+
const tools = new Map(), hooks = new Map()
|
|
139
|
+
const { apply } = await import(`../index.js?note-contract-${suffix}`)
|
|
140
|
+
apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {},
|
|
141
|
+
get: service => service === 'sessionQuery' ? query : undefined,
|
|
142
|
+
on: (event, handler) => hooks.set(event, handler) })
|
|
143
|
+
return { tools, hooks }
|
|
144
|
+
}
|
|
145
|
+
const first = await register('first')
|
|
146
|
+
const note = first.tools.get('coach_note')
|
|
147
|
+
assert.ok(note, 'one native tool must expose teaching continuation')
|
|
148
|
+
const exec = { agent: { session: { id: 'session-1' } } }
|
|
149
|
+
const plain = await note.execute({ action: 'write', summary: '准备续讲状态含义;尚未核对学生回应', nextStep: '先查看已有的课堂上下文' })
|
|
150
|
+
shape(note.output.schema, plain)
|
|
151
|
+
assert.equal(plain.ok, true, plain.reject)
|
|
152
|
+
assert.ok(!('studentEvidence' in plain.notes[0]), 'unquoted notes do not invent student evidence')
|
|
153
|
+
const saved = await note.execute({ action: 'write', summary: '学生能解释容量维,但迁移能力尚未核对', nextStep: '用另一个背包变式确认状态含义', studentQuote: quote }, exec)
|
|
154
|
+
shape(note.output.schema, saved)
|
|
155
|
+
assert.equal(saved.ok, true, saved.reject)
|
|
156
|
+
assert.equal(saved.notes.at(-1).studentEvidence.messageId, 'message-7')
|
|
157
|
+
const bad = await note.execute({ action: 'write', summary: '不存在的回应', nextStep: '继续核对', studentQuote: '这句话不在学生的消息中' }, exec)
|
|
158
|
+
shape(note.output.schema, bad)
|
|
159
|
+
assert.equal(bad.ok, false)
|
|
160
|
+
const second = await register('reload')
|
|
161
|
+
const read = await second.tools.get('coach_note').execute({ action: 'read' })
|
|
162
|
+
shape(second.tools.get('coach_note').output.schema, read)
|
|
163
|
+
assert.deepEqual(read.notes, saved.notes)
|
|
164
|
+
assert.equal(read.total, 2)
|
|
165
|
+
const status = await second.tools.get('coach_status').execute({})
|
|
166
|
+
shape(second.tools.get('coach_status').output.schema, status)
|
|
167
|
+
assert.equal(status.verifiedCount, 0)
|
|
168
|
+
assert.equal(status.learnedCount, 0)
|
|
169
|
+
assert.equal(status.cursorTaught, '', 'even a real student quote cannot become a teaching/contact certificate')
|
|
170
|
+
assert.match(status.teachingContext, /背包变式/)
|
|
171
|
+
const hook = second.hooks.get('agent/pre-step')
|
|
172
|
+
const decision = { kind: 'enter', messages: [] }
|
|
173
|
+
const injected = await hook({ step: 1, messages: [] }, async () => decision)
|
|
174
|
+
const message = injected.messages.at(-1)
|
|
175
|
+
assert.equal(message.source.kind, 'plugin:acmer-coach', 'format v4 requires producer-owned source kind')
|
|
176
|
+
assert.ok(message.content.some(block => block.type === 'text' && block.text.includes('背包变式')))
|
|
177
|
+
assert.deepEqual(await hook({ step: 2, messages: [] }, async () => decision), decision)
|
|
178
|
+
} finally {
|
|
179
|
+
for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
|
|
180
|
+
if (value === undefined) delete process.env[key]
|
|
181
|
+
else process.env[key] = value
|
|
182
|
+
}
|
|
183
|
+
rmSync(dir, { recursive: true, force: true })
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('continuation follows a noted unfinished lesson while the cursor stays on its prerequisite', async () => {
|
|
188
|
+
const dir = mkdtempSync(join(tmpdir(), 'coach-note-pending-'))
|
|
189
|
+
const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
|
|
190
|
+
try {
|
|
191
|
+
writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes:\n - {id: A, name: 基础, depends: []}\n - {id: B, name: 新课, depends: [A]}\n')
|
|
192
|
+
writeFileSync(join(dir, 'PROGRESS.yaml'), 'version: 1\ncursor: A\nnodes:\n A: {status: learned}\n')
|
|
193
|
+
process.env.COACH_DATA_DIR = dir
|
|
194
|
+
process.env.COACH_DB = join(dir, 'unused.db')
|
|
195
|
+
const tools = new Map(), hooks = new Map()
|
|
196
|
+
const { apply } = await import('../index.js?note-pending-contract')
|
|
197
|
+
apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on: (event, handler) => hooks.set(event, handler) })
|
|
198
|
+
const assigned = await tools.get('coach_assign').execute({ node: 'B', deliverable: '解释状态含义', teachMinutes: 20, why: '沿着已学前置开始新课' })
|
|
199
|
+
assert.equal(assigned.accepted, true, assigned.reject)
|
|
200
|
+
assert.equal((await tools.get('coach_note').execute({ action: 'write', node: 'B', summary: '状态含义已解释,尚未检查学生回应', nextStep: '继续核对容量维度' })).ok, true)
|
|
201
|
+
const status = await tools.get('coach_status').execute({})
|
|
202
|
+
assert.equal(status.cursor, 'A', 'showing continuity must not move the cursor')
|
|
203
|
+
assert.match(status.teachingContext, /容量维度/)
|
|
204
|
+
const latest = await tools.get('coach_note').execute({ action: 'read' })
|
|
205
|
+
assert.equal(latest.node, 'B')
|
|
206
|
+
assert.equal(latest.total, 1)
|
|
207
|
+
const explicit = await tools.get('coach_note').execute({ action: 'read', node: 'A' })
|
|
208
|
+
assert.equal(explicit.node, 'A', 'explicit node selection overrides the context fallback')
|
|
209
|
+
assert.equal(explicit.total, 0)
|
|
210
|
+
const injected = await hooks.get('agent/pre-step')({ step: 1, messages: [] }, async () => ({ kind: 'enter', messages: [] }))
|
|
211
|
+
assert.ok(injected.messages.at(-1).content.some(block => block.type === 'text' && block.text.includes('容量维度')))
|
|
212
|
+
} finally {
|
|
213
|
+
for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
|
|
214
|
+
if (value === undefined) delete process.env[key]
|
|
215
|
+
else process.env[key] = value
|
|
216
|
+
}
|
|
217
|
+
rmSync(dir, { recursive: true, force: true })
|
|
218
|
+
}
|
|
219
|
+
})
|