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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acmer-coach",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "2.
|
|
3
|
+
"description": "小鲸教练插件:保存训练位置和真实证据,支持教练判断、持续教学与比赛备赛。读 MAP/DEPENDS/PROGRESS,提供训练、判因和检测工具。",
|
|
4
|
+
"version": "2.5.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/Miantiao1231/acmer-coach.git"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"@deepseek-ai/dsh-tools": "0.2.0-rc.2"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "node --test dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs"
|
|
26
|
+
"test": "node --test dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs"
|
|
27
27
|
},
|
|
28
28
|
"dsh": {
|
|
29
29
|
"engines": {
|
package/verify-shape.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// 退出码非 0 = 有错配。
|
|
18
18
|
|
|
19
19
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import assert from 'node:assert/strict'
|
|
20
21
|
import os from 'node:os'
|
|
21
22
|
import path from 'node:path'
|
|
22
23
|
import { pathToFileURL } from 'node:url'
|
|
@@ -136,7 +137,7 @@ function validate(schema, value, at, errs) {
|
|
|
136
137
|
if (!(k in props)) errs.push(`${at}.${k}: schema 未声明(additionalProperties:false → dsh 校验会拒)`)
|
|
137
138
|
}
|
|
138
139
|
for (const [k, s] of Object.entries(props)) {
|
|
139
|
-
if (!(k in value)) { if (s.required) errs.push(`${at}.${k}: 缺 required 字段`); continue }
|
|
140
|
+
if (!(k in value)) { if (s.required === true || (Array.isArray(schema.required) && schema.required.includes(k))) errs.push(`${at}.${k}: 缺 required 字段`); continue }
|
|
140
141
|
validate(s, value[k], `${at}.${k}`, errs)
|
|
141
142
|
}
|
|
142
143
|
} else if (t === 'array') {
|
|
@@ -153,6 +154,21 @@ function validate(schema, value, at, errs) {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// 嵌套对象的 required 列表描述子字段,不代表这个对象自身必填。
|
|
158
|
+
const optionalObjectSchema = { type: 'object', required: ['summary'], properties: {
|
|
159
|
+
summary: { type: 'string' },
|
|
160
|
+
studentEvidence: { type: 'object', required: ['quote'], properties: { quote: { type: 'string' } } },
|
|
161
|
+
} }
|
|
162
|
+
const validOptional = [], missingParent = [], missingChild = [], missingDsl = []
|
|
163
|
+
validate(optionalObjectSchema, { summary: '教练判断' }, 'out', validOptional)
|
|
164
|
+
validate(optionalObjectSchema, {}, 'out', missingParent)
|
|
165
|
+
validate(optionalObjectSchema, { summary: '教练判断', studentEvidence: {} }, 'out', missingChild)
|
|
166
|
+
validate({ type: 'object', properties: { summary: { type: 'string', required: true } } }, {}, 'out', missingDsl)
|
|
167
|
+
assert.deepEqual(validOptional, [])
|
|
168
|
+
assert.deepEqual(missingParent, ['out.summary: 缺 required 字段'])
|
|
169
|
+
assert.deepEqual(missingChild, ['out.studentEvidence.quote: 缺 required 字段'])
|
|
170
|
+
assert.deepEqual(missingDsl, ['out.summary: 缺 required 字段'])
|
|
171
|
+
|
|
156
172
|
const mod = await import(`${INSTALLED}?sweep=${Date.now()}`)
|
|
157
173
|
const registered = []
|
|
158
174
|
mod.apply(mockCtx(registered))
|
|
@@ -169,10 +185,10 @@ const cases = [
|
|
|
169
185
|
['coach_ping', {}],
|
|
170
186
|
['coach_target', { action: 'read' }],
|
|
171
187
|
['coach_target', { action: 'set', expectedRevision: 0, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
|
|
172
|
-
//
|
|
188
|
+
// 目标能力提供关联提示;没有映射的节点仍可由教练解释后安排,
|
|
173
189
|
// 再切回无地图能力的读题卡,保留后续旧夹具的成功路径。
|
|
174
190
|
['coach_target', { action: 'set', expectedRevision: 1, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'dp-modeling', weight: 80, reason: '测试硬闸' }] }],
|
|
175
|
-
['coach_assign#target-
|
|
191
|
+
['coach_assign#target-advisory', { node: 'B', deliverable: '把代码贴给我', why: '测试未映射节点仍可训练', minutes: 60, teachMinutes: 25 }],
|
|
176
192
|
['coach_target', { action: 'set', expectedRevision: 2, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
|
|
177
193
|
['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
|
|
178
194
|
['coach_vp_connect', { action: 'status' }],
|
|
@@ -206,6 +222,12 @@ const cases = [
|
|
|
206
222
|
['coach_next', { cursor: '并不存在' }], // 打错字
|
|
207
223
|
['coach_next', {}], // 游标空 → 读夹具
|
|
208
224
|
['coach_status', {}],
|
|
225
|
+
['coach_note#read', { action: 'read', node: 'B' }],
|
|
226
|
+
['coach_note#write', { action: 'write', node: 'B', summary: '已说明状态需要保留哪些历史;还没收到学生回应。', nextStep: '接着核对能否区分两段未来不同的历史。' }],
|
|
227
|
+
['coach_note#read-saved', { action: 'read', node: 'B', limit: 5 }],
|
|
228
|
+
['coach_note#quote-rejected', { action: 'write', node: 'B', summary: '待核实回应', nextStep: '核对原话', studentQuote: '我理解了这个状态的定义。' }],
|
|
229
|
+
['coach_note#invalid-node', { action: 'write', node: 'ZZZ', summary: '测试笔记', nextStep: '继续' }],
|
|
230
|
+
['coach_note#empty-summary', { action: 'write', node: 'B', summary: '', nextStep: '继续' }],
|
|
209
231
|
['coach_set_cursor', { node: 'B' }],
|
|
210
232
|
['coach_set_cursor', { node: 'ZZZ' }], // 拒
|
|
211
233
|
['coach_mark', { node: 'C', status: 'studying' }],
|
|
@@ -307,11 +329,19 @@ for (const [name, args, preset] of cases) {
|
|
|
307
329
|
bad++
|
|
308
330
|
continue
|
|
309
331
|
}
|
|
310
|
-
if (name === 'coach_assign#target-
|
|
311
|
-
console.log(` FAIL ${name} →
|
|
332
|
+
if (name === 'coach_assign#target-advisory' && (out.accepted !== true || out.targetAligned !== false)) {
|
|
333
|
+
console.log(` FAIL ${name} → 未映射节点应允许布置,并明确 targetAligned=false`)
|
|
312
334
|
bad++
|
|
313
335
|
continue
|
|
314
336
|
}
|
|
337
|
+
if (name.startsWith('coach_note#')) {
|
|
338
|
+
const rejected = ['quote-rejected', 'invalid-node', 'empty-summary'].includes(name.split('#')[1])
|
|
339
|
+
if (out.ok === rejected || (!rejected && name !== 'coach_note#read' && out.notes?.length !== 1)) {
|
|
340
|
+
console.log(` FAIL ${name} → 续课笔记的成功/拒绝或落盘结果不符`)
|
|
341
|
+
bad++
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
}
|
|
315
345
|
const errs = []
|
|
316
346
|
validate(tool.output.schema, out, 'out', errs)
|
|
317
347
|
let renderNote = ''
|
package/verify.mjs
CHANGED
|
@@ -89,7 +89,7 @@ for (const f of readdirSync(KNOWLEDGE)) {
|
|
|
89
89
|
}
|
|
90
90
|
process.env.COACH_DATA_DIR = tmpEarly
|
|
91
91
|
let stale = []
|
|
92
|
-
for (const f of ['index.js', 'package.json', 'lib/setup.js', 'lib/vp-client.js', 'lib/workspace-migration.js', 'lib/strategy.js', 'lib/curriculum.js', 'lib/curriculum-evidence.js', 'lib/curriculum-planning.js', 'assets/knowledge/NODE_SCOPES.yaml', 'assets/knowledge/CONTEST_PROFILES.yaml', 'assets/rules/coach-rules.md']) {
|
|
92
|
+
for (const f of ['index.js', 'package.json', 'lib/setup.js', 'lib/vp-client.js', 'lib/workspace-migration.js', 'lib/strategy.js', 'lib/teaching.js', 'lib/curriculum.js', 'lib/curriculum-evidence.js', 'lib/curriculum-planning.js', 'assets/knowledge/NODE_SCOPES.yaml', 'assets/knowledge/CONTEST_PROFILES.yaml', 'assets/rules/coach-rules.md']) {
|
|
93
93
|
let same = true
|
|
94
94
|
try {
|
|
95
95
|
same = readFileSync(`${SOURCE_DIR}\\${f}`, 'utf8') === readFileSync(`${INSTALLED_DIR}\\${f}`, 'utf8')
|
|
@@ -898,9 +898,9 @@ check('标「在学」建立教学记录(这是"讲过"的机械痕迹)', ma
|
|
|
898
898
|
|
|
899
899
|
// ③ 正面:标过之后同一行要报出「最早的教学痕迹」
|
|
900
900
|
const stYes = await T5('coach_status').execute({}, undefined)
|
|
901
|
-
check('★
|
|
901
|
+
check('★ 标过之后:报出最早接触记录,并说明不证明理解(验收 ③ 正面)',
|
|
902
902
|
/^\d{4}-\d{2}-\d{2}$/.test(stYes.cursorTaught) &&
|
|
903
|
-
T5('coach_status').output.render({}, stYes)[0].text.includes(
|
|
903
|
+
T5('coach_status').output.render({}, stYes)[0].text.includes(`接触记录 ${stYes.cursorTaught}(不证明理解)`),
|
|
904
904
|
`cursorTaught=${JSON.stringify(stYes.cursorTaught)}`)
|
|
905
905
|
// 游标还原 —— 下面几关的夹具是照着"游标本来就是空的"搭的
|
|
906
906
|
if (cursorWas) await T5('coach_set_cursor').execute({ node: cursorWas }, undefined)
|
|
@@ -2362,7 +2362,7 @@ function shapeDiff(tool, out) {
|
|
|
2362
2362
|
if (schema.additionalProperties === false && !(k in props)) extra.push(child(k))
|
|
2363
2363
|
}
|
|
2364
2364
|
for (const [k, s] of Object.entries(props)) {
|
|
2365
|
-
if (s?.required && !(k in value)) missing.push(child(k))
|
|
2365
|
+
if ((s?.required === true || (Array.isArray(schema.required) && schema.required.includes(k))) && !(k in value)) missing.push(child(k))
|
|
2366
2366
|
else if (k in value) {
|
|
2367
2367
|
const d = walk(s, value[k], child(k))
|
|
2368
2368
|
extra.push(...d.extra); missing.push(...d.missing)
|
|
@@ -2378,6 +2378,19 @@ function shapeDiff(tool, out) {
|
|
|
2378
2378
|
}
|
|
2379
2379
|
return walk(tool.output.schema, out ?? {}, '')
|
|
2380
2380
|
}
|
|
2381
|
+
const optionalObjectSchema = { type: 'object', required: ['summary'], properties: {
|
|
2382
|
+
summary: { type: 'string' },
|
|
2383
|
+
studentEvidence: { type: 'object', required: ['quote'], properties: { quote: { type: 'string' } } },
|
|
2384
|
+
} }
|
|
2385
|
+
const schemaTool = { output: { schema: optionalObjectSchema } }
|
|
2386
|
+
check('★ 形状尺:可选对象缺省合法,子字段 required 不代表父对象必填',
|
|
2387
|
+
shapeDiff(schemaTool, { summary: '教练判断' }).missing.length === 0)
|
|
2388
|
+
check('★ 形状尺:父对象 required 列表中的字段缺省仍拒绝',
|
|
2389
|
+
shapeDiff(schemaTool, {}).missing.join(',') === 'summary')
|
|
2390
|
+
check('★ 形状尺:提供可选对象后,仍核对子字段 required',
|
|
2391
|
+
shapeDiff(schemaTool, { summary: '教练判断', studentEvidence: {} }).missing.join(',') === 'studentEvidence.quote')
|
|
2392
|
+
check('★ 形状尺:原 DSL 的 required=true 仍核对',
|
|
2393
|
+
shapeDiff({ output: { schema: { type: 'object', properties: { summary: { type: 'string', required: true } } } } }, {}).missing.join(',') === 'summary')
|
|
2381
2394
|
const t1 = await planT.execute({ date: D[3], blocks: [{ from: '10:00', kind: 'review', node: 'A' }] })
|
|
2382
2395
|
check('工具层:排得进去', t1.ok === true && t1.count === 1, t1.reject)
|
|
2383
2396
|
const sd1 = shapeDiff(planT, t1)
|
|
@@ -3120,9 +3133,9 @@ check('studying 的用 at 算天数(3 天前标的)', rB33?.days === 3, `${r
|
|
|
3120
3133
|
const txt33 = T33.get('coach_schedule').output.render({}, sch33)[0].text
|
|
3121
3134
|
check('★ 渲染里摆得出来,且标明"该不该动是你判"',
|
|
3122
3135
|
txt33.includes('火候') && txt33.includes('该不该动是你判'), (txt33.match(/.*火候.*/) ?? [''])[0])
|
|
3123
|
-
check('★
|
|
3124
|
-
txt33.includes('
|
|
3125
|
-
(txt33.match(
|
|
3136
|
+
check('★ 排课建议依据目标与表现,不强制持续开新课',
|
|
3137
|
+
txt33.includes('根据目标') && txt33.includes('实际表现') && txt33.includes('迁移或 VP') && !txt33.includes('剩下的时间留给推进'),
|
|
3138
|
+
(txt33.match(/.*根据目标.*/) ?? ['(没有)'])[0].slice(0, 70))
|
|
3126
3139
|
|
|
3127
3140
|
// 没有火候(全是未学/没有记录)时不该硬塞一段
|
|
3128
3141
|
const tmp33b = mkdtempSync(path.join(os.tmpdir(), 'coach-ready-b-'))
|
|
@@ -3495,7 +3508,7 @@ mod38.apply(mockCtx(reg38))
|
|
|
3495
3508
|
const T38 = (n) => reg38.find((t) => t.name === n)
|
|
3496
3509
|
const asg38 = T38('coach_assign')
|
|
3497
3510
|
const prog38 = () => readFileSync(path.join(tmp38, 'PROGRESS.yaml'), 'utf8')
|
|
3498
|
-
// 夹具里的游标是空的 —— 先设上。(`coach_status`
|
|
3511
|
+
// 夹具里的游标是空的 —— 先设上。(`coach_status` 报接触日期读的是
|
|
3499
3512
|
// PROGRESS.yaml 里的游标,不是 assign 那条路径传进来的 cursor 参数。)
|
|
3500
3513
|
await T38('coach_set_cursor').execute({ node: '记忆化搜索' })
|
|
3501
3514
|
|
|
@@ -3504,22 +3517,28 @@ const g38a = await asg38.execute({ cursor: '记忆化搜索', node: '记忆化
|
|
|
3504
3517
|
check('★ 游标节点 + 没讲过 + 不给 teachMinutes → 拒(这就是那个洞)',
|
|
3505
3518
|
g38a.accepted === false && g38a.reject.includes('全新知识点'), g38a.reject.split('\n')[0])
|
|
3506
3519
|
|
|
3507
|
-
// ②
|
|
3520
|
+
// ② 带讲授计划 → 放行,但计划不能伪装成已发生的教学。
|
|
3508
3521
|
const g38b = await asg38.execute({
|
|
3509
3522
|
cursor: '记忆化搜索', node: '记忆化搜索', deliverable: '贴代码', minutes: 40, teachMinutes: 20 })
|
|
3510
3523
|
check('★ 带 teachMinutes → 放行', g38b.accepted === true, g38b.reject)
|
|
3511
|
-
check('★
|
|
3512
|
-
prog38().includes('taught:'),
|
|
3524
|
+
check('★ 讲授计划不写 taught:计划不等于实际接触',
|
|
3525
|
+
!prog38().includes('taught:'), prog38())
|
|
3513
3526
|
const st38 = await T38('coach_status').execute({})
|
|
3514
|
-
check('★
|
|
3515
|
-
st38.cursorTaught
|
|
3516
|
-
|
|
3527
|
+
check('★ 只有计划时 cursorTaught 仍为空,计划时长另存在待办里',
|
|
3528
|
+
st38.cursorTaught === '' && st38.pendingActions.find((a) => a.node === '记忆化搜索')?.teachMinutes === 20,
|
|
3529
|
+
`cursorTaught=${st38.cursorTaught || '(空)'}`)
|
|
3530
|
+
check('★ 讲授计划不动 status(还没发生的课不能变成学习记录)',
|
|
3517
3531
|
st38.studyingCount === 0 && st38.learnedCount === 0 && st38.verifiedCount === 0,
|
|
3518
3532
|
JSON.stringify({ 在学: st38.studyingCount, 学过: st38.learnedCount, 已验证: st38.verifiedCount }))
|
|
3519
3533
|
|
|
3520
|
-
// ③
|
|
3534
|
+
// ③ 计划不能解锁继续练;教练记录实际进入学习后才适用游标例外。
|
|
3521
3535
|
const g38c = await asg38.execute({ cursor: '记忆化搜索', node: '记忆化搜索', deliverable: '贴代码', minutes: 40 })
|
|
3522
|
-
check('★
|
|
3536
|
+
check('★ 只有计划:不给 teachMinutes 仍拒绝',
|
|
3537
|
+
g38c.accepted === false && g38c.reject.includes('全新知识点'), g38c.reject)
|
|
3538
|
+
const marked38 = await T38('coach_mark').execute({ node: '记忆化搜索', status: 'studying' })
|
|
3539
|
+
check('★ 实际开始学习由教练显式记录', marked38.ok === true, marked38.reject)
|
|
3540
|
+
const g38real = await asg38.execute({ cursor: '记忆化搜索', node: '记忆化搜索', deliverable: '贴代码', minutes: 40 })
|
|
3541
|
+
check('★ 实际接触之后:游标节点再布置不用重讲', g38real.accepted === true, g38real.reject)
|
|
3523
3542
|
|
|
3524
3543
|
// ④ 不误伤:非游标的新节点照样要讲
|
|
3525
3544
|
const g38d = await asg38.execute({ cursor: '记忆化搜索', node: '区间 DP', deliverable: '贴代码', minutes: 40 })
|