acmer-coach 2.4.0 → 2.6.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 +34 -3
- package/assets/knowledge/CONTEST_PROFILES.yaml +62 -5
- package/assets/knowledge/skilltree.html +3 -3
- package/assets/rules/coach-rules.md +35 -14
- package/coach/skilltree_template.html +3 -3
- package/dev/catalog-check.mjs +36 -0
- package/dev/coaching-contract-check.mjs +127 -1
- package/dev/curriculum-check.mjs +1 -1
- package/dev/curriculum-evidence-check.mjs +18 -0
- package/dev/grading-check.mjs +161 -0
- package/dev/parity-check.mjs +21 -4
- package/dev/run-all.sh +6 -2
- package/dev/strategy-check.mjs +41 -7
- package/dev/teaching-check.mjs +191 -0
- package/dev/training-check.mjs +183 -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 +37 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +7 -6
- package/index.js +273 -164
- package/lib/curriculum-evidence.js +7 -3
- package/lib/curriculum.js +10 -7
- package/lib/strategy.js +14 -13
- package/lib/teaching.js +78 -0
- package/package.json +3 -3
- package/verify-shape.mjs +35 -5
- package/verify.mjs +82 -63
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { parse, stringify } from 'yaml'
|
|
7
|
+
import { collectEvidence } from '../lib/curriculum-evidence.js'
|
|
8
|
+
import { evaluateExitChecks } from '../lib/curriculum-planning.js'
|
|
9
|
+
|
|
10
|
+
async function fixture(run, nodeRecord = { status: 'studying' }) {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), 'coach-grading-'))
|
|
12
|
+
const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
|
|
13
|
+
try {
|
|
14
|
+
process.env.COACH_DATA_DIR = dir
|
|
15
|
+
process.env.COACH_DB = join(dir, 'unused.db')
|
|
16
|
+
writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes:\n - {id: A, name: 基础, depends: []}\n')
|
|
17
|
+
const path = join(dir, 'PROGRESS.yaml')
|
|
18
|
+
writeFileSync(path, stringify({ version: 1, cursor: 'A', nodes: { A: nodeRecord }, pending: {
|
|
19
|
+
node: 'A', totalMinutes: 90, passScore: 4, maxScore: 6,
|
|
20
|
+
problems: [
|
|
21
|
+
{ problemId: 'P1', band: '易', weight: 1, cfRating: 1200, source: 'pool' },
|
|
22
|
+
{ problemId: 'P2', band: '中', weight: 2, cfRating: 1600, source: 'pool' },
|
|
23
|
+
{ problemId: 'P3', band: '难', weight: 3, cfRating: 1900, source: 'pool' },
|
|
24
|
+
],
|
|
25
|
+
} }))
|
|
26
|
+
const { apply } = await import(`../index.js?grading=${encodeURIComponent(dir)}`)
|
|
27
|
+
const tools = new Map()
|
|
28
|
+
apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on() {} })
|
|
29
|
+
await run({ grade: tools.get('coach_grade'), call: (name, args) => tools.get(name).execute(args),
|
|
30
|
+
ledger: () => parse(readFileSync(path, 'utf8')), bytes: () => readFileSync(path, 'utf8') })
|
|
31
|
+
} finally {
|
|
32
|
+
for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
|
|
33
|
+
if (value === undefined) delete process.env[key]
|
|
34
|
+
else process.env[key] = value
|
|
35
|
+
}
|
|
36
|
+
rmSync(dir, { recursive: true, force: true })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const results = (extra = {}) => ['P1', 'P2', 'P3'].map(problemId => ({ problemId, solved: true, minutes: 15, ...extra }))
|
|
41
|
+
const independentExit = ledger => evaluateExitChecks({ exitChecks: [{ node: 'A', minEvents: 1, independent: true }] },
|
|
42
|
+
collectEvidence(ledger), [], '2000-01-01')[0]
|
|
43
|
+
|
|
44
|
+
test('a passing paper without independence facts cannot create a mastery certificate', async () => {
|
|
45
|
+
await fixture(async ({ grade, ledger }) => {
|
|
46
|
+
const result = await grade.execute({ results: results() })
|
|
47
|
+
assert.equal(result.ok, true)
|
|
48
|
+
assert.equal(result.outcome, 'passed')
|
|
49
|
+
assert.equal(result.status, 'studying')
|
|
50
|
+
assert.equal(result.verified, false)
|
|
51
|
+
assert.equal(result.independence, 'unknown')
|
|
52
|
+
assert.equal(result.verifiedAt, 0)
|
|
53
|
+
const record = ledger().nodes.A
|
|
54
|
+
assert.equal(record.checks[0].independent, undefined)
|
|
55
|
+
assert.equal(record.checks[0].verifiedAt, 0)
|
|
56
|
+
assert.equal(independentExit(ledger()).passed, false, 'unknown independence cannot satisfy the course exit condition')
|
|
57
|
+
assert.equal(ledger().pending, undefined)
|
|
58
|
+
assert.match(grade.output.render({}, result)[0].text, /独立性.*未|未.*独立/)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('a passing paper with a helped solution stays a paper pass without a certificate', async () => {
|
|
63
|
+
await fixture(async ({ grade, ledger }) => {
|
|
64
|
+
const reported = results({ independent: true })
|
|
65
|
+
reported[1].independent = false
|
|
66
|
+
const result = await grade.execute({ results: reported })
|
|
67
|
+
assert.equal(result.outcome, 'passed')
|
|
68
|
+
assert.equal(result.independence, 'assisted')
|
|
69
|
+
assert.equal(result.verified, false)
|
|
70
|
+
assert.equal(result.status, 'studying')
|
|
71
|
+
assert.equal(result.verifiedAt, 0)
|
|
72
|
+
assert.equal(ledger().nodes.A.checks[0].independent, false)
|
|
73
|
+
assert.equal(independentExit(ledger()).passed, false)
|
|
74
|
+
assert.match(grade.output.render({}, result)[0].text, /有帮助/)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('explicitly independent solved problems certify a passing paper and carry the evidence into course checks', async () => {
|
|
79
|
+
await fixture(async ({ grade, ledger }) => {
|
|
80
|
+
const result = await grade.execute({ results: results({ independent: true }) })
|
|
81
|
+
assert.equal(result.verified, true)
|
|
82
|
+
assert.equal(result.status, 'verified')
|
|
83
|
+
assert.equal(result.independence, 'independent')
|
|
84
|
+
assert.equal(result.verifiedAt, 1900)
|
|
85
|
+
assert.equal(ledger().nodes.A.checks[0].independent, true)
|
|
86
|
+
assert.equal(independentExit(ledger()).passed, true)
|
|
87
|
+
assert.equal(ledger().nodes.A.passes, undefined, 'exam records must not invent practice records')
|
|
88
|
+
assert.match(grade.output.render({}, result)[0].text, /不代表隔日.*迁移/)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('weighted passing still requires explicit independence for every solved problem', async () => {
|
|
93
|
+
await fixture(async ({ grade }) => {
|
|
94
|
+
const reported = results({ independent: true })
|
|
95
|
+
delete reported[2].independent
|
|
96
|
+
const result = await grade.execute({ results: reported })
|
|
97
|
+
assert.equal(result.outcome, 'passed')
|
|
98
|
+
assert.equal(result.verified, false)
|
|
99
|
+
assert.equal(result.independence, 'unknown')
|
|
100
|
+
})
|
|
101
|
+
await fixture(async ({ grade }) => {
|
|
102
|
+
const reported = results({ independent: true })
|
|
103
|
+
reported[1] = { problemId: 'P2', solved: false, minutes: 15 }
|
|
104
|
+
const result = await grade.execute({ results: reported })
|
|
105
|
+
assert.equal(result.score, 4)
|
|
106
|
+
assert.equal(result.verified, true, 'the existing weighted pass line remains valid; unsolved problems do not prove mastery')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('explicit independence never certifies a failing or overtime paper', async () => {
|
|
111
|
+
for (const [reported, outcome] of [
|
|
112
|
+
[results({ independent: true, solved: false }), 'failed'],
|
|
113
|
+
[results({ independent: true, minutes: 40 }), 'overTime'],
|
|
114
|
+
]) await fixture(async ({ grade, ledger }) => {
|
|
115
|
+
const result = await grade.execute({ results: reported })
|
|
116
|
+
assert.equal(result.outcome, outcome)
|
|
117
|
+
assert.equal(result.verified, false)
|
|
118
|
+
assert.equal(result.status, 'studying')
|
|
119
|
+
assert.equal(result.verifiedAt, 0)
|
|
120
|
+
assert.equal(independentExit(ledger()).passed, false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('unknown or assisted passing records preserve historical verified and learned states', async () => {
|
|
125
|
+
const previousCheck = { date: '2026-09-01', outcome: 'passed', score: 6, maxScore: 6, passScore: 4,
|
|
126
|
+
minutes: 45, limit: 90, verifiedAt: 1600, selfReported: 0, problems: [] }
|
|
127
|
+
for (const status of ['verified', 'learned']) await fixture(async ({ grade, ledger }) => {
|
|
128
|
+
const result = await grade.execute({ results: results() })
|
|
129
|
+
assert.equal(result.status, status)
|
|
130
|
+
assert.equal(result.verified, false, 'status may be historical; verified describes this attempt only')
|
|
131
|
+
assert.equal(result.verifiedAt, 0)
|
|
132
|
+
assert.equal(ledger().nodes.A.at, '2026-09-01')
|
|
133
|
+
assert.deepEqual(ledger().nodes.A.checks[0], previousCheck)
|
|
134
|
+
assert.equal(ledger().nodes.A.checks.length, 2)
|
|
135
|
+
}, { status, at: '2026-09-01', checks: [previousCheck] })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('malformed grading fields and contradictory IDs are rejected without changing the pending paper', async () => {
|
|
139
|
+
await fixture(async ({ grade, bytes }) => {
|
|
140
|
+
const before = bytes()
|
|
141
|
+
for (const invalid of [
|
|
142
|
+
{ independent: 'true' }, { independent: null }, { solved: 'true' },
|
|
143
|
+
{ minutes: -1 }, { minutes: Infinity }, { minutes: Number.MAX_VALUE }, { minutes: '15' }, { problemId: '' }, { problemId: 'P2' },
|
|
144
|
+
]) {
|
|
145
|
+
const reported = results()
|
|
146
|
+
Object.assign(reported[0], invalid)
|
|
147
|
+
let rejected
|
|
148
|
+
try { rejected = await grade.execute({ results: reported }) }
|
|
149
|
+
catch (error) {
|
|
150
|
+
assert.equal(error.code, 'INVALID_ARGS', 'the native SDK may reject malformed types before tool execution')
|
|
151
|
+
}
|
|
152
|
+
if (rejected) {
|
|
153
|
+
assert.equal(rejected.ok, false, JSON.stringify(invalid))
|
|
154
|
+
assert.equal(rejected.verified, false)
|
|
155
|
+
assert.equal(rejected.independence, 'unknown')
|
|
156
|
+
for (const key of grade.output.schema.required) assert.ok(key in rejected, `missing refusal field ${key}`)
|
|
157
|
+
}
|
|
158
|
+
assert.equal(bytes(), before)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
})
|
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,18 @@ 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_schedule' && Array.isArray(ra?.blockKinds) && Array.isArray(rb?.blockKinds) && rb.blockKinds.length === 3) {
|
|
139
|
+
const added = ra.blockKinds.slice(3)
|
|
140
|
+
if (JSON.stringify(added) === JSON.stringify([{ kind: 'diagnostic', minutes: 15, name: '短诊断' }, { kind: 'vp', minutes: 300, name: '完整 VP' }])) ra.blockKinds = ra.blockKinds.slice(0, 3)
|
|
141
|
+
}
|
|
142
|
+
if (tool === 'coach_status' && rb && !('teachingContext' in rb) && ra?.teachingContext === '') {
|
|
143
|
+
delete ra.teachingContext
|
|
144
|
+
}
|
|
137
145
|
const sa = JSON.stringify(ra ?? { ERR: ea }), sb = JSON.stringify(rb ?? { ERR: eb })
|
|
138
146
|
if (sa === sb) { check(`${tool} · ${label}`, true) }
|
|
139
147
|
else {
|
|
@@ -154,8 +162,17 @@ for (const [tool, args, label] of CALLS.filter((c) => c[0] !== 'coach_diagnose')
|
|
|
154
162
|
try { va = await ta.execute(args, undefined) } catch { continue }
|
|
155
163
|
try { vb = await tb.execute(args, undefined) } catch { continue }
|
|
156
164
|
const ra = strip(ta.output.render(args, va)[0]?.text ?? '')
|
|
157
|
-
const
|
|
158
|
-
|
|
165
|
+
const legacyText = tb.output.render(args, vb)[0]?.text ?? ''
|
|
166
|
+
// The old lecture label overstated its evidence; only this intentional label changes.
|
|
167
|
+
const rb = strip(tool === 'coach_status'
|
|
168
|
+
? legacyText.replace(/ 讲于 (\d{4}-\d{2}-\d{2})/g, ' 接触记录 $1(不证明理解)')
|
|
169
|
+
: legacyText)
|
|
170
|
+
// Only the changed goal-led advice is excluded; schedule facts still match byte for byte.
|
|
171
|
+
const scheduleAdvice = (text) => text
|
|
172
|
+
.replace('/diagnostic 15min(短诊断)/vp 300min(完整 VP)', '')
|
|
173
|
+
.replace(/^⚠️ \*\*复习别把新知识挤没了。\*\*.*剩下的时间留给推进。$/m, '<COACH_ADVICE>')
|
|
174
|
+
.replace(/^根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。上面的间隔只供判断;不要为了填满课表持续开新知识点。$/m, '<COACH_ADVICE>')
|
|
175
|
+
const same = tool === 'coach_schedule' ? scheduleAdvice(ra) === scheduleAdvice(rb) : ra === rb
|
|
159
176
|
check(`渲染 · ${tool} · ${label}`, same, same ? '' : '文本不一致')
|
|
160
177
|
}
|
|
161
178
|
|
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,9 @@ 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
|
|
72
|
+
run "grading" node --test dev/grading-check.mjs
|
|
73
|
+
run "training" node --test dev/training-check.mjs
|
|
70
74
|
run "VP-client" node dev/vp-client-check.mjs
|
|
71
75
|
run "VP-sync" node --test dev/vp-sync-check.mjs
|
|
72
76
|
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
|
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { createTeachingNotes } from '../lib/teaching.js'
|
|
7
|
+
|
|
8
|
+
const AT = '2026-10-11T10:00:00.000Z'
|
|
9
|
+
const EXEC = { agent: { session: { id: 'session-1' } } }
|
|
10
|
+
const QUOTE = '状态不需要保存选择顺序,只需要保存剩余容量。'
|
|
11
|
+
const userEvent = (overrides = {}) => ({
|
|
12
|
+
type: 'user/message', seq: 7,
|
|
13
|
+
data: { id: 'message-7', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: QUOTE }] },
|
|
14
|
+
...overrides,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
function fixture(t) {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), 'acmer-coach-teaching-'))
|
|
19
|
+
const path = join(root, 'PROGRESS.json')
|
|
20
|
+
const initial = {
|
|
21
|
+
version: 1, cursor: 'dp', updated: '2026-10-10',
|
|
22
|
+
nodes: { dp: { status: 'verified', taught: '2026-10-09', passes: [{ date: '2026-10-10' }] } },
|
|
23
|
+
pending: { node: 'graph', date: '2026-10-11', problems: ['fixture-problem'] },
|
|
24
|
+
pendingActions: { dp: { node: 'dp', deliverable: '写出状态含义' } },
|
|
25
|
+
verified_problems: { 'fixture-problem': { source: 'fixture' } },
|
|
26
|
+
}
|
|
27
|
+
writeFileSync(path, JSON.stringify(initial))
|
|
28
|
+
t.after(() => { unlinkSync(path); rmdirSync(root) })
|
|
29
|
+
const loadProgress = () => JSON.parse(readFileSync(path, 'utf8'))
|
|
30
|
+
const saveProgress = (p) => { writeFileSync(path, JSON.stringify(p)); return p }
|
|
31
|
+
const dependencies = {
|
|
32
|
+
loadProgress, saveProgress,
|
|
33
|
+
loadMap: () => ({ byId: new Map([['dp', { id: 'dp', name: '动态规划基础' }], ['graph', { id: 'graph', name: '最短路' }]]) }),
|
|
34
|
+
getSessionQuery: () => undefined,
|
|
35
|
+
now: () => AT,
|
|
36
|
+
}
|
|
37
|
+
return { initial, loadProgress, saveProgress, dependencies, create: () => createTeachingNotes(dependencies) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('续课笔记留在既有节点账本,重建后可读且不改变掌握与待测卷', async (t) => {
|
|
41
|
+
const f = fixture(t)
|
|
42
|
+
const result = await f.create().write({ summary: '状态含义还需检查', nextStep: '用三个物品的小例子让学生解释状态' })
|
|
43
|
+
assert.equal(result.ok, true)
|
|
44
|
+
assert.equal(result.node, 'dp')
|
|
45
|
+
assert.deepEqual(result.notes, [{ at: AT, summary: '状态含义还需检查', nextStep: '用三个物品的小例子让学生解释状态' }])
|
|
46
|
+
assert.deepEqual(f.create().read(), result)
|
|
47
|
+
const expected = structuredClone(f.initial)
|
|
48
|
+
expected.nodes.dp.lessonNotes = [{ ...result.notes[0], studentEvidence: null }]
|
|
49
|
+
assert.deepEqual(f.loadProgress(), expected)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('无效知识点、空白和超长笔记不能写入账本', async (t) => {
|
|
53
|
+
const f = fixture(t)
|
|
54
|
+
const notes = f.create()
|
|
55
|
+
const valid = { summary: '仍需辨析状态', nextStep: '解释一个反例' }
|
|
56
|
+
for (const change of [
|
|
57
|
+
{ node: 'unknown' }, { node: 123 }, { summary: ' ' }, { summary: 'x'.repeat(1001) },
|
|
58
|
+
{ nextStep: '' }, { nextStep: 'x'.repeat(501) }, { studentQuote: ' ' },
|
|
59
|
+
{ studentQuote: 'x'.repeat(1501) },
|
|
60
|
+
]) {
|
|
61
|
+
const result = await notes.write({ ...valid, ...change })
|
|
62
|
+
assert.equal(result.ok, false, JSON.stringify(change))
|
|
63
|
+
assert.deepEqual(result.notes, [])
|
|
64
|
+
assert.equal(result.total, 0)
|
|
65
|
+
assert.deepEqual(f.loadProgress(), f.initial)
|
|
66
|
+
}
|
|
67
|
+
for (const limit of [0, -1, 21, 1.5, '5']) assert.equal(notes.read({ limit }).ok, false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('学生原话通过当前原生会话核对并保存稳定出处', async (t) => {
|
|
71
|
+
const f = fixture(t)
|
|
72
|
+
let queried
|
|
73
|
+
f.dependencies.getSessionQuery = () => ({ readSession: async (id) => {
|
|
74
|
+
queried = id
|
|
75
|
+
return { session: { id }, events: [userEvent()] }
|
|
76
|
+
} })
|
|
77
|
+
const result = await f.create().write({ summary: '能区分容量和顺序,尚未检查代码', nextStep: '独立实现转移', studentQuote: QUOTE }, EXEC)
|
|
78
|
+
assert.equal(queried, 'session-1')
|
|
79
|
+
assert.equal(result.ok, true)
|
|
80
|
+
assert.deepEqual(result.notes[0].studentEvidence, { sessionId: 'session-1', seq: 7, messageId: 'message-7', quote: QUOTE })
|
|
81
|
+
assert.equal(f.loadProgress().nodes.dp.status, 'verified')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('伪造原话、插件消息、跨会话内容或缺失出处不能成为学生依据', async (t) => {
|
|
85
|
+
const invalidEvent = [
|
|
86
|
+
userEvent({ data: { ...userEvent().data, content: [{ type: 'text', text: '别的答复' }] } }),
|
|
87
|
+
userEvent({ data: { ...userEvent().data, source: { kind: 'plugin:coach' } } }),
|
|
88
|
+
userEvent({ data: { ...userEvent().data, role: 'assistant' } }),
|
|
89
|
+
userEvent({ seq: -1 }), userEvent({ seq: undefined }),
|
|
90
|
+
userEvent({ data: { ...userEvent().data, id: '' } }),
|
|
91
|
+
userEvent({ type: 'compaction/message' }),
|
|
92
|
+
userEvent({ data: { ...userEvent().data, content: [{ type: 'image', text: QUOTE }] } }),
|
|
93
|
+
]
|
|
94
|
+
for (const event of invalidEvent) {
|
|
95
|
+
const f = fixture(t)
|
|
96
|
+
f.dependencies.getSessionQuery = () => ({ readSession: async () => ({ session: { id: 'session-1' }, events: [event] }) })
|
|
97
|
+
const result = await f.create().write({ summary: '判断', nextStep: '继续', studentQuote: QUOTE }, EXEC)
|
|
98
|
+
assert.equal(result.ok, false)
|
|
99
|
+
assert.deepEqual(f.loadProgress(), f.initial)
|
|
100
|
+
}
|
|
101
|
+
const f = fixture(t)
|
|
102
|
+
const params = { summary: '判断', nextStep: '继续', studentQuote: QUOTE }
|
|
103
|
+
assert.equal((await f.create().write(params, EXEC)).ok, false, '无会话查询服务必须拒绝')
|
|
104
|
+
f.dependencies.getSessionQuery = () => ({ readSession: async () => ({ session: { id: 'another-session' }, events: [userEvent()] }) })
|
|
105
|
+
assert.equal((await f.create().write(params, EXEC)).ok, false)
|
|
106
|
+
assert.equal((await f.create().write(params)).ok, false, '无当前调用会话必须拒绝')
|
|
107
|
+
f.dependencies.getSessionQuery = () => ({ readSession: async () => { throw new Error('unavailable') } })
|
|
108
|
+
assert.equal((await f.create().write(params, EXEC)).ok, false)
|
|
109
|
+
assert.deepEqual(f.loadProgress(), f.initial)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('没有游标时找最近一次续课,默认只读最近五条并保留全部历史', async (t) => {
|
|
113
|
+
const f = fixture(t)
|
|
114
|
+
const notes = f.create()
|
|
115
|
+
for (let i = 1; i <= 6; i++) await notes.write({ summary: `第${i}次讲解`, nextStep: '继续解释转移' })
|
|
116
|
+
assert.equal(notes.read().total, 6)
|
|
117
|
+
assert.equal(notes.read().notes.length, 5)
|
|
118
|
+
assert.equal(notes.read().notes[0].summary, '第2次讲解')
|
|
119
|
+
f.dependencies.now = () => '2026-10-11T11:00:00.000Z'
|
|
120
|
+
await f.create().write({ node: 'graph', summary: '图上松弛还要澄清', nextStep: '比较两条路径' })
|
|
121
|
+
const progress = f.loadProgress()
|
|
122
|
+
progress.cursor = ''
|
|
123
|
+
progress.pendingActions = {}
|
|
124
|
+
f.saveProgress(progress)
|
|
125
|
+
assert.equal(f.create().read().node, 'graph')
|
|
126
|
+
assert.equal(f.create().read({ node: 'dp', limit: 20 }).notes.length, 6)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('续课上下文短且标明教练判断,核对原话不等于证明掌握', async (t) => {
|
|
130
|
+
const f = fixture(t)
|
|
131
|
+
const notes = f.create()
|
|
132
|
+
assert.equal(notes.context(f.loadProgress()), '')
|
|
133
|
+
await notes.write({ summary: '状态含义还需辨析'.repeat(100), nextStep: '用小例子解释状态'.repeat(60) })
|
|
134
|
+
const context = notes.context(f.loadProgress())
|
|
135
|
+
assert.ok(context.length <= 700)
|
|
136
|
+
assert.match(context, /教练判断/)
|
|
137
|
+
assert.match(context, /下次接续/)
|
|
138
|
+
assert.equal(notes.context(f.loadProgress(), 'graph'), '')
|
|
139
|
+
f.dependencies.getSessionQuery = () => ({ readSession: async () => ({ session: { id: 'session-1' }, events: [userEvent()] }) })
|
|
140
|
+
await f.create().write({ summary: '对状态有一个合理解释', nextStep: '独立写出转移', studentQuote: QUOTE }, EXEC)
|
|
141
|
+
const grounded = f.create().context(f.loadProgress())
|
|
142
|
+
assert.match(grounded, /原话已核对/)
|
|
143
|
+
assert.match(grounded, /不代表掌握/)
|
|
144
|
+
assert.match(grounded, /独立写出转移/)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('原话查询期间其他工具保存的进度不会被旧快照覆盖', async (t) => {
|
|
148
|
+
const f = fixture(t)
|
|
149
|
+
let release
|
|
150
|
+
f.dependencies.getSessionQuery = () => ({ readSession: () => new Promise((resolve) => { release = resolve }) })
|
|
151
|
+
const writing = f.create().write({ summary: '状态解释待验证', nextStep: '写出转移', studentQuote: QUOTE }, EXEC)
|
|
152
|
+
const updated = f.loadProgress()
|
|
153
|
+
updated.cursor = 'graph'
|
|
154
|
+
updated.nodes.graph = { status: 'studying', checks: [{ outcome: 'needs-work' }] }
|
|
155
|
+
updated.pendingActions.dp.deliverable = '更新后的动作'
|
|
156
|
+
f.saveProgress(updated)
|
|
157
|
+
release({ session: { id: 'session-1' }, events: [userEvent()] })
|
|
158
|
+
assert.equal((await writing).ok, true)
|
|
159
|
+
const saved = f.loadProgress()
|
|
160
|
+
assert.equal(saved.cursor, 'graph')
|
|
161
|
+
assert.deepEqual(saved.nodes.graph, updated.nodes.graph)
|
|
162
|
+
assert.deepEqual(saved.pendingActions, updated.pendingActions)
|
|
163
|
+
assert.deepEqual(saved.pending, updated.pending)
|
|
164
|
+
assert.deepEqual(saved.verified_problems, updated.verified_problems)
|
|
165
|
+
assert.equal(saved.nodes.dp.lessonNotes.length, 1)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('续课优先最近未完动作的笔记,显式读取和默认写入不受选择影响', async (t) => {
|
|
169
|
+
const f = fixture(t)
|
|
170
|
+
await f.create().write({ summary: '游标节点的旧笔记', nextStep: '继续状态解释' })
|
|
171
|
+
const progress = f.loadProgress()
|
|
172
|
+
progress.pendingActions.graph = { node: 'graph', deliverable: '解释松弛' }
|
|
173
|
+
f.saveProgress(progress)
|
|
174
|
+
f.dependencies.now = () => '2026-10-11T11:00:00.000Z'
|
|
175
|
+
await f.create().write({ node: 'graph', summary: '刚讲到路径比较', nextStep: '继续比较两条路径' })
|
|
176
|
+
const notes = f.create()
|
|
177
|
+
assert.equal(notes.read().node, 'graph')
|
|
178
|
+
assert.match(notes.context(f.loadProgress()), /刚讲到路径比较/)
|
|
179
|
+
assert.equal(notes.read({ node: 'dp' }).node, 'dp')
|
|
180
|
+
assert.match(notes.context(f.loadProgress(), 'dp'), /游标节点的旧笔记/)
|
|
181
|
+
f.dependencies.now = () => '2026-10-11T12:00:00.000Z'
|
|
182
|
+
assert.equal((await f.create().write({ summary: '仍写入当前游标', nextStep: '继续游标节点' })).node, 'dp')
|
|
183
|
+
assert.equal(f.loadProgress().cursor, 'dp')
|
|
184
|
+
assert.deepEqual(f.loadProgress().pendingActions, progress.pendingActions)
|
|
185
|
+
assert.equal(f.create().read().node, 'dp', '多个未完动作按笔记时间选最近')
|
|
186
|
+
const finished = f.loadProgress()
|
|
187
|
+
delete finished.pendingActions.graph
|
|
188
|
+
delete finished.pendingActions.dp
|
|
189
|
+
f.saveProgress(finished)
|
|
190
|
+
assert.equal(f.create().read().node, 'dp', '没有未完动作回到当前游标')
|
|
191
|
+
})
|