acmer-coach 2.4.0 → 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/README.md +26 -3
- package/assets/rules/coach-rules.md +27 -14
- package/dev/coaching-contract-check.mjs +127 -1
- package/dev/curriculum-check.mjs +1 -1
- package/dev/parity-check.mjs +16 -4
- package/dev/run-all.sh +4 -2
- package/dev/strategy-check.mjs +41 -7
- package/dev/teaching-check.mjs +191 -0
- package/dev/vp-sync-check.mjs +1 -1
- package/dev.sh +2 -2
- package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md +31 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +6 -5
- package/index.js +62 -33
- package/lib/strategy.js +4 -10
- package/lib/teaching.js +78 -0
- package/package.json +3 -3
- package/verify-shape.mjs +35 -5
- package/verify.mjs +35 -16
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
## 它到底做了什么
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
31 个工具,三条轴:知识图、比赛能力、训练证据。
|
|
24
24
|
|
|
25
25
|
| 轴 | 含义 |
|
|
26
26
|
|---|---|
|
|
@@ -49,6 +49,7 @@ coach_schedule → coach_plan 排课
|
|
|
49
49
|
coach_target → coach_vp_import → coach_postmortem → coach_focus
|
|
50
50
|
↓ 比赛目标、VP 复盘、当前重点
|
|
51
51
|
coach_scope 适用范围、延伸、反例、退出条件
|
|
52
|
+
coach_note 续课笔记:教到哪里、尚待确认什么、下次接哪里
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
`coach_wiki` 是知识来源:讲任何知识点之前先查本地 OI Wiki,**别凭记忆讲**。
|
|
@@ -72,6 +73,9 @@ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`dat
|
|
|
72
73
|
`coach_scope` 会返回一个能力或知识点的适用范围、延伸、反例、常见错误、迁移题
|
|
73
74
|
和退出条件。没有人工范围卡片时它会明确拒绝编造,避免把“讲过”误当成“会用”。
|
|
74
75
|
|
|
76
|
+
能力排名只是取舍参考。排在前三之外、尚未映射到目标能力的节点也能布置,由小鲸解释
|
|
77
|
+
为什么现在练;课程版本、当前阶段和真实前置的检查继续保留。
|
|
78
|
+
|
|
75
79
|
### 长期学习路线
|
|
76
80
|
|
|
77
81
|
`coach_curriculum assess` 从已有检测、代码判因、做题记录和日程实际完成中列出
|
|
@@ -117,6 +121,24 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
|
|
|
117
121
|
|
|
118
122
|
详见 [长期课程机制](docs/长期课程机制.md)。
|
|
119
123
|
|
|
124
|
+
### 2.5:接着教,判断仍归小鲸
|
|
125
|
+
|
|
126
|
+
`coach_note` 只做一件事:把一个知识点的教学判断和接续点留在现有
|
|
127
|
+
`PROGRESS.yaml` 的 `nodes.<id>.lessonNotes`。`read` 查看最近笔记,`write` 保存
|
|
128
|
+
`summary` 与 `nextStep`;不新增课程状态机、聊天数据库或另一份掌握记录。
|
|
129
|
+
`coach_status` 的 `teachingContext` 和每轮首步状态会带上短笔记,需要原话时再查原生会话。
|
|
130
|
+
未指定节点时,读取优先显示最近有笔记的未完动作,再看游标;没有游标则看最近笔记。
|
|
131
|
+
写入默认使用游标,显式 `node` 优先,不会为了写笔记移动游标。
|
|
132
|
+
|
|
133
|
+
`studentQuote` 可选。填写时核对当前 dsh 会话中的真实学生消息,自动记录来源;
|
|
134
|
+
插件消息、压缩摘要和其他会话不能冒充学生回答。核对原话存在不等于判断学生理解,
|
|
135
|
+
没有原话的笔记也可保存,但标作教练判断。笔记不会改进度状态、游标、待办或检测结果。
|
|
136
|
+
旧宿主没有原生会话查询时,普通笔记可用,带原话的写入会明确拒绝。
|
|
137
|
+
|
|
138
|
+
`teachMinutes` 现在只保存讲授计划,不再自动写“已经教过”的日期。实际进入学习由
|
|
139
|
+
教练显式 `coach_mark studying` 记录;已有接触记录继续可用,但不证明理解。
|
|
140
|
+
这版验证的是记录、来源和续课机制;真实小鲸对话的教学效果仍需观察。
|
|
141
|
+
|
|
120
142
|
### 2.4:证据、验收与教学依据
|
|
121
143
|
|
|
122
144
|
`coach_focus` 分开近30天的独立成功、待确认观察、已确认短板,按不同场次计数。
|
|
@@ -250,11 +272,12 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
|
|
|
250
272
|
|
|
251
273
|
| 文件 | 作用 |
|
|
252
274
|
|---|---|
|
|
253
|
-
| `index.js` | 插件本体(host 端,
|
|
275
|
+
| `index.js` | 插件本体(host 端,31 个工具 + 规则注入) |
|
|
254
276
|
| `lib/setup.js` | 环境搭建 + CF 公开数据同步 |
|
|
255
277
|
| `lib/curriculum.js` | 持久课程路线、阶段验收门槛、版本与修订历史 |
|
|
256
278
|
| `lib/curriculum-evidence.js` | 从实际记录派生证据引用与事件身份,去重课表和训练记录 |
|
|
257
279
|
| `lib/curriculum-planning.js` | 阶段编排依据、前置顺序与进入阶段后的新证据检查 |
|
|
280
|
+
| `lib/teaching.js` | 节点续课笔记,复用进度文件与 dsh 原生会话查询 |
|
|
258
281
|
| `lib/client.js` | 浏览器端:会话头部「技能树」按钮 + 弹层 |
|
|
259
282
|
| `package.json` | 声明 `dsh.bundle.patch` + `dsh.client` |
|
|
260
283
|
| `cordis.patch.yml` | bundle 层的 insert 行 |
|
|
@@ -276,7 +299,7 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
|
|
|
276
299
|
|---|---|---|
|
|
277
300
|
| **规则** | `assets/rules/coach-rules.md` | 插件用 `systemPrompt.section()` 注进 system prompt(和人设同一条路)。**改它要重启 dsh** |
|
|
278
301
|
| **知识** | 地图 4 份 + OI Wiki 检索库 | `coach_setup init` 铺到数据目录;OI Wiki 直接从包里读 |
|
|
279
|
-
| **工具** |
|
|
302
|
+
| **工具** | 31 个 | 装完就在 |
|
|
280
303
|
|
|
281
304
|
规则进的是 **system prompt,不是消息流**:行为约束每轮都得在场,
|
|
282
305
|
而消息流会被长对话把尾巴挤掉。文本是**静态**的(只在加载时读一次)——
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
* `coach_focus` 只汇总近30天不同场次:成功起点不算问题,重复提交/复盘不增加场次数,
|
|
23
23
|
修改旧复盘不把它变成最近发生。分数是取舍参考,不是掌握度、出题概率或奖牌概率。
|
|
24
24
|
|
|
25
|
-
*
|
|
25
|
+
* 有比赛目标时,结合 `coach_target read` 和 `coach_focus` 的信息取舍;当前上下文已有
|
|
26
|
+
足够信息时不必重复查询,也不要求固定调用顺序。能力排名是参考,排在前三之外或尚未
|
|
27
|
+
映射到能力的节点可以训练,说明理由即可。没有目标时不能用
|
|
26
28
|
rating、游标或地图后继猜长期方向;先建立比赛、日期、结果目标、团队模式、时间预算
|
|
27
29
|
和能力优先级。
|
|
28
30
|
* `coach_focus` 给的是比赛能力重点,不是知识点清单。每次训练必须说明它服务哪个能力、
|
|
@@ -92,9 +94,10 @@
|
|
|
92
94
|
|
|
93
95
|
下面九条是硬规则,每条都能机械检查。违反就是没做好本职工作:
|
|
94
96
|
|
|
95
|
-
1.
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
1. **给训练指令前,核对目标、当前重点和地图前置**。
|
|
98
|
+
用 `coach_target`、`coach_focus`、`coach_next` 按需补足信息;上下文已有足够依据时
|
|
99
|
+
不重复读取,不规定调用顺序。不许只凭 rating 或地图后继挑知识点,
|
|
100
|
+
节点必须真实存在,前置必须满足。
|
|
98
101
|
2. **每条指令必须带地图节点 id**。节点 id 只能从地图取到,
|
|
99
102
|
所以这条会自动逼你先读地图。
|
|
100
103
|
3. **一次只给一个动作。** 禁止候选列表、禁止「你可以考虑…」、
|
|
@@ -186,16 +189,18 @@
|
|
|
186
189
|
|
|
187
190
|
### 复习别把新知识挤没
|
|
188
191
|
|
|
189
|
-
|
|
190
|
-
|
|
192
|
+
训练要改善目标比赛中的表现。新知识、复习和迁移都可能带来改善,
|
|
193
|
+
安排哪一个要看目标、已有表现和投入收益。
|
|
191
194
|
|
|
192
195
|
**但他自己会想先复习** —— 复习舒服(做过的东西有把握)、
|
|
193
196
|
开新知识点难受(进入状态有成本)。所以这个平衡**得你把**,
|
|
194
197
|
不能等他主动说"我想学新的":
|
|
195
198
|
|
|
196
199
|
* 排课时,复习块**按火候排**(放得最久的那几个才排),不是按"他提了哪个"排;
|
|
197
|
-
*
|
|
198
|
-
|
|
200
|
+
* **推进不等于开新知识点**:按目标与实际表现选择新课、复习、迁移或 VP,
|
|
201
|
+
不为填课表硬开新点,也不让熟悉题反复练习代替真实改善;
|
|
202
|
+
* **放了一两个月的知识点先核对保持情况**,再决定检测、复习或重讲,
|
|
203
|
+
不把间隔长直接当作已经忘光。
|
|
199
204
|
* **提法按规则 3:一次一个动作** ——「这块我建议验一下,90 分钟三题,做不做?」
|
|
200
205
|
并且**说清你为什么觉得该验了**(拿上面的记录当证据)。
|
|
201
206
|
他说做,你才走下面三步。
|
|
@@ -339,9 +344,16 @@
|
|
|
339
344
|
他说会了就别再给。
|
|
340
345
|
* **注释密度跟他的样例走** —— 样例里注释少就少,别逐行注释。
|
|
341
346
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
仍可保存,但必须当作教练判断;笔记不改游标、进度、待办或检测结果,不替代掌握证据。
|
|
345
357
|
|
|
346
358
|
⚠️ **例外:游标自己那个节点不算"新知识点"** —— 接着做,不逼你再讲一遍。
|
|
347
359
|
**但例外有条件:这个节点得真讲过**(有教学痕迹)。游标可以被挪到一个
|
|
@@ -457,7 +469,8 @@
|
|
|
457
469
|
|
|
458
470
|
## 训练数据操作纪律(P0)
|
|
459
471
|
|
|
460
|
-
*
|
|
472
|
+
* 已授权课程内的事实记录、续课笔记和做题用时正常保存,不重复要求确认。
|
|
473
|
+
替换或删除个人数据、大幅改变目标或课程时,先说明将改什么,等确认后执行。
|
|
461
474
|
* 禁止擅自修改他人的训练数据。
|
|
462
475
|
* 挑题之前先查 AC 记录,不重复喂已经会的题。
|
|
463
476
|
|
|
@@ -506,8 +519,8 @@
|
|
|
506
519
|
(一个写、一个验、一个造 hack 数据)。
|
|
507
520
|
末尾并行死磕多道难题是 ICPC 最常见的败因 —— 风险是最后一道都做不出。
|
|
508
521
|
* 每场记一句:**题目是不是全都被读过**(读漏题比做不出更亏)。
|
|
509
|
-
*
|
|
510
|
-
|
|
522
|
+
* **临近比赛通常优先巩固与模拟**:按剩余时间、掌握基础和预期收益取舍;
|
|
523
|
+
必要的新内容可安排,说明收益、风险和验收方式,不按固定两周一律禁止。
|
|
511
524
|
|
|
512
525
|
## 紧急停止(最高优先级)
|
|
513
526
|
|
|
@@ -9,7 +9,7 @@ function shape(schema, value) {
|
|
|
9
9
|
assert.ok(value && typeof value === 'object' && !Array.isArray(value))
|
|
10
10
|
if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected field ${key}`)
|
|
11
11
|
for (const [key, field] of Object.entries(schema.properties ?? {})) {
|
|
12
|
-
if (field.required) assert.ok(key in value, `missing field ${key}`)
|
|
12
|
+
if (field.required === true || (Array.isArray(schema.required) && schema.required.includes(key))) assert.ok(key in value, `missing field ${key}`)
|
|
13
13
|
if (key in value) shape(field, value[key])
|
|
14
14
|
}
|
|
15
15
|
} else if (schema.type === 'array') {
|
|
@@ -38,6 +38,7 @@ test('registered coaching tools expose cited findings, sourced teaching and comp
|
|
|
38
38
|
assert.ok(tools.get('coach_postmortem').parameters.properties.findings)
|
|
39
39
|
assert.ok(tools.get('coach_focus').output.schema.properties.profile.properties?.id, 'SDK needs explicit nested profile fields')
|
|
40
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')
|
|
41
42
|
assert.ok((await call('coach_target', { action: 'profiles' })).availableProfiles.length)
|
|
42
43
|
const missing = await call('coach_postmortem', { eventId: 'unknown', findings: [] })
|
|
43
44
|
assert.equal(missing.ok, false)
|
|
@@ -91,3 +92,128 @@ test('registered coaching tools expose cited findings, sourced teaching and comp
|
|
|
91
92
|
rmSync(dir, { recursive: true, force: true })
|
|
92
93
|
}
|
|
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
|
+
})
|
package/dev/curriculum-check.mjs
CHANGED
|
@@ -53,7 +53,7 @@ function validateShape(schema, value) {
|
|
|
53
53
|
assert.equal(typeof value, 'object'); assert.notEqual(value, null)
|
|
54
54
|
if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected ${key}`)
|
|
55
55
|
for (const [key, child] of Object.entries(schema.properties)) {
|
|
56
|
-
if (child.required) assert.ok(key in value, `missing ${key}`)
|
|
56
|
+
if (child.required === true || (Array.isArray(schema.required) && schema.required.includes(key))) assert.ok(key in value, `missing ${key}`)
|
|
57
57
|
if (key in value) validateShape(child, value[key])
|
|
58
58
|
}
|
|
59
59
|
} else if (schema.type === 'array') {
|
package/dev/parity-check.mjs
CHANGED
|
@@ -73,7 +73,7 @@ const onlyA = namesA.filter((n) => !namesB.includes(n))
|
|
|
73
73
|
check('开源版没有丢掉任何原有工具', onlyB.length === 0, onlyB.join(',') || '(没丢)')
|
|
74
74
|
// 新增的只能是「搭环境 / 拉数据 / 查知识 / 长期课程」这几类 —— 它们补的正是
|
|
75
75
|
// 开源版缺的那些基础设施(原版靠本地已有的库和 skill,别人没有)。
|
|
76
|
-
const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import', 'coach_curriculum', 'coach_target', 'coach_focus', 'coach_scope', 'coach_postmortem', 'coach_vp_import', 'coach_vp_connect', 'coach_vp_sync', 'coach_vp_contests', 'coach_vp_replay'])
|
|
76
|
+
const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import', 'coach_curriculum', 'coach_note', 'coach_target', 'coach_focus', 'coach_scope', 'coach_postmortem', 'coach_vp_import', 'coach_vp_connect', 'coach_vp_sync', 'coach_vp_contests', 'coach_vp_replay'])
|
|
77
77
|
check('新增的都在预期内(搭建 / 检索 / 课程 / 比赛 / VP)',
|
|
78
78
|
onlyA.every((n) => EXPECTED_NEW.has(n)), onlyA.join(',') || '(无新增)')
|
|
79
79
|
// 对照副本可能也已经升级到 20/21 个工具;检查原工具集合,不能拿固定数量拒绝新版。
|
|
@@ -130,10 +130,14 @@ for (const [tool, args, label] of CALLS) {
|
|
|
130
130
|
try { rb = norm(await tb.execute(args, undefined)) } catch (e) { eb = e.message }
|
|
131
131
|
// Legacy hosts have no goal layer; only its explicit no-target defaults may be absent.
|
|
132
132
|
if (tool === 'coach_assign' && rb && !('targetAligned' in rb) &&
|
|
133
|
-
|
|
133
|
+
ra?.targetAligned === false &&
|
|
134
134
|
Array.isArray(ra.competencies) && !ra.competencies.length) {
|
|
135
135
|
delete ra.targetAligned; delete ra.competencies
|
|
136
136
|
}
|
|
137
|
+
// A legacy host cannot return the new empty continuation field.
|
|
138
|
+
if (tool === 'coach_status' && rb && !('teachingContext' in rb) && ra?.teachingContext === '') {
|
|
139
|
+
delete ra.teachingContext
|
|
140
|
+
}
|
|
137
141
|
const sa = JSON.stringify(ra ?? { ERR: ea }), sb = JSON.stringify(rb ?? { ERR: eb })
|
|
138
142
|
if (sa === sb) { check(`${tool} · ${label}`, true) }
|
|
139
143
|
else {
|
|
@@ -154,8 +158,16 @@ for (const [tool, args, label] of CALLS.filter((c) => c[0] !== 'coach_diagnose')
|
|
|
154
158
|
try { va = await ta.execute(args, undefined) } catch { continue }
|
|
155
159
|
try { vb = await tb.execute(args, undefined) } catch { continue }
|
|
156
160
|
const ra = strip(ta.output.render(args, va)[0]?.text ?? '')
|
|
157
|
-
const
|
|
158
|
-
|
|
161
|
+
const legacyText = tb.output.render(args, vb)[0]?.text ?? ''
|
|
162
|
+
// The old lecture label overstated its evidence; only this intentional label changes.
|
|
163
|
+
const rb = strip(tool === 'coach_status'
|
|
164
|
+
? legacyText.replace(/ 讲于 (\d{4}-\d{2}-\d{2})/g, ' 接触记录 $1(不证明理解)')
|
|
165
|
+
: legacyText)
|
|
166
|
+
// Only the changed goal-led advice is excluded; schedule facts still match byte for byte.
|
|
167
|
+
const scheduleAdvice = (text) => text
|
|
168
|
+
.replace(/^⚠️ \*\*复习别把新知识挤没了。\*\*.*剩下的时间留给推进。$/m, '<COACH_ADVICE>')
|
|
169
|
+
.replace(/^根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。上面的间隔只供判断;不要为了填满课表持续开新知识点。$/m, '<COACH_ADVICE>')
|
|
170
|
+
const same = tool === 'coach_schedule' ? scheduleAdvice(ra) === scheduleAdvice(rb) : ra === rb
|
|
159
171
|
check(`渲染 · ${tool} · ${label}`, same, same ? '' : '文本不一致')
|
|
160
172
|
}
|
|
161
173
|
|
package/dev/run-all.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# 一条命令跑全套验收。
|
|
3
3
|
#
|
|
4
|
-
#
|
|
4
|
+
# 验收项:
|
|
5
5
|
# verify / verify-shape 主验收和全工具形状扫描
|
|
6
6
|
# w1-check 解绑、夹具闸门、规则注入
|
|
7
7
|
# wiki-check OI Wiki 检索
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
# curriculum-check 长期课程、版本、阶段验收与任务关联
|
|
10
10
|
# curriculum-evidence 编排证据目录、引用与事件身份
|
|
11
11
|
# strategy-check 比赛目标、VP 证据、复盘与重点选择
|
|
12
|
+
# teaching 续课笔记、学生原话与进度保留
|
|
12
13
|
# parity-check 和线上那份的保真度对比
|
|
13
14
|
#
|
|
14
15
|
# 主验收跑的是**已安装副本**(不是源码)—— 那才是运行时真正加载的东西。
|
|
@@ -46,7 +47,7 @@ run() { # run <名字> <命令...>
|
|
|
46
47
|
echo "✓ $(echo "$out" | tail -1)"
|
|
47
48
|
else
|
|
48
49
|
echo "✗"
|
|
49
|
-
echo "$out" |
|
|
50
|
+
echo "$out" | sed 's/^/ /'
|
|
50
51
|
RC=1
|
|
51
52
|
fi
|
|
52
53
|
}
|
|
@@ -67,6 +68,7 @@ run "evidence" node --test dev/curriculum-evidence-check.mjs
|
|
|
67
68
|
run "strategy" node --test dev/strategy-check.mjs
|
|
68
69
|
run "catalog" node --test dev/catalog-check.mjs
|
|
69
70
|
run "coach-contract" node --test dev/coaching-contract-check.mjs
|
|
71
|
+
run "teaching" node --test dev/teaching-check.mjs
|
|
70
72
|
run "VP-client" node dev/vp-client-check.mjs
|
|
71
73
|
run "VP-sync" node --test dev/vp-sync-check.mjs
|
|
72
74
|
run "migration" node --test dev/workspace-migration-check.mjs
|
package/dev/strategy-check.mjs
CHANGED
|
@@ -209,19 +209,50 @@ test('first-use focus has a complete refusal shape and targets reject unknown ca
|
|
|
209
209
|
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
210
210
|
})
|
|
211
211
|
|
|
212
|
-
test('target route
|
|
212
|
+
test('target route allows a fourth-ranked target capability without imposing the focus ranking', () => {
|
|
213
213
|
const { dir, store } = fixture()
|
|
214
214
|
try {
|
|
215
|
+
assert.equal(store.target('set', {
|
|
216
|
+
expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
|
|
217
|
+
weeklyHours: 35, priorities: [
|
|
218
|
+
{ id: 'contest-reading', weight: 100 },
|
|
219
|
+
{ id: 'contest-decision', weight: 90 },
|
|
220
|
+
{ id: 'dp-modeling', weight: 80 },
|
|
221
|
+
{ id: 'graph-modeling', weight: 10 },
|
|
222
|
+
],
|
|
223
|
+
}).ok, true)
|
|
224
|
+
assert.deepEqual(store.focus().postponed, ['graph-modeling'])
|
|
225
|
+
const route = store.route('最短路', { purpose: 'progress', courseBound: false })
|
|
226
|
+
assert.equal(route.ok, true)
|
|
227
|
+
assert.equal(route.targetAligned, true)
|
|
228
|
+
assert.deepEqual(route.competencyIds, ['graph-modeling'])
|
|
229
|
+
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('target route reports alignment without rejecting the coach choice', () => {
|
|
233
|
+
const { dir, store } = fixture()
|
|
234
|
+
try {
|
|
235
|
+
assert.equal(store.route('树状数组').targetAligned, false, 'no target is not an alignment claim')
|
|
215
236
|
store.target('set', {
|
|
216
237
|
expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
|
|
217
238
|
weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 100 }],
|
|
218
239
|
})
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
240
|
+
for (const options of [
|
|
241
|
+
{ purpose: 'progress', courseBound: false },
|
|
242
|
+
{ purpose: 'progress', courseBound: true },
|
|
243
|
+
{ purpose: 'remediation', courseBound: false },
|
|
244
|
+
{ purpose: 'review', courseBound: false },
|
|
245
|
+
]) {
|
|
246
|
+
const unrelated = store.route('树状数组', options)
|
|
247
|
+
assert.equal(unrelated.ok, true)
|
|
248
|
+
assert.equal(unrelated.reject, '')
|
|
249
|
+
assert.equal(unrelated.targetAligned, false)
|
|
250
|
+
assert.deepEqual(unrelated.competencyIds, [])
|
|
251
|
+
assert.equal(unrelated.courseBound, options.courseBound)
|
|
252
|
+
}
|
|
253
|
+
const alignedReview = store.route('动态规划基础', { purpose: 'review', courseBound: true })
|
|
254
|
+
assert.equal(alignedReview.targetAligned, true)
|
|
255
|
+
assert.deepEqual(alignedReview.competencyIds, ['dp-modeling'])
|
|
225
256
|
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
226
257
|
})
|
|
227
258
|
|
|
@@ -313,5 +344,8 @@ test('corrupt stored strategy data returns a structured refusal', () => {
|
|
|
313
344
|
const result = store.target('read')
|
|
314
345
|
assert.equal(result.ok, false)
|
|
315
346
|
assert.match(result.reject, /TARGET.yaml/)
|
|
347
|
+
const route = store.route('最短路')
|
|
348
|
+
assert.equal(route.ok, false)
|
|
349
|
+
assert.match(route.reject, /TARGET.yaml/)
|
|
316
350
|
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
317
351
|
})
|