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
|
@@ -85,6 +85,7 @@ export function collectEvidence(progress, schedule = { days: {} }) {
|
|
|
85
85
|
if (!seenIds.has(id)) {
|
|
86
86
|
seenIds.add(id)
|
|
87
87
|
const facts = {}
|
|
88
|
+
if (text(record.competency)) facts.competency = text(record.competency)
|
|
88
89
|
if (typeof record.independent === 'boolean') facts.independent = record.independent
|
|
89
90
|
if (record.occurredAt !== undefined) facts.occurredAt = record.occurredAt
|
|
90
91
|
const minutes = source === 'schedule' ? record.actualMin : record.minutes
|
|
@@ -141,13 +142,16 @@ export function collectEvidence(progress, schedule = { days: {} }) {
|
|
|
141
142
|
if (!day || !object(entry) || !Array.isArray(entry.actual)) continue
|
|
142
143
|
for (const record of entry.actual) {
|
|
143
144
|
const node = text(record?.node)
|
|
144
|
-
|
|
145
|
+
const competency = text(record?.competency)
|
|
146
|
+
if ((!node && !competency) || !practiceValid(record, record?.actualMin)) continue
|
|
145
147
|
if (progressPractice.has(practiceKey(day, node, record, record.actualMin))) continue
|
|
146
148
|
const signal = record.solved && record.independent ? 'baseline' : 'gap'
|
|
147
|
-
const label =
|
|
149
|
+
const label = competency && !node
|
|
150
|
+
? (record.solved ? (record.independent ? '独立达到本次交付物要求' : '借助帮助达到本次交付物要求') : '尚未达到本次交付物要求')
|
|
151
|
+
: record.solved ? (record.independent ? '独立做出' : '依赖提示或题解做出') : '尚未做出'
|
|
148
152
|
const summary = `${text(record.problemId) ? `${text(record.problemId)}:` : ''}${label},实际用时 ${record.actualMin} 分钟`
|
|
149
153
|
const locator = `${day}:${encodeURIComponent(text(record.problemId))}`
|
|
150
|
-
const eventId = `practice:${encodeURIComponent(node)}:${day}:${encodeURIComponent(text(record.problemId))}`
|
|
154
|
+
const eventId = `practice:${encodeURIComponent(node || `competency:${competency}`)}:${day}:${encodeURIComponent(text(record.problemId))}`
|
|
151
155
|
add('schedule', node, day, locator, eventId, signal, summary, record)
|
|
152
156
|
}
|
|
153
157
|
}
|
package/lib/curriculum.js
CHANGED
|
@@ -17,10 +17,10 @@ function validDate(value) {
|
|
|
17
17
|
const d = new Date(`${value}T12:00:00Z`)
|
|
18
18
|
return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === value
|
|
19
19
|
}
|
|
20
|
-
export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSchedule = () => ({ days: {} }), loadStrategyEvidence = () => [], now = () => new Date() }) {
|
|
20
|
+
export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSchedule = () => ({ days: {} }), loadCompetencies = () => [], loadStrategyEvidence = () => [], now = () => new Date() }) {
|
|
21
21
|
const path = join(dataDir, 'CURRICULUM.yaml')
|
|
22
22
|
const evidence = () => [
|
|
23
|
-
...collectEvidence(loadProgress(), loadSchedule()).filter((entry) => loadMap().byId.has(entry.node)),
|
|
23
|
+
...collectEvidence(loadProgress(), loadSchedule()).filter((entry) => loadMap().byId.has(entry.node) || loadCompetencies().some(item => item.id === entry.competency)),
|
|
24
24
|
...loadStrategyEvidence().filter((entry) => entry && typeof entry.id === 'string' && typeof entry.competency === 'string'),
|
|
25
25
|
]
|
|
26
26
|
function enter(doc, catalog) {
|
|
@@ -227,7 +227,9 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
227
227
|
return { ...snapshot(null), ok: false, reject: err.message }
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
-
function assignment({ node, revision, purpose = 'progress', reason = '' }) {
|
|
230
|
+
function assignment({ node = '', competency = '', revision, purpose = 'progress', reason = '' }) {
|
|
231
|
+
if (Boolean(node) === Boolean(competency)) return { ok: false, reject: 'node 与 competency 必须且只能指定一个。', binding: null }
|
|
232
|
+
if (competency && !loadCompetencies().some(item => item.id === competency)) return { ok: false, reject: 'competency 必须来自已有比赛能力目录。', binding: null }
|
|
231
233
|
const doc = load()
|
|
232
234
|
if (!doc) return { ok: true, reject: '', binding: null }
|
|
233
235
|
if (doc.status !== 'active') return { ok: false, reject: '课程已经完成,请先明确下一条路线。', binding: null }
|
|
@@ -237,14 +239,15 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
237
239
|
if (!['progress', 'remediation', 'review'].includes(purpose)) return { ok: false, reject: 'purpose 只能是 progress/remediation/review。', binding: null }
|
|
238
240
|
if (purpose === 'progress') {
|
|
239
241
|
if (state.focusExpired) return { ok: false, reject: '本周主线已到期,先复盘并 revise;不会自动换专题。', binding: null }
|
|
240
|
-
if (!state.focusNodes.includes(node)) return { ok: false, reject: `「${node}」不在当前阶段主线;调整走 revise,补漏/复习要声明 purpose 和 routeReason。`, binding: null }
|
|
242
|
+
if (competency ? !state.phaseCompetencies.includes(competency) : !state.focusNodes.includes(node)) return { ok: false, reject: `「${node || competency}」不在当前阶段主线;调整走 revise,补漏/复习要声明 purpose 和 routeReason。`, binding: null }
|
|
241
243
|
} else if (clean(reason).length < 10) return { ok: false, reject: '补漏/复习需至少 10 字的 routeReason,说明与当前阶段的关系和回归条件。', binding: null }
|
|
242
|
-
if (purpose === 'review' && !['learned', 'verified'].includes(loadProgress().nodes?.[node]?.status)) return { ok: false, reject: '复习节点应有学过或已验证的记录;首次学习不能伪装成复习。', binding: null }
|
|
243
|
-
if (doc.phases[doc.current].kind === 'training') {
|
|
244
|
+
if (purpose === 'review' && node && !['learned', 'verified'].includes(loadProgress().nodes?.[node]?.status)) return { ok: false, reject: '复习节点应有学过或已验证的记录;首次学习不能伪装成复习。', binding: null }
|
|
245
|
+
if (node && doc.phases[doc.current].kind === 'training') {
|
|
244
246
|
const missing = prerequisitesOf([node], loadMap().byId).filter((dependency) => !knownNodes(loadProgress()).has(dependency))
|
|
245
247
|
if (missing.length) return { ok: false, reject: `当前训练还缺显式前置记录:${missing.join('、')};游标不会自动证明前置已掌握。`, binding: null }
|
|
246
248
|
}
|
|
247
|
-
const
|
|
249
|
+
const actionKey = node || `competency:${competency}`
|
|
250
|
+
const other = state.pendingNodes.filter((n) => n !== actionKey)
|
|
248
251
|
if (other.length) return { ok: false, reject: `当前单元还有未完成动作:${other.join('、')}。先完成或撤销,避免又换一节。`, binding: null }
|
|
249
252
|
return { ok: true, reject: '', binding: { revision: doc.revision, phaseId: state.phaseId,
|
|
250
253
|
phaseTitle: state.phaseTitle, purpose, reason: clean(reason) } }
|
package/lib/strategy.js
CHANGED
|
@@ -199,9 +199,16 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
|
|
|
199
199
|
const saveTarget = (doc) => writeYaml(targetPath, '# TARGET.yaml — 比赛目标与能力优先级\n', doc)
|
|
200
200
|
const saveVp = (doc) => writeYaml(vpPath, '# VP_EVENTS.yaml — VP 记录与赛后复盘\n', doc)
|
|
201
201
|
const loadCompetencies = () => readYaml(competenciesPath, () => ({ version: 1, competencies: [] })).competencies ?? []
|
|
202
|
-
const loadProfiles = () => readYaml(contestProfilesPath, () => ({ profiles: [] })).profiles ?? []
|
|
202
|
+
const loadProfiles = () => (readYaml(contestProfilesPath, () => ({ profiles: [] })).profiles ?? []).map(profile => {
|
|
203
|
+
const samples = asArray(profile.samples)
|
|
204
|
+
return { ...profile, samples, coverage: {
|
|
205
|
+
sampledProblems: samples.length, reviewedSolutions: samples.filter(sample => sample.solutionReviewed === true).length,
|
|
206
|
+
years: [...new Set(samples.map(sample => sample.year))].sort(), isSyllabus: false,
|
|
207
|
+
} }
|
|
208
|
+
})
|
|
203
209
|
const profileFor = id => loadProfiles().find(item => item.id === id) ?? { id: '', title: '', scope: '', status: 'unselected',
|
|
204
|
-
sources: [], competencyIds: [], selectionGuidance: [],
|
|
210
|
+
sources: [], competencyIds: [], selectionGuidance: [], samples: [], coverage: { sampledProblems: 0, reviewedSolutions: 0, years: [], isSyllabus: false },
|
|
211
|
+
limitations: ['未选择比赛样本配置;不能把通用能力卡当作本场考纲。'] }
|
|
205
212
|
|
|
206
213
|
function target(action = 'read', args = {}) {
|
|
207
214
|
let old
|
|
@@ -356,19 +363,13 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
|
|
|
356
363
|
const node = clean(nodeOrId)
|
|
357
364
|
let targetDoc
|
|
358
365
|
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
|
|
366
|
+
const base = { node, purpose, courseBound: Boolean(courseBound), targetAligned: false, competencyIds: [] }
|
|
367
|
+
if (!targetDoc.contest) return { ok: true, reject: '', ...base }
|
|
361
368
|
const catalog = loadCompetencies()
|
|
362
369
|
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
370
|
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) }
|
|
371
|
+
// 能力映射只报事实;优先级排名不能替教练决定这一课。
|
|
372
|
+
return { ok: true, reject: '', ...base, targetAligned: targetCards.length > 0, competencyIds: targetCards.map((item) => item.id) }
|
|
372
373
|
}
|
|
373
374
|
|
|
374
375
|
function evidence() {
|
|
@@ -401,7 +402,7 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
|
|
|
401
402
|
return [
|
|
402
403
|
`- 比赛目标:${t.contest},目标日期 ${t.date},结果目标 ${t.result},团队模式 ${t.teamMode}`,
|
|
403
404
|
`- 距目标日期:${f.daysRemaining}天;剩余时间影响训练取舍,不能只给所有能力乘同一紧急系数。`,
|
|
404
|
-
`- 比赛样本配置:${f.profile.title || '尚未选择'}(${f.profile.status}
|
|
405
|
+
`- 比赛样本配置:${f.profile.title || '尚未选择'}(${f.profile.status});覆盖 ${f.profile.coverage.sampledProblems} 题,已核对题解 ${f.profile.coverage.reviewedSolutions} 题;不是完整考纲。${f.profile.limitations.join(';')}`,
|
|
405
406
|
`- 当前目标权重:${t.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`,
|
|
406
407
|
f.focus?.length ? `- 本轮优先能力:${f.focus.map((x) => `${x.title}(${x.whyNow})`).join(';')}` : '- 当前没有可计算的能力重点。',
|
|
407
408
|
f.postponed?.length ? `- 暂缓能力:${f.postponed.join('、')}` : '',
|
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
|
+
}
|
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.6.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/grading-check.mjs dev/training-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 = ''
|