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
|
@@ -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
|
+
})
|
package/dev/vp-sync-check.mjs
CHANGED
|
@@ -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') {
|
package/dev.sh
CHANGED
|
@@ -35,7 +35,7 @@ done
|
|
|
35
35
|
# 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
|
|
36
36
|
# 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
|
|
37
37
|
# 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
|
|
38
|
-
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/workspace-migration.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/NODE_SCOPES.yaml assets/knowledge/CONTEST_PROFILES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs docs/长期课程机制.md docs/比赛与教学依据.md docs/设计方针.md dev.sh'
|
|
38
|
+
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/workspace-migration.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js lib/teaching.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/NODE_SCOPES.yaml assets/knowledge/CONTEST_PROFILES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/run-all.sh docs/长期课程机制.md docs/比赛与教学依据.md docs/设计方针.md dev.sh'
|
|
39
39
|
STALE=''
|
|
40
40
|
for f in $SYNC_FILES; do
|
|
41
41
|
for d in "${DSTS[@]}"; do
|
|
@@ -120,7 +120,7 @@ EVIDENCE_RC=${EVIDENCE_RC:-0}
|
|
|
120
120
|
|
|
121
121
|
echo
|
|
122
122
|
echo '── 策略、教学目录、真实工具与账号数据回归 ──'
|
|
123
|
-
node --test "$SRC_BASH/dev/strategy-check.mjs" "$SRC_BASH/dev/catalog-check.mjs" "$SRC_BASH/dev/coaching-contract-check.mjs" "$SRC_BASH/dev/workspace-migration-check.mjs" "$SRC_BASH/dev/vp-client-check.mjs" "$SRC_BASH/dev/vp-sync-check.mjs" || CONTRACT_RC=$?
|
|
123
|
+
node --test "$SRC_BASH/dev/strategy-check.mjs" "$SRC_BASH/dev/catalog-check.mjs" "$SRC_BASH/dev/coaching-contract-check.mjs" "$SRC_BASH/dev/teaching-check.mjs" "$SRC_BASH/dev/workspace-migration-check.mjs" "$SRC_BASH/dev/vp-client-check.mjs" "$SRC_BASH/dev/vp-sync-check.mjs" || CONTRACT_RC=$?
|
|
124
124
|
CONTRACT_RC=${CONTRACT_RC:-0}
|
|
125
125
|
|
|
126
126
|
LEDGER_AFTER=$(ledger_fp)
|
package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md
CHANGED
|
@@ -31,6 +31,37 @@ WF 扩展诊断只读了 [2024 World Finals 官方题面](https://icpc.global/wo
|
|
|
31
31
|
|
|
32
32
|
用户临时问新知识点时可以回答。回答不自动成为主线任务,也不自动改变游标或阶段。
|
|
33
33
|
|
|
34
|
+
## 2.5:给教练续课依据,不代替教练判断
|
|
35
|
+
|
|
36
|
+
本版只增加 `coach_note` 的读写入口,笔记放进现有 `PROGRESS.nodes.<id>.lessonNotes`。
|
|
37
|
+
记录是“当前教学判断 + 下一次接续点”;可选学生原话通过 dsh 原生 `sessionQuery`
|
|
38
|
+
核对当前会话的真实用户消息,来源由程序生成。插件消息或压缩摘要不能成为学生原话。
|
|
39
|
+
核对存在不证明理解,笔记也不改变游标、掌握状态、待办或检测结果。
|
|
40
|
+
|
|
41
|
+
`coach_status` 和原有会话首步注入只提供短笔记,详细材料按需读取。
|
|
42
|
+
默认读取优先接续最近有笔记的未完动作,再看游标;无游标时看最近笔记。
|
|
43
|
+
写入默认落在当前游标,指定节点时使用该节点,不改变游标本身。
|
|
44
|
+
教学进展或中断值得保存时才写,不要求每轮留笔记,不规定固定工具顺序。
|
|
45
|
+
能力排名作为参考,前三之外或尚未映射到能力的节点仍允许教练安排;
|
|
46
|
+
课程、前置和写入证据的检查保留。`teachMinutes` 是计划,不自动生成已教学日期,
|
|
47
|
+
实际开始学习仍由教练显式记录;旧记录保留,不重新推断发生过什么。
|
|
48
|
+
|
|
49
|
+
这个取舍是结合本插件所做的设计判断:
|
|
50
|
+
|
|
51
|
+
* [Anthropic:Building effective agents](https://www.anthropic.com/engineering/building-effective-agents)
|
|
52
|
+
与 [OpenAI:A practical guide to building agents](https://openai.com/business/guides-and-resources/a-practical-guide-to-building-ai-agents/)
|
|
53
|
+
都建议从简单、可验证的方案开始。因此沿用小鲸这个 agent,未增加多教练编排或框架。
|
|
54
|
+
* [Anthropic:Effective context engineering](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)
|
|
55
|
+
支持保留必要摘要、按需获取详情;本版沿用原进度文件与宿主会话查询,不复制聊天数据库。
|
|
56
|
+
* [Anthropic:Writing tools for agents](https://www.anthropic.com/engineering/writing-tools-for-agents)
|
|
57
|
+
强调清楚、职责明确的工具与真实任务评测;本版把续课笔记与已有检测、日程工具分开,
|
|
58
|
+
不用固定调用次序判断教练是否正确。
|
|
59
|
+
* [Anthropic:Demystifying evals for AI agents](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents)
|
|
60
|
+
区分任务结果与交互质量;工具回归只能验证记录和来源,不能据此声称学生学习效果已证实。
|
|
61
|
+
|
|
62
|
+
验证覆盖跨模块实例读取、原话来源、拒绝伪引用和并行更新时保留已有进度。
|
|
63
|
+
没有做真实学生的长期对照试验,也没有把笔记数量当教学质量分数。
|
|
64
|
+
|
|
34
65
|
## 四个明确的教学边界
|
|
35
66
|
|
|
36
67
|
最短路卡提供有向边 `s→a:2`、`s→b:5`、`b→a:-4`:确定后不再更新的 Dijkstra 会过早确定 a;真实最短距离是 1。算法仍偶尔得到对的结果不能证明它适用于负权图。[技术依据](https://oi-wiki.org/graph/shortest-path/)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
## 一、一句话命题
|
|
9
9
|
|
|
10
|
-
>
|
|
10
|
+
> **教练把比赛目标、当下理解和下一步教学连起来。**
|
|
11
11
|
>
|
|
12
12
|
> 它站在局外、手里有地图、且不投入自尊。
|
|
13
13
|
> 在局里的人看不见河的全貌 —— 这跟聪明程度无关,是位置问题。
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
排优先级需要「什么重要、什么先什么后、哪里是坑」——
|
|
17
17
|
而这些恰好是初学者没有的知识。**这是循环依赖,必须由外部打破。**
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
资料和题目已经很多。教练要选择重点、讲清推理、核对理解,
|
|
20
|
+
根据真实回应调整讲法,并在下次接着教;排顺序只是其中一项。
|
|
20
21
|
|
|
21
22
|
---
|
|
22
23
|
|
|
@@ -116,9 +117,9 @@ verified 是有日期和条件的检测结论,不是永久标签。阶段验
|
|
|
116
117
|
「今天说了明天又忘」不是记忆问题,是**强制力问题**。治法见第五节。
|
|
117
118
|
|
|
118
119
|
**但这条有边界,得说清楚**:结构只能强制**机械可判**的那一半。
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
新知识点的工具可以要求留出讲解预算(`teachMinutes`),但预算只证明有计划,
|
|
121
|
+
不能核实授课完成或学生理解。续课笔记保存教学线索,原话核对也只证明消息存在。
|
|
122
|
+
讲法、重点与复习取舍交给教练;程序约束证据来源、账本写入和任务执行边界。
|
|
122
123
|
|
|
123
124
|
### 原则 6:命名是终点,不是入口(讲题规范)
|
|
124
125
|
|
package/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// acmer-coach
|
|
2
2
|
// ══════════════════════════════════════════════════════════════════
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 小鲸教练插件。目标:保存训练位置与可核查的证据,
|
|
4
|
+
// 让教练据此决定讲法、练习和复习,避免跨会话丢进度。
|
|
5
5
|
//
|
|
6
6
|
// 注册走 bundle 层:package.json 声明 `dsh.bundle.patch`,由
|
|
7
7
|
// cordis.patch.yml 里那行 insert 挂上去。
|
|
@@ -31,13 +31,14 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
31
31
|
import { initDataDir, syncCodeforces, contactEvidence, importPool, importRecords } from './lib/setup.js'
|
|
32
32
|
import { createCurriculumStore, curriculumTool } from './lib/curriculum.js'
|
|
33
33
|
import { createStrategyStore } from './lib/strategy.js'
|
|
34
|
+
import { createTeachingNotes } from './lib/teaching.js'
|
|
34
35
|
import { createVpClient } from './lib/vp-client.js'
|
|
35
36
|
import { migratePersonalWorkspace as migrateWorkspacePersonalFiles } from './lib/workspace-migration.js'
|
|
36
37
|
|
|
37
38
|
const name = 'acmer-coach'
|
|
38
39
|
const inject = ['tools']
|
|
39
40
|
|
|
40
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.5.0'
|
|
41
42
|
|
|
42
43
|
// 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
|
|
43
44
|
// 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
|
|
@@ -993,7 +994,7 @@ function nodeFlags(progress, byId) {
|
|
|
993
994
|
// 判据 = 进度文件里**有没有关于这个节点的记录**。缺省即未学(见 nodeStatus:
|
|
994
995
|
// 只有非未学的节点才会被写进文件),所以"记录存在"本身就说明有东西发生过:
|
|
995
996
|
// · `rec.at` —— 教练标过「在学」的那天(见 markStatus:只在状态真变时更新)
|
|
996
|
-
// · `taught` ——
|
|
997
|
+
// · `taught` —— 旧版讲解计划留下的日期,保留兼容,不证明实际讲过
|
|
997
998
|
// · `passes` —— 他打过卡(coach_log)
|
|
998
999
|
// · `checks` —— 判过一次卷(coach_grade)
|
|
999
1000
|
//
|
|
@@ -1008,9 +1009,7 @@ function taughtEvidence(progress, id) {
|
|
|
1008
1009
|
if (!rec) return null
|
|
1009
1010
|
const dates = [
|
|
1010
1011
|
String(rec.at ?? ''),
|
|
1011
|
-
//
|
|
1012
|
-
// coach_status 会一直报「还没有教学记录」,而"游标节点豁免先讲闸门"这条
|
|
1013
|
-
// 也就永远没法按"讲过没"来判断(这个漏洞就是从这儿反推出来的)。
|
|
1012
|
+
// 兼容旧版接触线索;新计划不再写 taught,续课笔记也不算掌握证据。
|
|
1014
1013
|
...(Array.isArray(rec.taught) ? rec.taught.map((t) => String(t ?? '')) : []),
|
|
1015
1014
|
...(Array.isArray(rec.passes) ? rec.passes.map((p) => String(p?.date ?? '')) : []),
|
|
1016
1015
|
...(Array.isArray(rec.checks) ? rec.checks.map((c) => String(c?.date ?? '')) : []),
|
|
@@ -1670,7 +1669,7 @@ function registerPages(ctx) {
|
|
|
1670
1669
|
// 它要防的是"规则被长对话压掉",而规则在 AGENTS.md 里 —— 那是 system prompt,
|
|
1671
1670
|
// 压根不参与压缩。真正会丢的是**位置**,而 pre-step 每轮都重注一遍,
|
|
1672
1671
|
// 压掉的下一轮就回来了。所以那半条是多余的。
|
|
1673
|
-
function registerStateHook(ctx) {
|
|
1672
|
+
function registerStateHook(ctx, teaching) {
|
|
1674
1673
|
ctx.on('agent/pre-step', async ({ messages, step }, next) => {
|
|
1675
1674
|
const decision = await next()
|
|
1676
1675
|
try {
|
|
@@ -1684,13 +1683,14 @@ function registerStateHook(ctx) {
|
|
|
1684
1683
|
const studying = Object.entries(p.nodes ?? {})
|
|
1685
1684
|
.filter(([, r]) => r?.status === 'studying')
|
|
1686
1685
|
.map(([id]) => id)
|
|
1686
|
+
const teachingContext = teaching.context(p)
|
|
1687
1687
|
|
|
1688
1688
|
// 课程可以先于游标存在;即使尚未布置动作,也要在新会话继续同一路线。
|
|
1689
1689
|
let hasCurriculum = false
|
|
1690
1690
|
try { hasCurriculum = Boolean(curriculum.load()) } catch { hasCurriculum = true }
|
|
1691
1691
|
let hasTarget = false
|
|
1692
1692
|
try { hasTarget = Boolean(strategy.loadTarget().contest) } catch { hasTarget = true }
|
|
1693
|
-
if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget) return decision
|
|
1693
|
+
if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget && !teachingContext) return decision
|
|
1694
1694
|
|
|
1695
1695
|
const { byId } = loadMap()
|
|
1696
1696
|
const nameOf = (id) => byId.get(String(id))?.name ?? String(id)
|
|
@@ -1698,6 +1698,7 @@ function registerStateHook(ctx) {
|
|
|
1698
1698
|
const lines = ['<coach_state>', '(教练插件注入的位置,不用再调工具问)']
|
|
1699
1699
|
lines.push(strategy.context())
|
|
1700
1700
|
lines.push(curriculum.context())
|
|
1701
|
+
if (teachingContext) lines.push(teachingContext)
|
|
1701
1702
|
lines.push(cursor
|
|
1702
1703
|
? `- 游标:${nameOf(cursor)}(${cursor})${node?.domain ? ` [${node.domain}]` : ''}`
|
|
1703
1704
|
: '- 游标:**还没设** —— 先问清他现在在哪个知识点,用 coach_set_cursor 设上')
|
|
@@ -1951,6 +1952,43 @@ function apply(ctx) {
|
|
|
1951
1952
|
} catch { /* 没这服务 / 没这文件 → 不注,照常加载 */ }
|
|
1952
1953
|
|
|
1953
1954
|
ctx.tools.register(defineTool(curriculumTool(curriculum)))
|
|
1955
|
+
const teaching = createTeachingNotes({ loadProgress, saveProgress, loadMap,
|
|
1956
|
+
getSessionQuery: () => ctx.get?.('sessionQuery') })
|
|
1957
|
+
ctx.tools.register(defineTool({
|
|
1958
|
+
name: 'coach_note',
|
|
1959
|
+
description: '读取或保存简短续课笔记:讲到哪里、教练的判断、下次怎样接着讲。复用节点进度账本;不改掌握状态或自动决定下一课。' +
|
|
1960
|
+
'studentQuote 可选;提供时自动核对当前会话的学生原话,核对存在不代表已经理解。无回应依据时写明尚未核对,不编造表现。',
|
|
1961
|
+
parameters: {
|
|
1962
|
+
action: { type: 'string', enum: ['read', 'write'], description: '默认 read;write 追加一条笔记。' },
|
|
1963
|
+
node: { type: 'string', description: '地图节点 id。写入默认游标;读取默认最近有笔记的未完动作,其次游标,无游标时读最近笔记。显式指定始终优先。' },
|
|
1964
|
+
limit: { type: 'integer', description: '读取最近几条,默认 5,最多 20。' },
|
|
1965
|
+
summary: { type: 'string', description: 'write 必填,实际讲授位置和教练判断,最多 1000 字;区分观察与推测。' },
|
|
1966
|
+
nextStep: { type: 'string', description: 'write 必填,下次续课建议,最多 500 字;后续教练可据新表现调整。' },
|
|
1967
|
+
studentQuote: { type: 'string', description: '可选,当前会话学生实际发过的一段原话,最多 1500 字;不需要手输消息 id。' },
|
|
1968
|
+
},
|
|
1969
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
1970
|
+
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true },
|
|
1971
|
+
node: { type: 'string', required: true }, total: { type: 'integer', required: true },
|
|
1972
|
+
notes: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
1973
|
+
at: { type: 'string', required: true }, summary: { type: 'string', required: true }, nextStep: { type: 'string', required: true },
|
|
1974
|
+
studentEvidence: { type: 'object', additionalProperties: false, properties: {
|
|
1975
|
+
sessionId: { type: 'string', required: true }, seq: { type: 'integer', required: true },
|
|
1976
|
+
messageId: { type: 'string', required: true }, quote: { type: 'string', required: true },
|
|
1977
|
+
} },
|
|
1978
|
+
} } },
|
|
1979
|
+
} }, render: (_args, v) => [{ type: 'text', text: !v.ok ? `❌ ${v.reject}` : v.notes.length
|
|
1980
|
+
? `续课笔记 · ${v.node}(最近 ${v.notes.length} / 共 ${v.total} 条)\n` + v.notes.map(note =>
|
|
1981
|
+
`${note.at}\n教练记录:${note.summary}\n续课建议:${note.nextStep}\n` +
|
|
1982
|
+
(note.studentEvidence ? `学生原话:「${note.studentEvidence.quote}」(存在已核对,理解程度仍由教练判断)`
|
|
1983
|
+
: '尚无学生回应依据;这条是教练笔记。')).join('\n\n')
|
|
1984
|
+
: `「${v.node || '当前节点'}」尚无续课笔记。` }] },
|
|
1985
|
+
execute: async (args = {}, exec) => {
|
|
1986
|
+
const action = args.action ?? 'read'
|
|
1987
|
+
if (action === 'read') return teaching.read(args)
|
|
1988
|
+
if (action === 'write') return teaching.write(args, exec)
|
|
1989
|
+
return { ok: false, reject: 'action 只能是 read 或 write。', node: '', notes: [], total: 0 }
|
|
1990
|
+
},
|
|
1991
|
+
}))
|
|
1954
1992
|
|
|
1955
1993
|
// ── 目标驱动策略层:比赛目标 / VP 证据 / 复盘 / 当前重点 ─────────
|
|
1956
1994
|
// 课程图回答“有哪些知识点”,策略层回答“为什么现在学这个”。两者分开,
|
|
@@ -2747,7 +2785,7 @@ function apply(ctx) {
|
|
|
2747
2785
|
(v.suggestions.length ? `\n相近的有:${v.suggestions.join(' / ')}` : '\n也没找到相近的,确认一下写法。'),
|
|
2748
2786
|
}]
|
|
2749
2787
|
}
|
|
2750
|
-
const TIER_TAG = { core: '★核心', normal: '常见', rare: '少见', skip: '
|
|
2788
|
+
const TIER_TAG = { core: '★核心', normal: '常见', rare: '少见', skip: '默认暂缓' }
|
|
2751
2789
|
// (需求 #4):**每个节点都要出 entry**。
|
|
2752
2790
|
// 原来是 `c.entry ? ' ≈'+c.entry : ''` —— 于是「没数据」和「够不着」
|
|
2753
2791
|
// 在输出里长得一模一样(都是不显示),很容易把"没数据"读成
|
|
@@ -2990,7 +3028,7 @@ function apply(ctx) {
|
|
|
2990
3028
|
'它一次只收一个节点,所以**给不出候选列表** —— 这正是设计意图。' +
|
|
2991
3029
|
'有长期课程时先读 coach_curriculum,带 curriculumRevision;主线必须属于当前阶段,' +
|
|
2992
3030
|
'补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。' +
|
|
2993
|
-
'
|
|
3031
|
+
'比赛目标与重点排序是决策参考,不会限制在排名前三;结合学生表现说明 why。' +
|
|
2994
3032
|
'补漏/复习必须说明 routeReason。',
|
|
2995
3033
|
parameters: {
|
|
2996
3034
|
cursor: {
|
|
@@ -3013,7 +3051,8 @@ function apply(ctx) {
|
|
|
3013
3051
|
'**先讲一遍**的时间(分钟)。开一个他从没碰过的知识点时**必须给**,' +
|
|
3014
3052
|
'不给会被拒绝 —— 直接扔题让他自己卡,那是给学过的人的练法。' +
|
|
3015
3053
|
'讲多久**你自己定**(不参考任何固定值),看你判断他需要多少。' +
|
|
3016
|
-
`最低 ${TEACH_FLOOR_MIN}
|
|
3054
|
+
`最低 ${TEACH_FLOOR_MIN} 分钟,没有上限。已经在学或学过的节点不用给。` +
|
|
3055
|
+
'这是计划时长,不能证明已经讲过或学生理解了。',
|
|
3017
3056
|
},
|
|
3018
3057
|
why: { type: 'string', description: '为什么挑它(一句话,进台账,供训练员质疑时举证)' },
|
|
3019
3058
|
curriculumRevision: { type: 'integer', description: '有效课程 read 返回的 revision;无课程时可不填。' },
|
|
@@ -3163,7 +3202,7 @@ function apply(ctx) {
|
|
|
3163
3202
|
const targetRoute = strategy.route(node, { purpose: args?.purpose, courseBound: Boolean(route.binding) })
|
|
3164
3203
|
if (!targetRoute.ok) return deny(targetRoute.reject)
|
|
3165
3204
|
if (targetRoute.competencyIds.length) {
|
|
3166
|
-
why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '
|
|
3205
|
+
why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '目标能力对应的训练动作'}`
|
|
3167
3206
|
}
|
|
3168
3207
|
|
|
3169
3208
|
// ③ 未建课程时沿用直接后继;有课程时允许当前阶段跨分支,仍检查前置。
|
|
@@ -3271,18 +3310,7 @@ function apply(ctx) {
|
|
|
3271
3310
|
totalMinutes: net + teach + TAIL_MIN, why,
|
|
3272
3311
|
}
|
|
3273
3312
|
if (route.binding) pend[node].curriculum = route.binding
|
|
3274
|
-
//
|
|
3275
|
-
// 在节点上记一条 `taught` 日期 —— 它是「讲过没」唯一的结构化痕迹。
|
|
3276
|
-
// 在这之前讲完**不留任何记录**:coach_status 永远报「还没有教学记录」,
|
|
3277
|
-
// 游标节点那条豁免也就没法按"讲过没"来判断(漏洞就是从这儿反推出来的)。
|
|
3278
|
-
// 记的是"讲授段排上了"这一天,不是"讲得多好" —— 回答"讲过没"够用了。
|
|
3279
|
-
if (teach > 0) {
|
|
3280
|
-
const rec = { ...(progA.nodes[node] ?? {}) }
|
|
3281
|
-
const taught = Array.isArray(rec.taught) ? rec.taught.slice() : []
|
|
3282
|
-
if (!taught.includes(assignedAt)) taught.push(assignedAt)
|
|
3283
|
-
rec.taught = taught
|
|
3284
|
-
progA.nodes[node] = rec
|
|
3285
|
-
}
|
|
3313
|
+
// 布置讲解只保存计划;接触状态与理解证据由各自入口记录。
|
|
3286
3314
|
progA.pendingActions = pend
|
|
3287
3315
|
let pendingRecorded = true
|
|
3288
3316
|
try { saveProgress(progA) } catch { pendingRecorded = false }
|
|
@@ -3332,6 +3360,7 @@ function apply(ctx) {
|
|
|
3332
3360
|
// 让"讲过没"对教练**可查** —— 判据跟 coach_test 开卷时用的同一个
|
|
3333
3361
|
// 函数(taughtEvidence),不另写一份。需求 #5 的验收 ③。
|
|
3334
3362
|
cursorTaught: { type: 'string', required: true },
|
|
3363
|
+
teachingContext: { type: 'string', required: true },
|
|
3335
3364
|
domain: { type: 'string', required: true },
|
|
3336
3365
|
updated: { type: 'string', required: true },
|
|
3337
3366
|
verifiedCount: { type: 'integer', required: true },
|
|
@@ -3489,9 +3518,10 @@ function apply(ctx) {
|
|
|
3489
3518
|
// 十有八九就是游标这个节点。埋在别处等于没报。(需求 #5 验收 ③)
|
|
3490
3519
|
(v.cursorName
|
|
3491
3520
|
? (v.cursorTaught
|
|
3492
|
-
? `
|
|
3521
|
+
? ` 接触记录 ${v.cursorTaught}(不证明理解)`
|
|
3493
3522
|
: ` ⚠️ **还没有教学记录** —— 现在开卷会被拒,先讲`)
|
|
3494
3523
|
: ''),
|
|
3524
|
+
v.teachingContext,
|
|
3495
3525
|
// 押着的卷放最前面之一:它是个**卡住下一步的约束**(有新卷就开不了),
|
|
3496
3526
|
// 埋在底下等于没报。
|
|
3497
3527
|
v.pendingNode
|
|
@@ -3640,6 +3670,7 @@ function apply(ctx) {
|
|
|
3640
3670
|
cursor: p.cursor,
|
|
3641
3671
|
cursorName: byId.get(p.cursor)?.name ?? '',
|
|
3642
3672
|
cursorTaught: taughtEvidence(p, p.cursor)?.first ?? '',
|
|
3673
|
+
teachingContext: teaching.context(p),
|
|
3643
3674
|
domain: byId.get(p.cursor)?.domain ?? '',
|
|
3644
3675
|
updated: p.updated,
|
|
3645
3676
|
verifiedCount: verified.length,
|
|
@@ -5052,7 +5083,7 @@ function apply(ctx) {
|
|
|
5052
5083
|
const deny = (reject) => ({
|
|
5053
5084
|
ok: false, reject, node: '', nodeName: '', domain: '',
|
|
5054
5085
|
outcome: '', score: 0, maxScore: 0, passScore: PASS_SCORE,
|
|
5055
|
-
minutes: 0, limit: 0, status: '', verifiedAt: 0, problems: [], checks: 0,
|
|
5086
|
+
minutes: 0, limit: 0, status: '', verifiedAt: 0, selfReported: 0, problems: [], checks: 0,
|
|
5056
5087
|
})
|
|
5057
5088
|
|
|
5058
5089
|
const prog = loadProgress()
|
|
@@ -5593,10 +5624,8 @@ function apply(ctx) {
|
|
|
5593
5624
|
L.push(` ${r.nodeName} ${tag} ${ago}${rec}`)
|
|
5594
5625
|
}
|
|
5595
5626
|
L.push('',
|
|
5596
|
-
'
|
|
5597
|
-
'
|
|
5598
|
-
'做新题难受),**所以那个平衡得你来把**:复习块按上面的火候排,' +
|
|
5599
|
-
'剩下的时间留给推进。')
|
|
5627
|
+
'根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。' +
|
|
5628
|
+
'上面的间隔只供判断;不要为了填满课表持续开新知识点。')
|
|
5600
5629
|
}
|
|
5601
5630
|
L.push('', '要排就用 coach_plan,**一次只排他说的那一天**。')
|
|
5602
5631
|
return [{ type: 'text', text: L.join('\n') }]
|
|
@@ -6097,8 +6126,8 @@ function apply(ctx) {
|
|
|
6097
6126
|
},
|
|
6098
6127
|
}))
|
|
6099
6128
|
|
|
6100
|
-
//
|
|
6101
|
-
registerStateHook(ctx)
|
|
6129
|
+
// 每个 turn 注入当前位置与续课线索,教学判断仍交给小鲸。
|
|
6130
|
+
registerStateHook(ctx, teaching)
|
|
6102
6131
|
|
|
6103
6132
|
// 技能树页面(可选注入 —— 没有 webServer 的环境跳过,工具照常工作)
|
|
6104
6133
|
ctx.inject(['webServer'], (sctx) => { registerPages(sctx) })
|
package/lib/strategy.js
CHANGED
|
@@ -356,19 +356,13 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
|
|
|
356
356
|
const node = clean(nodeOrId)
|
|
357
357
|
let targetDoc
|
|
358
358
|
try { targetDoc = loadTarget() } catch (err) { return { ok: false, reject: err.message, node, purpose, courseBound: Boolean(courseBound), targetAligned: false, competencyIds: [] } }
|
|
359
|
-
const base = { node, purpose, courseBound: Boolean(courseBound), targetAligned:
|
|
360
|
-
if (!targetDoc.contest
|
|
359
|
+
const base = { node, purpose, courseBound: Boolean(courseBound), targetAligned: false, competencyIds: [] }
|
|
360
|
+
if (!targetDoc.contest) return { ok: true, reject: '', ...base }
|
|
361
361
|
const catalog = loadCompetencies()
|
|
362
362
|
const priorityIds = new Set(targetDoc.priorities.map((item) => item.id))
|
|
363
|
-
const current = focus()
|
|
364
|
-
const currentIds = new Set((current.focus ?? []).map((item) => item.id))
|
|
365
363
|
const targetCards = catalog.filter((item) => priorityIds.has(item.id) && item.nodes?.includes(node))
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (mappedTarget.length && !currentCards.length) {
|
|
369
|
-
return { ok: false, reject: `「${node}」不服务当前比赛重点;当前重点是 ${current.focus.map((item) => item.id).join('、')}。如果这是课程主线,请带 curriculumRevision;如果是补漏/复习,请声明 purpose 和 routeReason。`, ...base, targetAligned: false, competencyIds: targetCards.map((item) => item.id) }
|
|
370
|
-
}
|
|
371
|
-
return { ok: true, reject: '', ...base, targetAligned: currentCards.length > 0, competencyIds: currentCards.map((item) => item.id) }
|
|
364
|
+
// 能力映射只报事实;优先级排名不能替教练决定这一课。
|
|
365
|
+
return { ok: true, reject: '', ...base, targetAligned: targetCards.length > 0, competencyIds: targetCards.map((item) => item.id) }
|
|
372
366
|
}
|
|
373
367
|
|
|
374
368
|
function evidence() {
|
package/lib/teaching.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Short lesson continuity notes; teaching judgments never change mastery records. */
|
|
2
|
+
export function createTeachingNotes({ loadProgress, saveProgress, loadMap, getSessionQuery, now = () => new Date().toISOString() }) {
|
|
3
|
+
const failure = (reject, node = '') => ({ ok: false, reject, node, notes: [], total: 0 })
|
|
4
|
+
const publicNote = ({ studentEvidence, ...note }) => studentEvidence ? { ...note, studentEvidence } : note
|
|
5
|
+
const mostRecentNoteNode = (progress, ids) => {
|
|
6
|
+
let latest = '', latestAt = ''
|
|
7
|
+
for (const id of ids) {
|
|
8
|
+
const at = progress.nodes[id]?.lessonNotes?.at(-1)?.at
|
|
9
|
+
if (at && at >= latestAt) { latest = id; latestAt = at }
|
|
10
|
+
}
|
|
11
|
+
return latest
|
|
12
|
+
}
|
|
13
|
+
const resolveNode = (progress, node) => {
|
|
14
|
+
if (node !== undefined) return typeof node === 'string' ? node.trim() : ''
|
|
15
|
+
return mostRecentNoteNode(progress, Object.keys(progress.pendingActions || {}))
|
|
16
|
+
|| progress.cursor || mostRecentNoteNode(progress, Object.keys(progress.nodes))
|
|
17
|
+
}
|
|
18
|
+
const meaningful = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max
|
|
19
|
+
|
|
20
|
+
function read({ node, limit = 5 } = {}) {
|
|
21
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 20) return failure('读取条数须为 1 到 20 的整数。')
|
|
22
|
+
const progress = loadProgress()
|
|
23
|
+
node = resolveNode(progress, node)
|
|
24
|
+
if (!node || !loadMap().byId.has(node)) return failure('请指定有效的知识点或先设置游标。', node)
|
|
25
|
+
const notes = progress.nodes[node]?.lessonNotes || []
|
|
26
|
+
return { ok: true, reject: '', node, notes: notes.slice(-limit).map(publicNote), total: notes.length }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function write({ node, summary, nextStep, studentQuote }, exec) {
|
|
30
|
+
if (!meaningful(summary, 1000) || !meaningful(nextStep, 500)) return failure('教学摘要和下次接续必须有内容,分别最多 1000 和 500 字。')
|
|
31
|
+
if (studentQuote !== undefined && !meaningful(studentQuote, 1500)) return failure('学生原话必须有内容,最多 1500 字。')
|
|
32
|
+
let progress = loadProgress()
|
|
33
|
+
node = node === undefined ? (progress.cursor || '') : typeof node === 'string' ? node.trim() : ''
|
|
34
|
+
if (!node || !loadMap().byId.has(node)) return failure('请指定有效的知识点或先设置游标。', node)
|
|
35
|
+
let studentEvidence = null
|
|
36
|
+
if (studentQuote !== undefined) {
|
|
37
|
+
const sessionId = exec?.agent?.session?.id
|
|
38
|
+
if (!meaningful(sessionId, 500)) return failure('无法核对学生原话:当前调用缺少会话标识。', node)
|
|
39
|
+
try {
|
|
40
|
+
const query = getSessionQuery?.()
|
|
41
|
+
if (typeof query?.readSession !== 'function') return failure('无法核对学生原话:宿主未提供会话查询服务。', node)
|
|
42
|
+
const snapshot = await query.readSession(sessionId)
|
|
43
|
+
if (snapshot?.session?.id && snapshot.session.id !== sessionId) return failure('会话查询返回了其他会话,未写入笔记。', node)
|
|
44
|
+
const event = Array.isArray(snapshot?.events) && snapshot.events.findLast((event) => {
|
|
45
|
+
const message = event.data
|
|
46
|
+
return event.type === 'user/message' && message?.role === 'user' && message.source?.kind === 'user'
|
|
47
|
+
&& Number.isInteger(event.seq) && event.seq >= 0 && meaningful(message.id, 500)
|
|
48
|
+
&& Array.isArray(message.content) && message.content.filter((block) => block.type === 'text' && typeof block.text === 'string').map((block) => block.text).join('\n').includes(studentQuote)
|
|
49
|
+
})
|
|
50
|
+
if (!event) return failure('当前会话中没有可核对的这段学生原话,未写入笔记。', node)
|
|
51
|
+
studentEvidence = { sessionId, seq: event.seq, messageId: event.data.id, quote: studentQuote }
|
|
52
|
+
} catch {
|
|
53
|
+
return failure('会话查询失败,学生原话尚未核对,未写入笔记。', node)
|
|
54
|
+
}
|
|
55
|
+
// Session lookup yields; keep any progress another tool saved in the meantime.
|
|
56
|
+
progress = loadProgress()
|
|
57
|
+
}
|
|
58
|
+
const record = progress.nodes[node] || {}
|
|
59
|
+
progress.nodes[node] = { ...record, lessonNotes: [...(record.lessonNotes || []), { at: now(), summary: summary.trim(), nextStep: nextStep.trim(), studentEvidence }] }
|
|
60
|
+
saveProgress(progress)
|
|
61
|
+
return read({ node })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function context(progress, preferredNode) {
|
|
65
|
+
const node = resolveNode(progress, preferredNode)
|
|
66
|
+
const note = progress.nodes[node]?.lessonNotes?.at(-1)
|
|
67
|
+
if (!note) return ''
|
|
68
|
+
const clip = (text, length) => text.replace(/\s+/g, ' ').slice(0, length)
|
|
69
|
+
const lines = [
|
|
70
|
+
`续课笔记 · ${clip(node, 60)} · ${note.at}`,
|
|
71
|
+
`教练判断:${clip(note.summary, 200)}`,
|
|
72
|
+
note.studentEvidence ? `学生原话已核对(不代表掌握):${clip(note.studentEvidence.quote, 120)}` : '尚无学生原话依据;这是教练的教学观察。',
|
|
73
|
+
`下次接续建议(可调整):${clip(note.nextStep, 220)}`,
|
|
74
|
+
]
|
|
75
|
+
return lines.join('\n')
|
|
76
|
+
}
|
|
77
|
+
return { read, write, context }
|
|
78
|
+
}
|