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
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,26 +31,27 @@ 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.6.0'
|
|
41
42
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
43
|
+
// 默认题目循环:40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
|
|
44
|
+
// 小诊断和完整 VP 使用独立时长,不强加题目循环的收尾。
|
|
44
45
|
//
|
|
45
46
|
// ⚠️ **单位统一(需求 #7)**:系统里说「40 分钟」,一律指
|
|
46
47
|
// **他自己动手做题的净时间** —— 止损点就是它。收尾那 20 分钟(遗留 10 +
|
|
47
48
|
// 重写 10)**固定**、不随净时间压缩。
|
|
48
|
-
// 净 40(默认)→ 整块 60
|
|
49
|
+
// 净 40(默认)→ 整块 60;显式小任务可缩短净时间
|
|
49
50
|
// coach_assign 的参数曾经是"总时长"(工具内部减 20 才是自己做),而
|
|
50
51
|
// coach_plan 的 cycle 和规则文件里的 40 都是"自己做" —— 同一个数两个意思,
|
|
51
52
|
// 报时间线就会卡在这儿(传 40,实际只拿到 20 分钟自己做)。
|
|
52
53
|
const NET_DEFAULT_MIN = 40 // 自己做:默认
|
|
53
|
-
const NET_FLOOR_MIN =
|
|
54
|
+
const NET_FLOOR_MIN = 1 // 实际时间盒由教练按任务决定
|
|
54
55
|
const TAIL_MIN = 20 // 解决遗留(10) + 重写(10),不随净时间压缩
|
|
55
56
|
const CYCLE_TOTAL_MIN = NET_DEFAULT_MIN + TAIL_MIN // 60:题目循环的整块长度(plan 的 cycle 就是它)
|
|
56
57
|
|
|
@@ -83,6 +84,7 @@ function createStores(vpDir) {
|
|
|
83
84
|
})
|
|
84
85
|
curriculum = createCurriculumStore({
|
|
85
86
|
dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
|
|
87
|
+
loadCompetencies: () => strategy.loadCompetencies(),
|
|
86
88
|
loadStrategyEvidence: () => strategy.evidence().evidence,
|
|
87
89
|
})
|
|
88
90
|
}
|
|
@@ -467,12 +469,13 @@ function setScheduleField(date, field, value) {
|
|
|
467
469
|
// 而排程本身是**教学判断**(先补哪个点、排多密、今天要不要减量),
|
|
468
470
|
// 那正是教练存在的理由,不该退回给训练员。
|
|
469
471
|
//
|
|
470
|
-
//
|
|
471
|
-
// 传了就会冒出「排个 37 分钟的循环块」,而 40/10/10 的止损点全靠这个时长守着。
|
|
472
|
+
// 块类型保留已有默认时长,允许教练按任务明确整块时长。
|
|
472
473
|
const SCHED_KINDS = {
|
|
473
474
|
cycle: { min: 60, name: '题目循环' },
|
|
474
475
|
review: { min: 25, name: '复习一题' },
|
|
475
476
|
exam: { min: 90, name: '章节检测' },
|
|
477
|
+
diagnostic: { min: 15, name: '短诊断' },
|
|
478
|
+
vp: { min: 300, name: '完整 VP' },
|
|
476
479
|
}
|
|
477
480
|
|
|
478
481
|
// 网格范围。⚠️ **必须和 skilltree_template.html 里的 HOUR_FROM / HOUR_TO 逐字一致**:
|
|
@@ -624,13 +627,18 @@ function planDay(date, blocks, opts = {}) {
|
|
|
624
627
|
return
|
|
625
628
|
}
|
|
626
629
|
const node = String(b?.node ?? '').trim()
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
630
|
+
const competency = String(b?.competency ?? '').trim()
|
|
631
|
+
const identity = trainingIdentity(node, competency)
|
|
632
|
+
if (!identity.ok) {
|
|
633
|
+
errs.push(`${no}:${identity.reject}`)
|
|
631
634
|
return
|
|
632
635
|
}
|
|
633
|
-
|
|
636
|
+
const minutes = b?.minutes === undefined ? spec.min : b.minutes
|
|
637
|
+
if (!Number.isInteger(minutes) || minutes <= 0 || minutes > 960) {
|
|
638
|
+
errs.push(`${no}:minutes 必须是 1–960 的整数分钟。`)
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
out.push({ a, z: a + minutes, kind, node, competency, no })
|
|
634
642
|
})
|
|
635
643
|
|
|
636
644
|
// 排到网格外面的块,页面画不出来
|
|
@@ -671,6 +679,7 @@ function planDay(date, blocks, opts = {}) {
|
|
|
671
679
|
|
|
672
680
|
const planned = sorted.map((b) => ({
|
|
673
681
|
from: minHM(b.a), to: minHM(b.z), kind: b.kind, node: b.node,
|
|
682
|
+
...(b.competency ? { competency: b.competency } : {}),
|
|
674
683
|
}))
|
|
675
684
|
const w = setScheduleField(d, 'planned', planned)
|
|
676
685
|
if (!w.ok) return w
|
|
@@ -680,8 +689,9 @@ function planDay(date, blocks, opts = {}) {
|
|
|
680
689
|
planned: sorted.map((b) => ({
|
|
681
690
|
from: minHM(b.a), to: minHM(b.z), minutes: b.z - b.a,
|
|
682
691
|
kind: b.kind, kindName: SCHED_KINDS[b.kind].name,
|
|
683
|
-
node: b.node, nodeName:
|
|
684
|
-
domain:
|
|
692
|
+
node: b.node, nodeName: trainingIdentity(b.node, b.competency).name ?? b.node,
|
|
693
|
+
domain: trainingIdentity(b.node, b.competency).domain ?? '',
|
|
694
|
+
...(b.competency ? { competency: b.competency } : {}),
|
|
685
695
|
})),
|
|
686
696
|
// 只报「这天一共排了多少分钟」这个**事实**(他排完之后自己看表用)。
|
|
687
697
|
planMin,
|
|
@@ -756,11 +766,12 @@ function unplanDay(date, opts = {}) {
|
|
|
756
766
|
if (!w.ok) return w
|
|
757
767
|
|
|
758
768
|
const { byId } = loadMap()
|
|
759
|
-
const nameOf = (b) =>
|
|
769
|
+
const nameOf = (b) => trainingIdentity(String(b?.node ?? ''), String(b?.competency ?? '')).name ?? String(b?.node ?? '')
|
|
760
770
|
const pack = (b) => ({
|
|
761
771
|
from: String(b?.from ?? ''), to: String(b?.to ?? ''),
|
|
762
772
|
kind: String(b?.kind ?? ''), kindName: SCHED_KINDS[b?.kind]?.name ?? String(b?.kind ?? ''),
|
|
763
773
|
node: String(b?.node ?? ''), nodeName: nameOf(b),
|
|
774
|
+
...(b?.competency ? { competency: String(b.competency) } : {}),
|
|
764
775
|
})
|
|
765
776
|
return {
|
|
766
777
|
ok: true, reject: '', date: d,
|
|
@@ -847,7 +858,8 @@ function scheduleOverview(days = SCHED_WINDOW, fromDate = '') {
|
|
|
847
858
|
kind: String(b?.kind ?? ''),
|
|
848
859
|
kindName: SCHED_KINDS[b?.kind]?.name ?? String(b?.kind ?? ''),
|
|
849
860
|
node: String(b?.node ?? ''),
|
|
850
|
-
nodeName:
|
|
861
|
+
nodeName: trainingIdentity(String(b?.node ?? ''), String(b?.competency ?? '')).name ?? String(b?.node ?? ''),
|
|
862
|
+
...(b?.competency ? { competency: String(b.competency) } : {}),
|
|
851
863
|
}
|
|
852
864
|
})
|
|
853
865
|
const planMin = planned.reduce((t, b) => t + b.minutes, 0)
|
|
@@ -876,12 +888,13 @@ function scheduleOverview(days = SCHED_WINDOW, fromDate = '') {
|
|
|
876
888
|
const e = {
|
|
877
889
|
node: nodeId,
|
|
878
890
|
plannedMin: planned
|
|
879
|
-
.filter((b) => String(b.node) === nodeId)
|
|
891
|
+
.filter((b) => a?.competency ? b.competency === a.competency : String(b.node) === nodeId && !b.competency)
|
|
880
892
|
.reduce((t, b) => t + Number(b.minutes ?? 0), 0),
|
|
881
893
|
actualMin: Number(a?.actualMin ?? 0),
|
|
882
894
|
solved: a?.solved === true,
|
|
883
895
|
independent: a?.independent === true,
|
|
884
896
|
}
|
|
897
|
+
if (a?.competency) e.competency = String(a.competency)
|
|
885
898
|
if (a?.note) e.note = String(a.note)
|
|
886
899
|
return e
|
|
887
900
|
}),
|
|
@@ -906,6 +919,13 @@ function resolveCursor(explicit) {
|
|
|
906
919
|
return s || loadProgress().cursor
|
|
907
920
|
}
|
|
908
921
|
|
|
922
|
+
function trainingIdentity(node, competency) {
|
|
923
|
+
if (Boolean(node) === Boolean(competency)) return { ok: false, reject: 'node 与 competency 必须且只能指定一个。' }
|
|
924
|
+
const entry = competency ? strategy.loadCompetencies().find(item => item.id === competency) : loadMap().byId.get(node)
|
|
925
|
+
if (!entry) return { ok: false, reject: competency ? `能力目录中没有「${competency}」。` : `地图里没有「${node}」这个知识点,别自己造。先调 coach_next 看候选。` }
|
|
926
|
+
return { ok: true, key: node || `competency:${competency}`, name: entry.name ?? entry.title ?? node, domain: entry.domain ?? (competency ? '比赛能力' : '') }
|
|
927
|
+
}
|
|
928
|
+
|
|
909
929
|
// 状态读取。**缺省即未学** —— 只有非未学的节点才会被写进文件。
|
|
910
930
|
function nodeStatus(progress, id) {
|
|
911
931
|
const s = progress?.nodes?.[id]?.status
|
|
@@ -993,7 +1013,7 @@ function nodeFlags(progress, byId) {
|
|
|
993
1013
|
// 判据 = 进度文件里**有没有关于这个节点的记录**。缺省即未学(见 nodeStatus:
|
|
994
1014
|
// 只有非未学的节点才会被写进文件),所以"记录存在"本身就说明有东西发生过:
|
|
995
1015
|
// · `rec.at` —— 教练标过「在学」的那天(见 markStatus:只在状态真变时更新)
|
|
996
|
-
// · `taught` ——
|
|
1016
|
+
// · `taught` —— 旧版讲解计划留下的日期,保留兼容,不证明实际讲过
|
|
997
1017
|
// · `passes` —— 他打过卡(coach_log)
|
|
998
1018
|
// · `checks` —— 判过一次卷(coach_grade)
|
|
999
1019
|
//
|
|
@@ -1008,9 +1028,7 @@ function taughtEvidence(progress, id) {
|
|
|
1008
1028
|
if (!rec) return null
|
|
1009
1029
|
const dates = [
|
|
1010
1030
|
String(rec.at ?? ''),
|
|
1011
|
-
//
|
|
1012
|
-
// coach_status 会一直报「还没有教学记录」,而"游标节点豁免先讲闸门"这条
|
|
1013
|
-
// 也就永远没法按"讲过没"来判断(这个漏洞就是从这儿反推出来的)。
|
|
1031
|
+
// 兼容旧版接触线索;新计划不再写 taught,续课笔记也不算掌握证据。
|
|
1014
1032
|
...(Array.isArray(rec.taught) ? rec.taught.map((t) => String(t ?? '')) : []),
|
|
1015
1033
|
...(Array.isArray(rec.passes) ? rec.passes.map((p) => String(p?.date ?? '')) : []),
|
|
1016
1034
|
...(Array.isArray(rec.checks) ? rec.checks.map((c) => String(c?.date ?? '')) : []),
|
|
@@ -1670,7 +1688,7 @@ function registerPages(ctx) {
|
|
|
1670
1688
|
// 它要防的是"规则被长对话压掉",而规则在 AGENTS.md 里 —— 那是 system prompt,
|
|
1671
1689
|
// 压根不参与压缩。真正会丢的是**位置**,而 pre-step 每轮都重注一遍,
|
|
1672
1690
|
// 压掉的下一轮就回来了。所以那半条是多余的。
|
|
1673
|
-
function registerStateHook(ctx) {
|
|
1691
|
+
function registerStateHook(ctx, teaching) {
|
|
1674
1692
|
ctx.on('agent/pre-step', async ({ messages, step }, next) => {
|
|
1675
1693
|
const decision = await next()
|
|
1676
1694
|
try {
|
|
@@ -1684,13 +1702,14 @@ function registerStateHook(ctx) {
|
|
|
1684
1702
|
const studying = Object.entries(p.nodes ?? {})
|
|
1685
1703
|
.filter(([, r]) => r?.status === 'studying')
|
|
1686
1704
|
.map(([id]) => id)
|
|
1705
|
+
const teachingContext = teaching.context(p)
|
|
1687
1706
|
|
|
1688
1707
|
// 课程可以先于游标存在;即使尚未布置动作,也要在新会话继续同一路线。
|
|
1689
1708
|
let hasCurriculum = false
|
|
1690
1709
|
try { hasCurriculum = Boolean(curriculum.load()) } catch { hasCurriculum = true }
|
|
1691
1710
|
let hasTarget = false
|
|
1692
1711
|
try { hasTarget = Boolean(strategy.loadTarget().contest) } catch { hasTarget = true }
|
|
1693
|
-
if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget) return decision
|
|
1712
|
+
if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget && !teachingContext) return decision
|
|
1694
1713
|
|
|
1695
1714
|
const { byId } = loadMap()
|
|
1696
1715
|
const nameOf = (id) => byId.get(String(id))?.name ?? String(id)
|
|
@@ -1698,6 +1717,7 @@ function registerStateHook(ctx) {
|
|
|
1698
1717
|
const lines = ['<coach_state>', '(教练插件注入的位置,不用再调工具问)']
|
|
1699
1718
|
lines.push(strategy.context())
|
|
1700
1719
|
lines.push(curriculum.context())
|
|
1720
|
+
if (teachingContext) lines.push(teachingContext)
|
|
1701
1721
|
lines.push(cursor
|
|
1702
1722
|
? `- 游标:${nameOf(cursor)}(${cursor})${node?.domain ? ` [${node.domain}]` : ''}`
|
|
1703
1723
|
: '- 游标:**还没设** —— 先问清他现在在哪个知识点,用 coach_set_cursor 设上')
|
|
@@ -1951,6 +1971,43 @@ function apply(ctx) {
|
|
|
1951
1971
|
} catch { /* 没这服务 / 没这文件 → 不注,照常加载 */ }
|
|
1952
1972
|
|
|
1953
1973
|
ctx.tools.register(defineTool(curriculumTool(curriculum)))
|
|
1974
|
+
const teaching = createTeachingNotes({ loadProgress, saveProgress, loadMap,
|
|
1975
|
+
getSessionQuery: () => ctx.get?.('sessionQuery') })
|
|
1976
|
+
ctx.tools.register(defineTool({
|
|
1977
|
+
name: 'coach_note',
|
|
1978
|
+
description: '读取或保存简短续课笔记:讲到哪里、教练的判断、下次怎样接着讲。复用节点进度账本;不改掌握状态或自动决定下一课。' +
|
|
1979
|
+
'studentQuote 可选;提供时自动核对当前会话的学生原话,核对存在不代表已经理解。无回应依据时写明尚未核对,不编造表现。',
|
|
1980
|
+
parameters: {
|
|
1981
|
+
action: { type: 'string', enum: ['read', 'write'], description: '默认 read;write 追加一条笔记。' },
|
|
1982
|
+
node: { type: 'string', description: '地图节点 id。写入默认游标;读取默认最近有笔记的未完动作,其次游标,无游标时读最近笔记。显式指定始终优先。' },
|
|
1983
|
+
limit: { type: 'integer', description: '读取最近几条,默认 5,最多 20。' },
|
|
1984
|
+
summary: { type: 'string', description: 'write 必填,实际讲授位置和教练判断,最多 1000 字;区分观察与推测。' },
|
|
1985
|
+
nextStep: { type: 'string', description: 'write 必填,下次续课建议,最多 500 字;后续教练可据新表现调整。' },
|
|
1986
|
+
studentQuote: { type: 'string', description: '可选,当前会话学生实际发过的一段原话,最多 1500 字;不需要手输消息 id。' },
|
|
1987
|
+
},
|
|
1988
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
1989
|
+
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true },
|
|
1990
|
+
node: { type: 'string', required: true }, total: { type: 'integer', required: true },
|
|
1991
|
+
notes: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
1992
|
+
at: { type: 'string', required: true }, summary: { type: 'string', required: true }, nextStep: { type: 'string', required: true },
|
|
1993
|
+
studentEvidence: { type: 'object', additionalProperties: false, properties: {
|
|
1994
|
+
sessionId: { type: 'string', required: true }, seq: { type: 'integer', required: true },
|
|
1995
|
+
messageId: { type: 'string', required: true }, quote: { type: 'string', required: true },
|
|
1996
|
+
} },
|
|
1997
|
+
} } },
|
|
1998
|
+
} }, render: (_args, v) => [{ type: 'text', text: !v.ok ? `❌ ${v.reject}` : v.notes.length
|
|
1999
|
+
? `续课笔记 · ${v.node}(最近 ${v.notes.length} / 共 ${v.total} 条)\n` + v.notes.map(note =>
|
|
2000
|
+
`${note.at}\n教练记录:${note.summary}\n续课建议:${note.nextStep}\n` +
|
|
2001
|
+
(note.studentEvidence ? `学生原话:「${note.studentEvidence.quote}」(存在已核对,理解程度仍由教练判断)`
|
|
2002
|
+
: '尚无学生回应依据;这条是教练笔记。')).join('\n\n')
|
|
2003
|
+
: `「${v.node || '当前节点'}」尚无续课笔记。` }] },
|
|
2004
|
+
execute: async (args = {}, exec) => {
|
|
2005
|
+
const action = args.action ?? 'read'
|
|
2006
|
+
if (action === 'read') return teaching.read(args)
|
|
2007
|
+
if (action === 'write') return teaching.write(args, exec)
|
|
2008
|
+
return { ok: false, reject: 'action 只能是 read 或 write。', node: '', notes: [], total: 0 }
|
|
2009
|
+
},
|
|
2010
|
+
}))
|
|
1954
2011
|
|
|
1955
2012
|
// ── 目标驱动策略层:比赛目标 / VP 证据 / 复盘 / 当前重点 ─────────
|
|
1956
2013
|
// 课程图回答“有哪些知识点”,策略层回答“为什么现在学这个”。两者分开,
|
|
@@ -2178,6 +2235,11 @@ function apply(ctx) {
|
|
|
2178
2235
|
profile: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2179
2236
|
id: { type: 'string', required: true }, title: { type: 'string', required: true }, scope: { type: 'string', required: true }, status: { type: 'string', required: true },
|
|
2180
2237
|
sources: { type: 'array', required: true }, competencyIds: { type: 'array', required: true }, selectionGuidance: { type: 'array', required: true }, limitations: { type: 'array', required: true },
|
|
2238
|
+
samples: { type: 'array', required: true },
|
|
2239
|
+
coverage: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2240
|
+
sampledProblems: { type: 'integer', required: true }, reviewedSolutions: { type: 'integer', required: true },
|
|
2241
|
+
years: { type: 'array', required: true, items: { type: 'integer' } }, isSyllabus: { type: 'boolean', required: true },
|
|
2242
|
+
} },
|
|
2181
2243
|
} }, daysRemaining: { type: 'integer', required: true },
|
|
2182
2244
|
course: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2183
2245
|
exists: { type: 'boolean', required: true }, revision: { type: 'integer', required: true },
|
|
@@ -2205,7 +2267,7 @@ function apply(ctx) {
|
|
|
2205
2267
|
focusExpired: Boolean(snapshot.focusExpired), missingFreshEvidence: snapshot.missingFreshEvidence ?? [],
|
|
2206
2268
|
}
|
|
2207
2269
|
return { ...v, course, text: v.ok ? [`【训练取舍依据】长期:${course.goal || '尚未建立'}\n本次:${v.contest} · ${v.deadline} · 剩余${v.daysRemaining}天`,
|
|
2208
|
-
`样本:${v.profile.title || '未选择'}(${v.profile.status})\n适用边界:${v.profile.limitations.join(';')}`,
|
|
2270
|
+
`样本:${v.profile.title || '未选择'}(${v.profile.status})\n覆盖:${v.profile.coverage.sampledProblems}题,${v.profile.coverage.reviewedSolutions}题已核对题解,年份 ${v.profile.coverage.years.join('、') || '无'};不是完整考纲。\n适用边界:${v.profile.limitations.join(';')}`,
|
|
2209
2271
|
...v.focus.map((f, i) => `${i + 1}. ${f.title}(${f.id})\n 比赛价值:${f.contestUse}\n 为什么现在:${f.whyNow}\n 关联知识点:${f.nodes.join('、') || '按比赛能力训练'}\n 近30天场次:确认短板${f.gapCount} / 独立起点${f.baselineCount} / 待确认${f.observationCount}`),
|
|
2210
2272
|
course.exists ? `课程阶段:${course.phaseTitle}(${course.phaseId})\n课程主线:${course.focusNodes.join('、') || '能力证据驱动'}\n比赛能力:${course.phaseCompetencies.join('、') || '地图知识点'}` : '课程阶段:尚未建立,用 coach_curriculum assess/read/create 建立长期路线。',
|
|
2211
2273
|
course.pendingNodes.length ? `课程未完成动作:${course.pendingNodes.join('、')}` : '',
|
|
@@ -2747,7 +2809,7 @@ function apply(ctx) {
|
|
|
2747
2809
|
(v.suggestions.length ? `\n相近的有:${v.suggestions.join(' / ')}` : '\n也没找到相近的,确认一下写法。'),
|
|
2748
2810
|
}]
|
|
2749
2811
|
}
|
|
2750
|
-
const TIER_TAG = { core: '★核心', normal: '常见', rare: '少见', skip: '
|
|
2812
|
+
const TIER_TAG = { core: '★核心', normal: '常见', rare: '少见', skip: '默认暂缓' }
|
|
2751
2813
|
// (需求 #4):**每个节点都要出 entry**。
|
|
2752
2814
|
// 原来是 `c.entry ? ' ≈'+c.entry : ''` —— 于是「没数据」和「够不着」
|
|
2753
2815
|
// 在输出里长得一模一样(都是不显示),很容易把"没数据"读成
|
|
@@ -2979,41 +3041,40 @@ function apply(ctx) {
|
|
|
2979
3041
|
// ① node 必须在地图里 → 挡住它编知识点
|
|
2980
3042
|
// ② node 必须是游标的下一层 → 挡住它跳过前置乱指
|
|
2981
3043
|
// ③ 前置必须都满足 → 挡住「学这个之前你得先学那个」这类空头指令
|
|
2982
|
-
// ④
|
|
3044
|
+
// ④ 时间盒必须是一天内的正整数分钟
|
|
2983
3045
|
// 校验不过就**拒绝**,并把理由说清楚 —— 拒绝比给个错指令好。
|
|
2984
3046
|
ctx.tools.register(defineTool({
|
|
2985
3047
|
name: 'coach_assign',
|
|
2986
3048
|
description:
|
|
2987
3049
|
'布置**一个**训练动作。给训练员下指令时**必须**走这个工具,不要在正文里自由发挥 —— ' +
|
|
2988
|
-
'
|
|
2989
|
-
'
|
|
2990
|
-
'
|
|
3050
|
+
'它保存「做什么 + 时间盒 + 交付物」,支持地图节点或已有比赛能力:' +
|
|
3051
|
+
'节点需符合游标或课程主线且前置满足;纯能力训练不要求游标。诊断和 VP 不强加做题收尾。' +
|
|
3052
|
+
'它一次只收一个训练目标;时长由教练按任务决定。' +
|
|
2991
3053
|
'有长期课程时先读 coach_curriculum,带 curriculumRevision;主线必须属于当前阶段,' +
|
|
2992
3054
|
'补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。' +
|
|
2993
|
-
'
|
|
3055
|
+
'比赛目标与重点排序是决策参考,不会限制在排名前三;结合学生表现说明 why。' +
|
|
2994
3056
|
'补漏/复习必须说明 routeReason。',
|
|
2995
3057
|
parameters: {
|
|
2996
3058
|
cursor: {
|
|
2997
3059
|
type: 'string',
|
|
2998
3060
|
description: '当前游标。不填就用 PROGRESS.yaml 里的(这是常态)。',
|
|
2999
3061
|
},
|
|
3000
|
-
node: { type: 'string',
|
|
3062
|
+
node: { type: 'string', description: '地图知识点 id;与 competency 二选一。无课程时沿用游标,有课程时核查当前主线与前置。' },
|
|
3063
|
+
competency: { type: 'string', description: '已有比赛能力 id,例如 contest-reading;纯能力训练不要求游标或虚构地图节点。与 node 二选一。' },
|
|
3064
|
+
kind: { type: 'string', enum: ['practice', 'diagnostic', 'vp'], description: '默认 practice(保留做题与收尾);diagnostic/vp 的 minutes 是整项时长,不强加收尾或讲课。' },
|
|
3001
3065
|
deliverable: { type: 'string', required: true, description: '做完交什么,如「把代码贴给我」' },
|
|
3002
3066
|
minutes: {
|
|
3003
3067
|
type: 'number',
|
|
3004
|
-
description:
|
|
3005
|
-
`**他自己动手做题的净时间**(分钟)—— 止损点就是它,不是整块长度。` +
|
|
3006
|
-
`默认 ${NET_DEFAULT_MIN},最低 ${NET_FLOOR_MIN}。` +
|
|
3007
|
-
`整块长度 = 净时间 + ${TAIL_MIN}(解决遗留 10 + 重写 10,固定不压缩):` +
|
|
3008
|
-
`净 ${NET_DEFAULT_MIN} → 整块 ${CYCLE_TOTAL_MIN},和日程表上的 cycle 块对得上。`,
|
|
3068
|
+
description: '1–960 的整数分钟。地图 practice 是自己动手的净时间(默认40,另加20收尾);diagnostic/vp或纯能力动作是整项时长。',
|
|
3009
3069
|
},
|
|
3010
3070
|
teachMinutes: {
|
|
3011
3071
|
type: 'number',
|
|
3012
3072
|
description:
|
|
3013
|
-
'
|
|
3073
|
+
'practice 开全新地图知识点时先讲的时间(分钟);diagnostic/vp 不要求讲解。' +
|
|
3014
3074
|
'不给会被拒绝 —— 直接扔题让他自己卡,那是给学过的人的练法。' +
|
|
3015
3075
|
'讲多久**你自己定**(不参考任何固定值),看你判断他需要多少。' +
|
|
3016
|
-
|
|
3076
|
+
`给了讲解则最低 ${TEACH_FLOOR_MIN} 分钟,整项动作不得超过960分钟。已经在学或学过的节点不用给。` +
|
|
3077
|
+
'这是计划时长,不能证明已经讲过或学生理解了。',
|
|
3017
3078
|
},
|
|
3018
3079
|
why: { type: 'string', description: '为什么挑它(一句话,进台账,供训练员质疑时举证)' },
|
|
3019
3080
|
curriculumRevision: { type: 'integer', description: '有效课程 read 返回的 revision;无课程时可不填。' },
|
|
@@ -3030,6 +3091,7 @@ function apply(ctx) {
|
|
|
3030
3091
|
cursor: { type: 'string', required: true },
|
|
3031
3092
|
node: { type: 'string', required: true },
|
|
3032
3093
|
nodeName: { type: 'string', required: true },
|
|
3094
|
+
competency: { type: 'string' },
|
|
3033
3095
|
domain: { type: 'string', required: true },
|
|
3034
3096
|
// 需求 #7:minutes 是**净时间**(他自己做多久);整块 = minutes + tailMinutes
|
|
3035
3097
|
minutes: { type: 'integer', required: true },
|
|
@@ -3072,7 +3134,9 @@ function apply(ctx) {
|
|
|
3072
3134
|
return [{
|
|
3073
3135
|
type: 'text',
|
|
3074
3136
|
text: `❌ 这条指令布置不了:${v.reject}\n\n` +
|
|
3075
|
-
|
|
3137
|
+
(v.competency
|
|
3138
|
+
? '不要用候选列表代替指令;按拒绝原因核对能力、课程版本或时长,再调本工具。'
|
|
3139
|
+
: `**不要**改用候选列表交差 —— 先调 coach_next 重新挑一个就绪的,再调本工具。`),
|
|
3076
3140
|
}]
|
|
3077
3141
|
}
|
|
3078
3142
|
const table = v.blocks
|
|
@@ -3083,7 +3147,7 @@ function apply(ctx) {
|
|
|
3083
3147
|
text: [
|
|
3084
3148
|
`【今天这一个动作】${v.nodeName} [${v.domain}]`,
|
|
3085
3149
|
``,
|
|
3086
|
-
`做什么 ${v.nodeName}
|
|
3150
|
+
`做什么 ${v.nodeName}(${v.competency ? `比赛能力 ${v.competency}` : `地图节点 ${v.node}`})`,
|
|
3087
3151
|
// 需求 #7:两个数都印出来 ——「同一个 40 两个意思」就是这条要治的病。
|
|
3088
3152
|
`时间盒 自己做 ${v.minutes} + 收尾 ${v.tailMinutes} = 共 ${v.minutes + v.tailMinutes} 分钟` +
|
|
3089
3153
|
(v.teachMinutes ? `(另有先讲 ${v.teachMinutes} 分钟,全场合计 ${v.totalMinutes})` : ''),
|
|
@@ -3113,12 +3177,15 @@ function apply(ctx) {
|
|
|
3113
3177
|
const { byId, dependents, stamp } = loadMap()
|
|
3114
3178
|
const cursor = resolveCursor(args?.cursor)
|
|
3115
3179
|
const node = String(args?.node ?? '').trim()
|
|
3180
|
+
const competency = String(args?.competency ?? '').trim()
|
|
3181
|
+
const identity = trainingIdentity(node, competency)
|
|
3116
3182
|
const deliverable = String(args?.deliverable ?? '').trim()
|
|
3183
|
+
const kind = String(args?.kind ?? 'practice').trim()
|
|
3184
|
+
const tail = kind === 'practice' && !competency ? TAIL_MIN : 0
|
|
3117
3185
|
let why = String(args?.why ?? '').trim()
|
|
3118
3186
|
// 需求 #7:这个数现在是**净时间**(他自己动手做多久),不是整块长度。
|
|
3119
3187
|
// 整块 = 净 + TAIL_MIN(收尾固定 20),下面 blocks 和 totalMinutes 都照这个算。
|
|
3120
|
-
const net =
|
|
3121
|
-
? Math.trunc(args.minutes) : NET_DEFAULT_MIN
|
|
3188
|
+
const net = args?.minutes === undefined ? NET_DEFAULT_MIN : args.minutes
|
|
3122
3189
|
|
|
3123
3190
|
// 校验失败一律走这里:回一个带原因的空壳,**不抛异常** ——
|
|
3124
3191
|
// 抛异常会让对话断在那里,而拒绝是要让模型看见理由并改选的。
|
|
@@ -3126,7 +3193,7 @@ function apply(ctx) {
|
|
|
3126
3193
|
accepted: false, reject,
|
|
3127
3194
|
cursor, node, nodeName: byId.get(node)?.name ?? '',
|
|
3128
3195
|
domain: byId.get(node)?.domain ?? '',
|
|
3129
|
-
minutes: net, teachMinutes: 0, tailMinutes:
|
|
3196
|
+
minutes: Number.isInteger(net) ? net : 0, teachMinutes: 0, tailMinutes: tail, totalMinutes: 0, boxNote: '',
|
|
3130
3197
|
deliverable, why, mapStamp: stamp, blocks: [], selfTarget: false,
|
|
3131
3198
|
// 需求 #6:拒绝时也把这两个字段摆出来 —— 白名单式的 schema
|
|
3132
3199
|
// (additionalProperties: false + required:true)要求每个出口同形,
|
|
@@ -3135,15 +3202,16 @@ function apply(ctx) {
|
|
|
3135
3202
|
})
|
|
3136
3203
|
|
|
3137
3204
|
// ① 游标认不认得。「没设」和「打错」分开提示,别让模型去猜。
|
|
3138
|
-
if (!
|
|
3205
|
+
if (!identity.ok) return deny(identity.reject)
|
|
3206
|
+
if (!competency && !cursor) {
|
|
3139
3207
|
return deny('还没设游标(PROGRESS.yaml 里 cursor 是空的)。' +
|
|
3140
3208
|
'先确认训练员现在在哪个知识点,用 coach_set_cursor 设上。')
|
|
3141
3209
|
}
|
|
3142
|
-
if (!byId.has(cursor)) {
|
|
3210
|
+
if (!competency && !byId.has(cursor)) {
|
|
3143
3211
|
return deny(`地图里没有游标「${cursor}」。`)
|
|
3144
3212
|
}
|
|
3145
3213
|
// ② 节点必须真实存在 —— 挡住编造知识点
|
|
3146
|
-
if (!byId.has(node)) {
|
|
3214
|
+
if (!competency && !byId.has(node)) {
|
|
3147
3215
|
const near = suggest(node, [...byId.values()])
|
|
3148
3216
|
return deny(
|
|
3149
3217
|
`地图里没有「${node}」这个知识点,别自己造。` +
|
|
@@ -3151,7 +3219,7 @@ function apply(ctx) {
|
|
|
3151
3219
|
}
|
|
3152
3220
|
let route
|
|
3153
3221
|
try {
|
|
3154
|
-
route = curriculum.assignment({ node, revision: args?.curriculumRevision,
|
|
3222
|
+
route = curriculum.assignment({ node, competency, revision: args?.curriculumRevision,
|
|
3155
3223
|
purpose: args?.purpose, reason: args?.routeReason })
|
|
3156
3224
|
} catch (err) { return deny(`课程读取失败:${err.message};先核查,不凭印象布置。`) }
|
|
3157
3225
|
if (!route.ok) return deny(route.reject)
|
|
@@ -3160,10 +3228,12 @@ function apply(ctx) {
|
|
|
3160
3228
|
why = `课程「${route.binding.phaseTitle}」· ${route.binding.purpose}:${why}` +
|
|
3161
3229
|
(route.binding.reason ? `;补充依据:${route.binding.reason}` : '')
|
|
3162
3230
|
}
|
|
3163
|
-
const targetRoute =
|
|
3231
|
+
const targetRoute = competency
|
|
3232
|
+
? { ok: true, targetAligned: strategy.loadTarget().priorities.some(item => item.id === competency), competencyIds: [competency] }
|
|
3233
|
+
: strategy.route(node, { purpose: args?.purpose, courseBound: Boolean(route.binding) })
|
|
3164
3234
|
if (!targetRoute.ok) return deny(targetRoute.reject)
|
|
3165
3235
|
if (targetRoute.competencyIds.length) {
|
|
3166
|
-
why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '
|
|
3236
|
+
why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '目标能力对应的训练动作'}`
|
|
3167
3237
|
}
|
|
3168
3238
|
|
|
3169
3239
|
// ③ 未建课程时沿用直接后继;有课程时允许当前阶段跨分支,仍检查前置。
|
|
@@ -3175,12 +3245,12 @@ function apply(ctx) {
|
|
|
3175
3245
|
// 教练就一个动作都给不出来了 —— 它只能眼睁睁看着人卡住。
|
|
3176
3246
|
//
|
|
3177
3247
|
// 已经 verified 的不许再指回来:学完了就该往前走。
|
|
3178
|
-
if (node === cursor) {
|
|
3248
|
+
if (!competency && node === cursor) {
|
|
3179
3249
|
if (nodeStatus(loadProgress(), cursor) === 'verified' && route.binding?.purpose !== 'review') {
|
|
3180
3250
|
return deny(`「${cursor}」已经验证过了 —— 学完了就往前走,` +
|
|
3181
3251
|
'别在验证过的地方打转。用 coach_next 看下一层,或者往前沿退。')
|
|
3182
3252
|
}
|
|
3183
|
-
} else if (!(dependents.get(cursor) ?? []).includes(node) && !route.binding) {
|
|
3253
|
+
} else if (!competency && !(dependents.get(cursor) ?? []).includes(node) && !route.binding) {
|
|
3184
3254
|
return deny(`「${node}」不是「${cursor}」的下一层。先调 coach_next 看它到底能开哪些。`)
|
|
3185
3255
|
}
|
|
3186
3256
|
// ④ 前置必须都满足 —— 挡住「你得先学那个」这类空头指令
|
|
@@ -3188,17 +3258,13 @@ function apply(ctx) {
|
|
|
3188
3258
|
// 已学集合走**共用实现**:闭包(游标) ∪ 显式标为 学过/已验证 的节点。
|
|
3189
3259
|
// 原先这里只展开游标自己,训练员标过的几十个节点全被当没看见。
|
|
3190
3260
|
const assumed = assumedLearned(loadProgress(), byId, [cursor])
|
|
3191
|
-
const missing = (byId.get(node)
|
|
3261
|
+
const missing = (byId.get(node)?.depends ?? []).filter((d) => !assumed.has(d))
|
|
3192
3262
|
if (missing.length) {
|
|
3193
3263
|
return deny(`「${node}」还缺前置:${missing.join('、')}。它现在还开不了。`)
|
|
3194
3264
|
}
|
|
3195
3265
|
// ⑤ 时间盒下限(需求 #7:比的是**净时间**,不是整块长度)
|
|
3196
|
-
if (
|
|
3197
|
-
|
|
3198
|
-
`净时间 ${net} 分钟太短,一个题目循环压不到这个长度。` +
|
|
3199
|
-
'时间不够应该换更小的题,或者改用 review 块(25 分钟),' +
|
|
3200
|
-
`而不是把循环压扁。(下限:自己做 ${NET_FLOOR_MIN} 分钟 = 整块 ${NET_FLOOR_MIN + TAIL_MIN} 分钟)`)
|
|
3201
|
-
}
|
|
3266
|
+
if (!['practice', 'diagnostic', 'vp'].includes(kind)) return deny('kind 只能是 practice/diagnostic/vp。')
|
|
3267
|
+
if (!Number.isInteger(net) || net < NET_FLOOR_MIN || net > 960) return deny('minutes 必须是 1–960 的整数分钟。')
|
|
3202
3268
|
if (!deliverable) return deny('没写交付物。做完交什么必须说清楚,否则训练员不知道什么时候算完。')
|
|
3203
3269
|
|
|
3204
3270
|
// ⑥ **开新知识点必须先讲**。
|
|
@@ -3221,11 +3287,13 @@ function apply(ctx) {
|
|
|
3221
3287
|
// 而它存在的理由正是防「直接出题」。判据用 taughtEvidence,
|
|
3222
3288
|
// 和 coach_test 开卷、coach_status 报「讲于哪」是同一个实现。
|
|
3223
3289
|
const taughtBefore = taughtEvidence(loadProgress(), node) !== null
|
|
3224
|
-
const isNewNode = nodeStatus(loadProgress(), node) === 'none' &&
|
|
3290
|
+
const isNewNode = !competency && nodeStatus(loadProgress(), node) === 'none' &&
|
|
3225
3291
|
!(node === cursor && taughtBefore)
|
|
3226
3292
|
const teach = Number.isFinite(args?.teachMinutes) ? Math.trunc(args.teachMinutes) : 0
|
|
3293
|
+
if (args?.teachMinutes !== undefined && (!Number.isInteger(args.teachMinutes) || args.teachMinutes < 0 || args.teachMinutes > 960)) return deny('teachMinutes 必须是 0–960 的整数分钟。')
|
|
3294
|
+
if (net + tail + teach > 960) return deny('整项动作超过一天可训练的 960 分钟。')
|
|
3227
3295
|
|
|
3228
|
-
if (isNewNode && teach <= 0) {
|
|
3296
|
+
if (kind === 'practice' && isNewNode && teach <= 0) {
|
|
3229
3297
|
return deny(`「${byId.get(node)?.name ?? node}」是**全新知识点**(图上没人碰过),` +
|
|
3230
3298
|
'而你没给讲解段。\n' +
|
|
3231
3299
|
'开新知识点要**先拿一道例题讲一遍**再出题 —— 直接扔题让他自己卡,' +
|
|
@@ -3234,23 +3302,19 @@ function apply(ctx) {
|
|
|
3234
3302
|
}
|
|
3235
3303
|
if (teach > 0 && teach < TEACH_FLOOR_MIN) {
|
|
3236
3304
|
return deny(`讲解 ${teach} 分钟太短 —— 讲一道例题至少 ${TEACH_FLOOR_MIN} 分钟,` +
|
|
3237
|
-
'
|
|
3305
|
+
'再短就只是念一遍结论,等于没讲。讲多久你定,整项时长需在一天内。')
|
|
3238
3306
|
}
|
|
3239
3307
|
|
|
3240
|
-
const n =
|
|
3308
|
+
const n = { name: identity.name, domain: identity.domain }
|
|
3241
3309
|
const solve = net // 需求 #7:净时间直接就是「自己做」那一段
|
|
3242
3310
|
|
|
3243
3311
|
// 需求 #7 的两句提醒(只影响这段话,不挡布置):
|
|
3244
3312
|
// · 压到默认以下 → 提醒它压的是**止损点**,不是块
|
|
3245
3313
|
// · 净 ≠ 默认 → 提醒它这个整块长度**表上排不出来**(别让两个工具再打架)
|
|
3246
|
-
const
|
|
3247
|
-
? `净 ${net}
|
|
3314
|
+
const shape = kind === 'practice' && net !== NET_DEFAULT_MIN
|
|
3315
|
+
? `净 ${net} → 整块 ${net + tail} 分钟,排程时带 minutes=${net + tail + teach}。`
|
|
3248
3316
|
: ''
|
|
3249
|
-
const
|
|
3250
|
-
? `净 ${net} → 整块 ${net + TAIL_MIN} 分钟,**表上排不出这个形状**` +
|
|
3251
|
-
`(表上只有 cycle ${CYCLE_TOTAL_MIN} / review 25 / exam 90)。`
|
|
3252
|
-
: ''
|
|
3253
|
-
const boxNote = [squeeze, shape].filter(Boolean).join(' ')
|
|
3317
|
+
const boxNote = kind === 'practice' ? [shape].filter(Boolean).join(' ') : ''
|
|
3254
3318
|
|
|
3255
3319
|
// ── 落盘:押上这个动作(需求 #6)─────────────────────
|
|
3256
3320
|
// 布置的动作原先**只活在当次调用的返回里**:跨会话再开时教练不知道
|
|
@@ -3261,28 +3325,18 @@ function apply(ctx) {
|
|
|
3261
3325
|
// 但要在输出里喊出来 —— 静默失败 = 下个会话重复布置。
|
|
3262
3326
|
const progA = loadProgress()
|
|
3263
3327
|
const pend = { ...(progA.pendingActions ?? {}) }
|
|
3264
|
-
const replacedPending = Object.prototype.hasOwnProperty.call(pend,
|
|
3328
|
+
const replacedPending = Object.prototype.hasOwnProperty.call(pend, identity.key)
|
|
3265
3329
|
const assignedAt = ymdToday()
|
|
3266
|
-
pend[
|
|
3330
|
+
pend[identity.key] = {
|
|
3267
3331
|
at: assignedAt, deliverable,
|
|
3268
3332
|
// 存**拆开的**几个数,不存一个总数:总数在"整块 / 净时间"两种口径下
|
|
3269
3333
|
// 含义不同,存总数等于把口径混进账本(那正是 #7 要治的病)。
|
|
3270
|
-
solveMinutes: solve, tailMinutes:
|
|
3271
|
-
totalMinutes: net + teach +
|
|
3272
|
-
|
|
3273
|
-
if (route.binding) pend[node].curriculum = route.binding
|
|
3274
|
-
// 讲授段落盘:带 teachMinutes = 这次布置含讲授段,
|
|
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
|
|
3334
|
+
solveMinutes: solve, tailMinutes: tail, teachMinutes: teach,
|
|
3335
|
+
totalMinutes: net + teach + tail, why, kind,
|
|
3336
|
+
...(competency ? { competency } : {}),
|
|
3285
3337
|
}
|
|
3338
|
+
if (route.binding) pend[identity.key].curriculum = route.binding
|
|
3339
|
+
// 布置讲解只保存计划;接触状态与理解证据由各自入口记录。
|
|
3286
3340
|
progA.pendingActions = pend
|
|
3287
3341
|
let pendingRecorded = true
|
|
3288
3342
|
try { saveProgress(progA) } catch { pendingRecorded = false }
|
|
@@ -3290,8 +3344,9 @@ function apply(ctx) {
|
|
|
3290
3344
|
return {
|
|
3291
3345
|
accepted: true, reject: '',
|
|
3292
3346
|
cursor, node, nodeName: n.name ?? node, domain: n.domain ?? '',
|
|
3293
|
-
|
|
3294
|
-
|
|
3347
|
+
...(competency ? { competency } : {}),
|
|
3348
|
+
minutes: net, teachMinutes: teach, tailMinutes: tail,
|
|
3349
|
+
totalMinutes: net + teach + tail, boxNote,
|
|
3295
3350
|
deliverable, why, mapStamp: stamp, selfTarget: node === cursor,
|
|
3296
3351
|
pendingRecorded, pendingReplaced: replacedPending,
|
|
3297
3352
|
targetAligned: targetRoute.targetAligned, competencies: targetRoute.competencyIds,
|
|
@@ -3305,9 +3360,11 @@ function apply(ctx) {
|
|
|
3305
3360
|
minutes: teach,
|
|
3306
3361
|
}]
|
|
3307
3362
|
: []),
|
|
3308
|
-
{ kind: 'solve', label: '自己做(限时,到点必须停)', minutes: solve },
|
|
3309
|
-
|
|
3310
|
-
|
|
3363
|
+
{ kind: kind === 'practice' ? 'solve' : kind, label: kind === 'vp' ? '完整 VP(比赛过程与复盘另按交付物记录)' : kind === 'diagnostic' ? '诊断(限时观察当前表现)' : '自己做(限时,到点必须停)', minutes: solve },
|
|
3364
|
+
...(tail ? [
|
|
3365
|
+
{ kind: 'resolve', label: '解决遗留(做出来了就复盘思路,没做出来就听讲)', minutes: 10 },
|
|
3366
|
+
{ kind: 'rewrite', label: '重写', minutes: 10 },
|
|
3367
|
+
] : []),
|
|
3311
3368
|
],
|
|
3312
3369
|
}
|
|
3313
3370
|
},
|
|
@@ -3332,6 +3389,7 @@ function apply(ctx) {
|
|
|
3332
3389
|
// 让"讲过没"对教练**可查** —— 判据跟 coach_test 开卷时用的同一个
|
|
3333
3390
|
// 函数(taughtEvidence),不另写一份。需求 #5 的验收 ③。
|
|
3334
3391
|
cursorTaught: { type: 'string', required: true },
|
|
3392
|
+
teachingContext: { type: 'string', required: true },
|
|
3335
3393
|
domain: { type: 'string', required: true },
|
|
3336
3394
|
updated: { type: 'string', required: true },
|
|
3337
3395
|
verifiedCount: { type: 'integer', required: true },
|
|
@@ -3384,6 +3442,7 @@ function apply(ctx) {
|
|
|
3384
3442
|
nodeName: { type: 'string', required: true },
|
|
3385
3443
|
date: { type: 'string', required: true },
|
|
3386
3444
|
outcome: { type: 'string', required: true },
|
|
3445
|
+
independent: { type: 'boolean' },
|
|
3387
3446
|
score: { type: 'integer', required: true },
|
|
3388
3447
|
maxScore: { type: 'integer', required: true },
|
|
3389
3448
|
minutes: { type: 'integer', required: true },
|
|
@@ -3468,6 +3527,8 @@ function apply(ctx) {
|
|
|
3468
3527
|
node: { type: 'string', required: true },
|
|
3469
3528
|
nodeName: { type: 'string', required: true },
|
|
3470
3529
|
at: { type: 'string', required: true },
|
|
3530
|
+
competency: { type: 'string' },
|
|
3531
|
+
kind: { type: 'string' },
|
|
3471
3532
|
// 押了几天。-1 = 日期不明(跟火候那边同一个约定,别让它算成 0)
|
|
3472
3533
|
days: { type: 'integer', required: true },
|
|
3473
3534
|
deliverable: { type: 'string', required: true },
|
|
@@ -3489,9 +3550,10 @@ function apply(ctx) {
|
|
|
3489
3550
|
// 十有八九就是游标这个节点。埋在别处等于没报。(需求 #5 验收 ③)
|
|
3490
3551
|
(v.cursorName
|
|
3491
3552
|
? (v.cursorTaught
|
|
3492
|
-
? `
|
|
3553
|
+
? ` 接触记录 ${v.cursorTaught}(不证明理解)`
|
|
3493
3554
|
: ` ⚠️ **还没有教学记录** —— 现在开卷会被拒,先讲`)
|
|
3494
3555
|
: ''),
|
|
3556
|
+
v.teachingContext,
|
|
3495
3557
|
// 押着的卷放最前面之一:它是个**卡住下一步的约束**(有新卷就开不了),
|
|
3496
3558
|
// 埋在底下等于没报。
|
|
3497
3559
|
v.pendingNode
|
|
@@ -3504,7 +3566,7 @@ function apply(ctx) {
|
|
|
3504
3566
|
v.pendingActions.length
|
|
3505
3567
|
? '📌 **押着的动作**(布置了、还没落地):\n' +
|
|
3506
3568
|
v.pendingActions.map((a) =>
|
|
3507
|
-
` · ${a.nodeName}${a.nodeName !== a.node ? `(${a.node})` : ''} ` +
|
|
3569
|
+
` · ${a.nodeName}${a.node && a.nodeName !== a.node ? `(${a.node})` : ''} ` +
|
|
3508
3570
|
`交付物 ${a.deliverable} ` +
|
|
3509
3571
|
`时间盒 自己做 ${a.solveMinutes} + 收尾 ${a.tailMinutes} = ${a.solveMinutes + a.tailMinutes} 分钟` +
|
|
3510
3572
|
(a.teachMinutes ? `,另加先讲 ${a.teachMinutes}(共 ${a.totalMinutes})` : '') +
|
|
@@ -3533,6 +3595,7 @@ function apply(ctx) {
|
|
|
3533
3595
|
v.checkTotal
|
|
3534
3596
|
? `检测记录 ${v.checkTotal} 次,最近:` + v.recentChecks.slice(0, 4).map((c) =>
|
|
3535
3597
|
`${c.date.slice(5)} ${c.nodeName} ${OUTCOME_TAG[c.outcome] ?? c.outcome}` +
|
|
3598
|
+
(c.outcome === 'passed' ? (c.independent === true ? '[独立]' : c.independent === false ? '[有帮助]' : '[独立性未知]') : '') +
|
|
3536
3599
|
(c.outcome === 'overTime' ? `(${c.minutes}/${c.limit} 分钟)` : '') +
|
|
3537
3600
|
(c.verifiedAt ? ` 验证于≈${c.verifiedAt}` : '') +
|
|
3538
3601
|
(c.selfReported ? `[含 ${c.selfReported} 道自述题]` : '')).join(' · ')
|
|
@@ -3579,6 +3642,7 @@ function apply(ctx) {
|
|
|
3579
3642
|
allChecks.push({
|
|
3580
3643
|
node: id, nodeName: name,
|
|
3581
3644
|
date: String(c?.date ?? ''), outcome: String(c?.outcome ?? ''),
|
|
3645
|
+
...(typeof c?.independent === 'boolean' ? { independent: c.independent } : {}),
|
|
3582
3646
|
score: Number(c?.score ?? 0), maxScore: Number(c?.maxScore ?? 0),
|
|
3583
3647
|
minutes: Number(c?.minutes ?? 0), limit: Number(c?.limit ?? 0),
|
|
3584
3648
|
verifiedAt: Number(c?.verifiedAt ?? 0),
|
|
@@ -3640,6 +3704,7 @@ function apply(ctx) {
|
|
|
3640
3704
|
cursor: p.cursor,
|
|
3641
3705
|
cursorName: byId.get(p.cursor)?.name ?? '',
|
|
3642
3706
|
cursorTaught: taughtEvidence(p, p.cursor)?.first ?? '',
|
|
3707
|
+
teachingContext: teaching.context(p),
|
|
3643
3708
|
domain: byId.get(p.cursor)?.domain ?? '',
|
|
3644
3709
|
updated: p.updated,
|
|
3645
3710
|
verifiedCount: verified.length,
|
|
@@ -3673,7 +3738,9 @@ function apply(ctx) {
|
|
|
3673
3738
|
const then = parseYmd(at)
|
|
3674
3739
|
const days = then ? Math.round((parseYmd(ymdToday()) - then) / 86400000) : -1
|
|
3675
3740
|
return {
|
|
3676
|
-
node:
|
|
3741
|
+
node: a?.competency ? '' : id,
|
|
3742
|
+
nodeName: a?.competency ? strategy.loadCompetencies().find(card => card.id === a.competency)?.title ?? a.competency : byId.get(id)?.name ?? id,
|
|
3743
|
+
...(a?.competency ? { competency: String(a.competency), kind: String(a.kind ?? '') } : {}), at, days,
|
|
3677
3744
|
deliverable: String(a?.deliverable ?? ''),
|
|
3678
3745
|
solveMinutes: Number(a?.solveMinutes ?? 0),
|
|
3679
3746
|
tailMinutes: Number(a?.tailMinutes ?? 0),
|
|
@@ -4925,25 +4992,19 @@ function apply(ctx) {
|
|
|
4925
4992
|
|
|
4926
4993
|
// ── coach_grade:判卷 ────────────────────────────────────────
|
|
4927
4994
|
//
|
|
4928
|
-
//
|
|
4929
|
-
//
|
|
4930
|
-
//
|
|
4931
|
-
// 三种结局**不能合并**:
|
|
4932
|
-
// passed 分够 + 没超时 → 真过了,标 verified,可以推进
|
|
4933
|
-
// overTime 分够 + 超时 → **不算过**。这是「不熟」的机械形态:
|
|
4934
|
-
// 方法他有,就是慢。去向是加练,不是推进。
|
|
4935
|
-
// failed 分不够 → 不过,交 coach_diagnose 判因
|
|
4995
|
+
// 卷面通过与独立验证分开:分数、用时只证明本次过卷;独立性未知不补成 true。
|
|
4996
|
+
// 历史 verified 不撤销,本次未独立验证也不刷新验证段位。
|
|
4936
4997
|
//
|
|
4937
4998
|
// 只依赖卷子本身(pending 里存了整份题目信息),不连数据库 ——
|
|
4938
4999
|
// 判卷不该因为库被搬走或者锁了就失败。
|
|
4939
5000
|
ctx.tools.register(defineTool({
|
|
4940
5001
|
name: 'coach_grade',
|
|
4941
5002
|
description:
|
|
4942
|
-
'
|
|
5003
|
+
'给正押着的那张检测卷判卷。收每题 AC 与否、实际分钟数和明确的独立性事实(未知就省略)。\n' +
|
|
4943
5004
|
'判定:难易加权(易1/中2/难3,满分 6),**通过线 4 分且总用时不超过限时**。\n' +
|
|
4944
|
-
'分够但超时 →
|
|
4945
|
-
'
|
|
4946
|
-
'
|
|
5005
|
+
'分够但超时 → overTime;分不够 → failed。卷面通过且所有做出的题明确独立,才产生本次 verified。\n' +
|
|
5006
|
+
'接受过提示/题解/他人帮助,或独立性未知:只记过卷,保留原状态,不冒充独立掌握。\n' +
|
|
5007
|
+
'本次独立验证不证明隔日保持或迁移;下一步由教练结合课程和表现判断。',
|
|
4947
5008
|
parameters: {
|
|
4948
5009
|
results: {
|
|
4949
5010
|
type: 'array',
|
|
@@ -4956,6 +5017,7 @@ function apply(ctx) {
|
|
|
4956
5017
|
problemId: { type: 'string', required: true, description: '卷子里的题号,原样抄' },
|
|
4957
5018
|
solved: { type: 'boolean', required: true, description: '这题最后 AC 了吗' },
|
|
4958
5019
|
minutes: { type: 'number', required: true, description: '这题花了多少分钟(没做出来就填实际耗掉的)' },
|
|
5020
|
+
independent: { type: 'boolean', description: '这题是否未接受提示、题解或他人帮助而独立完成/尝试;未知就省略,不猜。' },
|
|
4959
5021
|
},
|
|
4960
5022
|
},
|
|
4961
5023
|
},
|
|
@@ -4977,6 +5039,8 @@ function apply(ctx) {
|
|
|
4977
5039
|
minutes: { type: 'integer', required: true },
|
|
4978
5040
|
limit: { type: 'integer', required: true },
|
|
4979
5041
|
status: { type: 'string', required: true },
|
|
5042
|
+
verified: { type: 'boolean', required: true, description: '本次卷面通过且所有做出题都有明确独立事实;不代表长期保持或迁移。' },
|
|
5043
|
+
independence: { type: 'string', required: true, description: '本次做出题的独立性:independent / assisted / unknown。' },
|
|
4980
5044
|
verifiedAt: { type: 'integer', required: true },
|
|
4981
5045
|
// 这张卷里有几道是「网上找的」(自述题)。>0 时这条 verified 的
|
|
4982
5046
|
// 成色就要打折 —— 卷面上看得见,记录里也留痕。
|
|
@@ -4993,6 +5057,7 @@ function apply(ctx) {
|
|
|
4993
5057
|
cfRating: { type: 'integer', required: true },
|
|
4994
5058
|
solved: { type: 'boolean', required: true },
|
|
4995
5059
|
minutes: { type: 'integer', required: true },
|
|
5060
|
+
independent: { type: 'boolean', description: '仅在本题明确报告独立性时返回。' },
|
|
4996
5061
|
// 网上找的题的 cfRating 是自报估值 —— 不计入 verifiedAt(见上面的
|
|
4997
5062
|
// solvedRatings 过滤)。来源得跟着结果走,不然判卷记录里分不出
|
|
4998
5063
|
// 这道题是库里查的还是他自己找的。**schema 得声明它** ——
|
|
@@ -5007,7 +5072,7 @@ function apply(ctx) {
|
|
|
5007
5072
|
render: (_args, v) => {
|
|
5008
5073
|
if (!v.ok) return [{ type: 'text', text: `❌ 判不了:${v.reject}` }]
|
|
5009
5074
|
const head = v.outcome === 'passed'
|
|
5010
|
-
? `✅
|
|
5075
|
+
? `✅ 卷面通过${v.verified ? ' · 本次独立验证' : ' · 未形成独立验证'} ${v.nodeName}`
|
|
5011
5076
|
: v.outcome === 'overTime'
|
|
5012
5077
|
? `⏱ 分数够,但超时了 —— 这不算过 ${v.nodeName}`
|
|
5013
5078
|
: `❌ 没过 ${v.nodeName}`
|
|
@@ -5019,29 +5084,29 @@ function apply(ctx) {
|
|
|
5019
5084
|
// 直接影响这条 verified 值不值钱,而"过了"这两个字会盖过一切。
|
|
5020
5085
|
...(v.selfReported
|
|
5021
5086
|
? [`⚠️ 其中 ${v.selfReported} 道是**网上找的**:题号没进过题库、难度是估的、` +
|
|
5022
|
-
'
|
|
5023
|
-
'想坐实这个知识点,下次用题库里的题再测一次。']
|
|
5087
|
+
'做题结果是报告的事实,难度估值不计入验证段位;需要时用题库题再核对。']
|
|
5024
5088
|
: []),
|
|
5025
5089
|
'',
|
|
5026
5090
|
]
|
|
5027
5091
|
for (const p of v.problems) {
|
|
5028
5092
|
lines.push(` ${p.solved ? '✓' : '✗'} ${p.band} ${p.problemId} ${p.title} ` +
|
|
5029
|
-
`${p.minutes} 分钟(权重 ${p.weight})`)
|
|
5093
|
+
`${p.minutes} 分钟(权重 ${p.weight};${p.independent === true ? '独立' : p.independent === false ? '有帮助' : '独立性未记录'})`)
|
|
5030
5094
|
}
|
|
5031
5095
|
lines.push('', '── 下一步 ──')
|
|
5032
5096
|
if (v.outcome === 'passed') {
|
|
5033
|
-
lines.push(
|
|
5034
|
-
|
|
5097
|
+
lines.push(v.verified
|
|
5098
|
+
? ` 本次达到独立验证条件${v.verifiedAt ? `,题库难度上限≈${v.verifiedAt}` : ',没有可核对的题库难度段位'};不代表隔日仍会或能迁移。`
|
|
5099
|
+
: v.independence === 'assisted'
|
|
5100
|
+
? ' 卷面过了,但做出题有帮助:保留原状态,不新增掌握证书;需要时再独立检测。'
|
|
5101
|
+
: ' 卷面过了,但独立性未记录:保留原状态,不新增掌握证书;不要把未知补成独立。')
|
|
5035
5102
|
const course = curriculum.execute({ action: 'read' })
|
|
5036
5103
|
lines.push(course.exists || !course.ok
|
|
5037
5104
|
? ' 先读 coach_curriculum 检查当前阶段验收;单点过卷不会自动推进阶段。继续当前主线,整体成果达标后再 advance。'
|
|
5038
|
-
: '
|
|
5105
|
+
: ' 根据实际理解和独立表现,选择补练、复检、迁移或下一节。')
|
|
5039
5106
|
} else if (v.outcome === 'overTime') {
|
|
5040
|
-
lines.push('
|
|
5041
|
-
'换个同难度的题限时加练,把速度压进限时里。')
|
|
5107
|
+
lines.push(' 本次超过限时。结合做题过程判断是建模、实现还是速度问题,再决定补讲或限时加练。')
|
|
5042
5108
|
} else {
|
|
5043
|
-
lines.push('
|
|
5044
|
-
'分清是「不会」还是「不认真」,再决定补课还是重写。')
|
|
5109
|
+
lines.push(' 结合没做出题的题面、代码和过程,用 coach_diagnose 判因,再决定补讲、练习或重写。')
|
|
5045
5110
|
}
|
|
5046
5111
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
5047
5112
|
},
|
|
@@ -5052,7 +5117,7 @@ function apply(ctx) {
|
|
|
5052
5117
|
const deny = (reject) => ({
|
|
5053
5118
|
ok: false, reject, node: '', nodeName: '', domain: '',
|
|
5054
5119
|
outcome: '', score: 0, maxScore: 0, passScore: PASS_SCORE,
|
|
5055
|
-
minutes: 0, limit: 0, status: '', verifiedAt: 0, problems: [], checks: 0,
|
|
5120
|
+
minutes: 0, limit: 0, status: '', verified: false, independence: 'unknown', verifiedAt: 0, selfReported: 0, problems: [], checks: 0,
|
|
5056
5121
|
})
|
|
5057
5122
|
|
|
5058
5123
|
const prog = loadProgress()
|
|
@@ -5076,6 +5141,11 @@ function apply(ctx) {
|
|
|
5076
5141
|
const keyOf = (id) => normId('nowcoder', String(id ?? '').trim())
|
|
5077
5142
|
const byIdGiven = new Map()
|
|
5078
5143
|
for (const r of given) {
|
|
5144
|
+
if (!r || typeof r.problemId !== 'string' || !r.problemId.trim() || typeof r.solved !== 'boolean' ||
|
|
5145
|
+
typeof r.minutes !== 'number' || !Number.isFinite(r.minutes) || r.minutes < 0 || !Number.isSafeInteger(Math.round(r.minutes)) ||
|
|
5146
|
+
(r.independent !== undefined && typeof r.independent !== 'boolean')) {
|
|
5147
|
+
return deny('每条结果必须有题号、布尔 solved 和非负有限分钟数;independent 只能是布尔值或省略。')
|
|
5148
|
+
}
|
|
5079
5149
|
const key = keyOf(r?.problemId)
|
|
5080
5150
|
if (byIdGiven.has(key)) return deny(`题号 ${r.problemId} 报了两遍。`)
|
|
5081
5151
|
byIdGiven.set(key, r)
|
|
@@ -5093,6 +5163,7 @@ function apply(ctx) {
|
|
|
5093
5163
|
}
|
|
5094
5164
|
const solved = r.solved === true
|
|
5095
5165
|
const spent = Math.max(0, Math.round(Number(r.minutes) || 0))
|
|
5166
|
+
if (!Number.isSafeInteger(minutes + spent)) return deny('总分钟数超出安全数值范围,不能记录。')
|
|
5096
5167
|
if (solved) score += Number(q.weight) || 0
|
|
5097
5168
|
minutes += spent
|
|
5098
5169
|
graded.push({
|
|
@@ -5104,6 +5175,7 @@ function apply(ctx) {
|
|
|
5104
5175
|
// 老卷子可能没有(那批 pending 是在加这个字段之前开的),兜底 0。
|
|
5105
5176
|
cfRating: Math.round(Number(q.cfRating) || 0),
|
|
5106
5177
|
solved, minutes: spent,
|
|
5178
|
+
...(typeof r.independent === 'boolean' ? { independent: r.independent } : {}),
|
|
5107
5179
|
})
|
|
5108
5180
|
}
|
|
5109
5181
|
|
|
@@ -5113,9 +5185,13 @@ function apply(ctx) {
|
|
|
5113
5185
|
// 超时和分数不够是**两种病**:前者是不熟,后者是不会。
|
|
5114
5186
|
const outcome = score < passScore ? 'failed'
|
|
5115
5187
|
: (limit > 0 && minutes > limit) ? 'overTime' : 'passed'
|
|
5188
|
+
const solvedProblems = graded.filter((g) => g.solved)
|
|
5189
|
+
const independence = solvedProblems.some((g) => g.independent === false) ? 'assisted'
|
|
5190
|
+
: solvedProblems.length && solvedProblems.every((g) => g.independent === true) ? 'independent' : 'unknown'
|
|
5191
|
+
const verified = outcome === 'passed' && independence === 'independent'
|
|
5116
5192
|
|
|
5117
5193
|
const node = String(p.node ?? '')
|
|
5118
|
-
const status =
|
|
5194
|
+
const status = verified ? 'verified'
|
|
5119
5195
|
: nodeStatus(prog, node) === 'none' ? 'studying' : nodeStatus(prog, node)
|
|
5120
5196
|
|
|
5121
5197
|
// 「验证于哪个段位」= 他拿下的题里 cfRating 最高的那个。
|
|
@@ -5130,7 +5206,7 @@ function apply(ctx) {
|
|
|
5130
5206
|
const solvedRatings = graded
|
|
5131
5207
|
.filter((g) => g.solved && g.source !== 'web')
|
|
5132
5208
|
.map((g) => g.cfRating).filter((r) => r > 0)
|
|
5133
|
-
const verifiedAt =
|
|
5209
|
+
const verifiedAt = verified && solvedRatings.length
|
|
5134
5210
|
? Math.max(...solvedRatings) : 0
|
|
5135
5211
|
|
|
5136
5212
|
// 自述题计数。
|
|
@@ -5149,10 +5225,10 @@ function apply(ctx) {
|
|
|
5149
5225
|
date: localStamp(new Date()).slice(0, 10),
|
|
5150
5226
|
outcome, score, maxScore, passScore,
|
|
5151
5227
|
minutes, limit, verifiedAt, selfReported, problems: graded,
|
|
5228
|
+
...(independence === 'unknown' ? {} : { independent: independence === 'independent' }),
|
|
5152
5229
|
})
|
|
5153
5230
|
nodeRec.checks = checks
|
|
5154
|
-
//
|
|
5155
|
-
// 但若本来就没学过,标成「在学」—— 他显然正在这上面花时间。
|
|
5231
|
+
// 未达到独立验证条件不降级旧状态;未学节点只记录正在学习。
|
|
5156
5232
|
const prevStatus = nodeStatus(prog, node)
|
|
5157
5233
|
nodeRec.status = status
|
|
5158
5234
|
// ⚠️ 修过:这条路**以前没写 `at`**。
|
|
@@ -5171,7 +5247,7 @@ function apply(ctx) {
|
|
|
5171
5247
|
return {
|
|
5172
5248
|
ok: true, reject: '', node, nodeName: byId.get(node)?.name ?? node,
|
|
5173
5249
|
domain: byId.get(node)?.domain ?? '', outcome, score, maxScore, passScore,
|
|
5174
|
-
minutes, limit, status, verifiedAt, selfReported,
|
|
5250
|
+
minutes, limit, status, verified, independence, verifiedAt, selfReported,
|
|
5175
5251
|
problems: graded, checks: checks.length,
|
|
5176
5252
|
}
|
|
5177
5253
|
},
|
|
@@ -5323,6 +5399,7 @@ function apply(ctx) {
|
|
|
5323
5399
|
type: 'string',
|
|
5324
5400
|
description: '撤哪个节点的动作。不填 = 手上只有一条时撤那条;有多条会被拒绝并列出来。',
|
|
5325
5401
|
},
|
|
5402
|
+
competency: { type: 'string', description: '撤哪个比赛能力动作,与 node 二选一;都不填时手上只有一条可直接撤。' },
|
|
5326
5403
|
reason: { type: 'string', description: '为什么撤(一句话,回显用)' },
|
|
5327
5404
|
},
|
|
5328
5405
|
output: {
|
|
@@ -5337,6 +5414,7 @@ function apply(ctx) {
|
|
|
5337
5414
|
at: { type: 'string', required: true },
|
|
5338
5415
|
deliverable: { type: 'string', required: true },
|
|
5339
5416
|
reason: { type: 'string', required: true },
|
|
5417
|
+
competency: { type: 'string' },
|
|
5340
5418
|
remaining: { type: 'integer', required: true },
|
|
5341
5419
|
},
|
|
5342
5420
|
},
|
|
@@ -5362,6 +5440,13 @@ function apply(ctx) {
|
|
|
5362
5440
|
const prog = loadProgress()
|
|
5363
5441
|
const pend = { ...(prog.pendingActions ?? {}) }
|
|
5364
5442
|
let node = String(args?.node ?? '').trim()
|
|
5443
|
+
let competency = String(args?.competency ?? '').trim()
|
|
5444
|
+
if (node && competency) return deny('node 与 competency 只能指定一个。')
|
|
5445
|
+
if (competency) {
|
|
5446
|
+
const identity = trainingIdentity('', competency)
|
|
5447
|
+
if (!identity.ok) return deny(identity.reject)
|
|
5448
|
+
node = identity.key
|
|
5449
|
+
}
|
|
5365
5450
|
if (!node) {
|
|
5366
5451
|
const keys = Object.keys(pend)
|
|
5367
5452
|
if (!keys.length) {
|
|
@@ -5378,6 +5463,7 @@ function apply(ctx) {
|
|
|
5378
5463
|
'可能已经落地(coach_log 销的),或者被撤过了。')
|
|
5379
5464
|
}
|
|
5380
5465
|
const a = pend[node]
|
|
5466
|
+
competency = String(a?.competency ?? '')
|
|
5381
5467
|
delete pend[node]
|
|
5382
5468
|
prog.pendingActions = pend
|
|
5383
5469
|
try {
|
|
@@ -5386,7 +5472,9 @@ function apply(ctx) {
|
|
|
5386
5472
|
return deny(`写盘失败:${err.message} —— 动作还押着,别当它撤了。`)
|
|
5387
5473
|
}
|
|
5388
5474
|
return {
|
|
5389
|
-
ok: true, reject: '', node
|
|
5475
|
+
ok: true, reject: '', node: competency ? '' : node,
|
|
5476
|
+
nodeName: trainingIdentity(competency ? '' : node, competency).name ?? node,
|
|
5477
|
+
...(competency ? { competency } : {}),
|
|
5390
5478
|
at: String(a?.at ?? ''), deliverable: String(a?.deliverable ?? ''),
|
|
5391
5479
|
reason, remaining: Object.keys(pend).length,
|
|
5392
5480
|
}
|
|
@@ -5466,6 +5554,7 @@ function apply(ctx) {
|
|
|
5466
5554
|
kindName: { type: 'string', required: true },
|
|
5467
5555
|
node: { type: 'string', required: true },
|
|
5468
5556
|
nodeName: { type: 'string', required: true },
|
|
5557
|
+
competency: { type: 'string' },
|
|
5469
5558
|
},
|
|
5470
5559
|
},
|
|
5471
5560
|
},
|
|
@@ -5480,6 +5569,7 @@ function apply(ctx) {
|
|
|
5480
5569
|
actualMin: { type: 'integer', required: true },
|
|
5481
5570
|
solved: { type: 'boolean', required: true },
|
|
5482
5571
|
independent: { type: 'boolean', required: true },
|
|
5572
|
+
competency: { type: 'string' },
|
|
5483
5573
|
// 可选(coach_log 带 note 时才写)。不写 required —— 可选键
|
|
5484
5574
|
// 在 dsh 那套里就是"不写 required",写 required:false 会抛。
|
|
5485
5575
|
note: { type: 'string' },
|
|
@@ -5593,10 +5683,8 @@ function apply(ctx) {
|
|
|
5593
5683
|
L.push(` ${r.nodeName} ${tag} ${ago}${rec}`)
|
|
5594
5684
|
}
|
|
5595
5685
|
L.push('',
|
|
5596
|
-
'
|
|
5597
|
-
'
|
|
5598
|
-
'做新题难受),**所以那个平衡得你来把**:复习块按上面的火候排,' +
|
|
5599
|
-
'剩下的时间留给推进。')
|
|
5686
|
+
'根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。' +
|
|
5687
|
+
'上面的间隔只供判断;不要为了填满课表持续开新知识点。')
|
|
5600
5688
|
}
|
|
5601
5689
|
L.push('', '要排就用 coach_plan,**一次只排他说的那一天**。')
|
|
5602
5690
|
return [{ type: 'text', text: L.join('\n') }]
|
|
@@ -5625,8 +5713,8 @@ function apply(ctx) {
|
|
|
5625
5713
|
'**范围只认他说的那一天**:他说「排今天」就只排今天,说「排周三」就只排周三,' +
|
|
5626
5714
|
'不要顺手把明天后天一起铺了 —— 那是替他做决定,而且是没问过他的决定。\n' +
|
|
5627
5715
|
'三种块:cycle = 题目循环 60min(40 自己做 + 10 解决遗留 + 10 重写)、' +
|
|
5628
|
-
'review = 复习一题 25min、exam = 章节检测 90min
|
|
5629
|
-
'
|
|
5716
|
+
'review = 复习一题 25min、exam = 章节检测 90min;diagnostic 默认 15min、vp 默认 300min。' +
|
|
5717
|
+
'这些是默认值;按任务需要用 minutes 明确时长。\n' +
|
|
5630
5718
|
'工具只拦**物理上做不到的**:压着他标的忙事 / 块叠在一起 / 知识点不在图上 / ' +
|
|
5631
5719
|
'日子在窗口外。**排多排少是你的判断,工具不给容量上限**,排太满的后果' +
|
|
5632
5720
|
'让表里的' +
|
|
@@ -5637,13 +5725,15 @@ function apply(ctx) {
|
|
|
5637
5725
|
date: { type: 'string', required: true, description: '哪天,YYYY-MM-DD,得在今天 ~ 今天+6 之间' },
|
|
5638
5726
|
blocks: {
|
|
5639
5727
|
type: 'array', required: true,
|
|
5640
|
-
description: '要排的块,按时间先后给。结束时间由 kind
|
|
5728
|
+
description: '要排的块,按时间先后给。结束时间由 minutes 或 kind 默认时长决定',
|
|
5641
5729
|
items: {
|
|
5642
5730
|
type: 'object', additionalProperties: false,
|
|
5643
5731
|
properties: {
|
|
5644
5732
|
from: { type: 'string', required: true, description: '开始时间 "HH:MM",要对齐 5 分钟' },
|
|
5645
|
-
kind: { type: 'string', required: true, enum: ['cycle', 'review', 'exam'] },
|
|
5646
|
-
node: { type: 'string',
|
|
5733
|
+
kind: { type: 'string', required: true, enum: ['cycle', 'review', 'exam', 'diagnostic', 'vp'] },
|
|
5734
|
+
node: { type: 'string', description: '地图知识点 id;与 competency 二选一。' },
|
|
5735
|
+
competency: { type: 'string', description: '已有比赛能力 id;与 node 二选一。' },
|
|
5736
|
+
minutes: { type: 'integer', description: '整块时长 1–960 分钟;不填保留该 kind 的默认时长。' },
|
|
5647
5737
|
},
|
|
5648
5738
|
},
|
|
5649
5739
|
},
|
|
@@ -5671,6 +5761,7 @@ function apply(ctx) {
|
|
|
5671
5761
|
node: { type: 'string', required: true },
|
|
5672
5762
|
nodeName: { type: 'string', required: true },
|
|
5673
5763
|
domain: { type: 'string', required: true },
|
|
5764
|
+
competency: { type: 'string' },
|
|
5674
5765
|
},
|
|
5675
5766
|
},
|
|
5676
5767
|
},
|
|
@@ -5700,7 +5791,7 @@ function apply(ctx) {
|
|
|
5700
5791
|
(v.replaced ? `(覆盖掉原来那 ${v.replaced} 个块)` : '')]
|
|
5701
5792
|
for (const b of v.planned) {
|
|
5702
5793
|
L.push(` ★ ${b.from}–${b.to} ${b.kindName} ${b.nodeName}` +
|
|
5703
|
-
(b.kind === 'cycle' ? ' (40 自己做 → 10 解决遗留 → 10 重写)' : ''))
|
|
5794
|
+
(b.kind === 'cycle' && b.minutes === CYCLE_TOTAL_MIN ? ' (40 自己做 → 10 解决遗留 → 10 重写)' : ''))
|
|
5704
5795
|
}
|
|
5705
5796
|
// 回一条**完整时间轴**,而不是「还剩多少分钟」。
|
|
5706
5797
|
// 排完他得能一眼看出这块夹在哪两件事之间 —— 那个位置才决定来不来得及。
|
|
@@ -5722,6 +5813,8 @@ function apply(ctx) {
|
|
|
5722
5813
|
async execute(args) {
|
|
5723
5814
|
const blocks = (Array.isArray(args?.blocks) ? args.blocks : []).map((b) => ({
|
|
5724
5815
|
from: String(b?.from ?? ''), kind: String(b?.kind ?? ''), node: String(b?.node ?? ''),
|
|
5816
|
+
...(b?.competency ? { competency: String(b.competency).trim() } : {}),
|
|
5817
|
+
...(b?.minutes !== undefined ? { minutes: b.minutes } : {}),
|
|
5725
5818
|
}))
|
|
5726
5819
|
const r = planDay(args?.date, blocks, { replace: args?.replace === true })
|
|
5727
5820
|
|
|
@@ -5795,6 +5888,7 @@ function apply(ctx) {
|
|
|
5795
5888
|
kindName: { type: 'string', required: true },
|
|
5796
5889
|
node: { type: 'string', required: true },
|
|
5797
5890
|
nodeName: { type: 'string', required: true },
|
|
5891
|
+
competency: { type: 'string' },
|
|
5798
5892
|
},
|
|
5799
5893
|
},
|
|
5800
5894
|
},
|
|
@@ -5809,6 +5903,7 @@ function apply(ctx) {
|
|
|
5809
5903
|
kindName: { type: 'string', required: true },
|
|
5810
5904
|
node: { type: 'string', required: true },
|
|
5811
5905
|
nodeName: { type: 'string', required: true },
|
|
5906
|
+
competency: { type: 'string' },
|
|
5812
5907
|
},
|
|
5813
5908
|
},
|
|
5814
5909
|
},
|
|
@@ -5879,7 +5974,7 @@ function apply(ctx) {
|
|
|
5879
5974
|
ctx.tools.register(defineTool({
|
|
5880
5975
|
name: 'coach_log',
|
|
5881
5976
|
description:
|
|
5882
|
-
'
|
|
5977
|
+
'记一条节点练习或比赛能力动作的**实际用时**:他做完一个块之后,报「做了多久、是不是独立做出来的」。\n' +
|
|
5883
5978
|
'**每次他报告完成情况就调一次**(不管是做出来了还是没做出来、超时了还是提前),' +
|
|
5884
5979
|
'这是系统学会"他真实速度是多少"的唯一来源 —— 不记,排程永远只能靠猜。\n' +
|
|
5885
5980
|
'计划时长不用你填:那天表上排了多久,工具自己从 SCHEDULE.yaml 里算。\n' +
|
|
@@ -5888,7 +5983,8 @@ function apply(ctx) {
|
|
|
5888
5983
|
'**同一天同一个知识点做了两道题,就调两次、各带自己的 problemId** —— ' +
|
|
5889
5984
|
'别合成一条(合成之后「哪道独立做出来的」就没了,而那正是开不开检测卷的依据)。',
|
|
5890
5985
|
parameters: {
|
|
5891
|
-
node: { type: 'string',
|
|
5986
|
+
node: { type: 'string', description: '知识点 id;与 competency 二选一。' },
|
|
5987
|
+
competency: { type: 'string', description: '已有比赛能力 id;记录能力诊断或完整 VP,与 node 二选一。' },
|
|
5892
5988
|
// (需求 #2):题号。**可选** —— 不填就是老行为(一天一节点一条)。
|
|
5893
5989
|
problemId: {
|
|
5894
5990
|
type: 'string',
|
|
@@ -5920,6 +6016,7 @@ function apply(ctx) {
|
|
|
5920
6016
|
date: { type: 'string', required: true },
|
|
5921
6017
|
node: { type: 'string', required: true },
|
|
5922
6018
|
nodeName: { type: 'string', required: true },
|
|
6019
|
+
competency: { type: 'string' },
|
|
5923
6020
|
plannedMin: { type: 'integer', required: true },
|
|
5924
6021
|
actualMin: { type: 'integer', required: true },
|
|
5925
6022
|
deviation: { type: 'integer', required: true },
|
|
@@ -5963,7 +6060,7 @@ function apply(ctx) {
|
|
|
5963
6060
|
? `\n (这天这个点${v.problemId ? '这道题' : ''}原来记过一条,覆盖了)`
|
|
5964
6061
|
: '') +
|
|
5965
6062
|
(v.recorded
|
|
5966
|
-
? `\n 台账:这个知识点第 ${v.passCount} 次做完记录。`
|
|
6063
|
+
? v.competency ? '\n 台账:比赛能力动作已记入实际记录;不证明长期掌握。' : `\n 台账:这个知识点第 ${v.passCount} 次做完记录。`
|
|
5967
6064
|
: '\n ⚠️ 掌握证据那条没写上(磁盘/权限问题)—— 时间记录是好的。') +
|
|
5968
6065
|
(v.actionCleared
|
|
5969
6066
|
? '\n 📌 这个节点上押着的动作**销了** —— 这条记录就是它的落地。'
|
|
@@ -5980,6 +6077,8 @@ function apply(ctx) {
|
|
|
5980
6077
|
const { byId } = loadMap()
|
|
5981
6078
|
const node = String(args?.node ?? '').trim()
|
|
5982
6079
|
const d = String(args?.date ?? '').trim() || ymdToday()
|
|
6080
|
+
const competency = String(args?.competency ?? '').trim()
|
|
6081
|
+
const identity = trainingIdentity(node, competency)
|
|
5983
6082
|
const actualMin = Math.round(Number(args?.actualMin))
|
|
5984
6083
|
const solved = args?.solved === true
|
|
5985
6084
|
const independent = args?.independent === true
|
|
@@ -5996,7 +6095,8 @@ function apply(ctx) {
|
|
|
5996
6095
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) {
|
|
5997
6096
|
return deny(`日期要 YYYY-MM-DD 格式,收到「${d}」。`)
|
|
5998
6097
|
}
|
|
5999
|
-
if (!
|
|
6098
|
+
if (!identity.ok) return deny(identity.reject)
|
|
6099
|
+
if (!competency && !byId.has(node)) {
|
|
6000
6100
|
const near = suggest(node, [...byId.values()])
|
|
6001
6101
|
return deny(`地图里没有「${node}」这个知识点,别自己造。` +
|
|
6002
6102
|
(near.length ? `相近的有:${near.join(' / ')}` : '先调 coach_status 看他在哪。'))
|
|
@@ -6023,7 +6123,7 @@ function apply(ctx) {
|
|
|
6023
6123
|
return a === null || z === null ? 0 : Math.max(0, z - a)
|
|
6024
6124
|
}
|
|
6025
6125
|
const plannedMin = (Array.isArray(day.planned) ? day.planned : [])
|
|
6026
|
-
.filter((b) => String(b?.node ?? '') === node)
|
|
6126
|
+
.filter((b) => competency ? b?.competency === competency : String(b?.node ?? '') === node && !b?.competency)
|
|
6027
6127
|
.reduce((t, b) => t + blockMin(b), 0)
|
|
6028
6128
|
|
|
6029
6129
|
// 同一天同一个点:**没给题号时**只留一条 —— 这张表没有开始时间,
|
|
@@ -6032,15 +6132,20 @@ function apply(ctx) {
|
|
|
6032
6132
|
// (09-21 的 ST 表:P2880 独立 AC、1548B 带提示 AC),压成一条等于
|
|
6033
6133
|
// 把"他独立做出过这个点的题"从台账里抹掉。
|
|
6034
6134
|
const idx = list.findIndex((r) =>
|
|
6035
|
-
String(r?.node ?? '') === node && String(r?.problemId ?? '') === problemId)
|
|
6135
|
+
(competency ? r?.competency === competency : String(r?.node ?? '') === node && !r?.competency) && String(r?.problemId ?? '') === problemId)
|
|
6036
6136
|
// 需求 #8:`plannedMin` 不再写进这条记录(见上面那段注释)
|
|
6037
6137
|
const entry = { node, actualMin, solved, independent }
|
|
6138
|
+
const pendingAction = loadProgress().pendingActions?.[identity.key]
|
|
6139
|
+
if (competency) {
|
|
6140
|
+
entry.competency = competency
|
|
6141
|
+
entry.kind = pendingAction?.kind ?? 'diagnostic'
|
|
6142
|
+
}
|
|
6038
6143
|
if (problemId) entry.problemId = problemId
|
|
6039
6144
|
if (note) entry.note = note
|
|
6040
6145
|
const replaced = idx >= 0
|
|
6041
6146
|
if (replaced) list[idx] = entry
|
|
6042
6147
|
else list.push(entry)
|
|
6043
|
-
const sameDayEntries = list.filter((r) => String(r?.node ?? '') === node).length
|
|
6148
|
+
const sameDayEntries = list.filter((r) => competency ? r?.competency === competency : String(r?.node ?? '') === node && !r?.competency).length
|
|
6044
6149
|
|
|
6045
6150
|
const w = setScheduleField(d, 'actual', list)
|
|
6046
6151
|
if (!w.ok) return deny(w.reject)
|
|
@@ -6061,35 +6166,39 @@ function apply(ctx) {
|
|
|
6061
6166
|
let passCount = 0
|
|
6062
6167
|
try {
|
|
6063
6168
|
const prog = loadProgress()
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
String(p?.
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6169
|
+
if (!competency) {
|
|
6170
|
+
const rec = { ...(prog.nodes[node] ?? {}) }
|
|
6171
|
+
const passes = Array.isArray(rec.passes) ? rec.passes.slice() : []
|
|
6172
|
+
const e = { date: d, solved, independent, minutes: actualMin }
|
|
6173
|
+
if (problemId) e.problemId = problemId
|
|
6174
|
+
if (note) e.note = note
|
|
6175
|
+
// 去重键和 SCHEDULE 那边**必须一致**(日期 + 节点 + 题号):
|
|
6176
|
+
// 两边口径分家的话,`coach_status` 的条目数和 `coach_next` 的计划
|
|
6177
|
+
// 会对不上同一件事 —— 这个项目反复在治的就是这个病。
|
|
6178
|
+
const pi = passes.findIndex((p) => String(p?.date ?? '') === d &&
|
|
6179
|
+
String(p?.problemId ?? '') === problemId)
|
|
6180
|
+
if (pi >= 0) passes[pi] = e
|
|
6181
|
+
else passes.push(e)
|
|
6182
|
+
rec.passes = passes
|
|
6183
|
+
prog.nodes[node] = rec
|
|
6184
|
+
passCount = passes.length
|
|
6185
|
+
}
|
|
6078
6186
|
// 押着的动作**落地即销**(需求 #6):这个节点上布置过、他这会儿记了
|
|
6079
6187
|
// 一条做完记录 = 就是做了。撤销是 coach_unassign 的活(那条路不写 passes)——
|
|
6080
6188
|
// 两个出口分开,是为了不让"做完了"和"不做了"在账上长成一个样。
|
|
6081
|
-
if (prog.pendingActions && Object.prototype.hasOwnProperty.call(prog.pendingActions,
|
|
6082
|
-
delete prog.pendingActions[
|
|
6189
|
+
if (prog.pendingActions && Object.prototype.hasOwnProperty.call(prog.pendingActions, identity.key)) {
|
|
6190
|
+
delete prog.pendingActions[identity.key]
|
|
6083
6191
|
actionCleared = true
|
|
6084
6192
|
}
|
|
6085
6193
|
saveProgress(prog)
|
|
6086
6194
|
recorded = true
|
|
6087
|
-
passCount =
|
|
6088
|
-
} catch { /*
|
|
6195
|
+
if (competency) passCount = sameDayEntries
|
|
6196
|
+
} catch { actionCleared = false /* 日程已保存,进度未保存,动作仍在。 */ }
|
|
6089
6197
|
|
|
6090
6198
|
return {
|
|
6091
6199
|
ok: true, reject: '', date: d, node,
|
|
6092
|
-
nodeName:
|
|
6200
|
+
nodeName: identity.name,
|
|
6201
|
+
...(competency ? { competency } : {}),
|
|
6093
6202
|
plannedMin, actualMin, deviation: actualMin - plannedMin, solved, independent,
|
|
6094
6203
|
replaced, problemId, sameDayEntries,
|
|
6095
6204
|
totalLogged: list.length, recorded, actionCleared, passCount, updated: w.updated,
|
|
@@ -6097,8 +6206,8 @@ function apply(ctx) {
|
|
|
6097
6206
|
},
|
|
6098
6207
|
}))
|
|
6099
6208
|
|
|
6100
|
-
//
|
|
6101
|
-
registerStateHook(ctx)
|
|
6209
|
+
// 每个 turn 注入当前位置与续课线索,教学判断仍交给小鲸。
|
|
6210
|
+
registerStateHook(ctx, teaching)
|
|
6102
6211
|
|
|
6103
6212
|
// 技能树页面(可选注入 —— 没有 webServer 的环境跳过,工具照常工作)
|
|
6104
6213
|
ctx.inject(['webServer'], (sctx) => { registerPages(sctx) })
|