acmer-coach 2.5.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/index.js CHANGED
@@ -38,20 +38,20 @@ import { migratePersonalWorkspace as migrateWorkspacePersonalFiles } from './lib
38
38
  const name = 'acmer-coach'
39
39
  const inject = ['tools']
40
40
 
41
- const VERSION = '2.5.0'
41
+ const VERSION = '2.6.0'
42
42
 
43
- // 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
44
- // 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
43
+ // 默认题目循环:40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
44
+ // 小诊断和完整 VP 使用独立时长,不强加题目循环的收尾。
45
45
  //
46
46
  // ⚠️ **单位统一(需求 #7)**:系统里说「40 分钟」,一律指
47
47
  // **他自己动手做题的净时间** —— 止损点就是它。收尾那 20 分钟(遗留 10 +
48
48
  // 重写 10)**固定**、不随净时间压缩。
49
- // 净 40(默认)→ 整块 60;净 30(下限)→ 整块 50
49
+ // 净 40(默认)→ 整块 60;显式小任务可缩短净时间
50
50
  // coach_assign 的参数曾经是"总时长"(工具内部减 20 才是自己做),而
51
51
  // coach_plan 的 cycle 和规则文件里的 40 都是"自己做" —— 同一个数两个意思,
52
52
  // 报时间线就会卡在这儿(传 40,实际只拿到 20 分钟自己做)。
53
53
  const NET_DEFAULT_MIN = 40 // 自己做:默认
54
- const NET_FLOOR_MIN = 30 // 自己做:下限。再短就该换更小的题,不是压扁循环
54
+ const NET_FLOOR_MIN = 1 // 实际时间盒由教练按任务决定
55
55
  const TAIL_MIN = 20 // 解决遗留(10) + 重写(10),不随净时间压缩
56
56
  const CYCLE_TOTAL_MIN = NET_DEFAULT_MIN + TAIL_MIN // 60:题目循环的整块长度(plan 的 cycle 就是它)
57
57
 
@@ -84,6 +84,7 @@ function createStores(vpDir) {
84
84
  })
85
85
  curriculum = createCurriculumStore({
86
86
  dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
87
+ loadCompetencies: () => strategy.loadCompetencies(),
87
88
  loadStrategyEvidence: () => strategy.evidence().evidence,
88
89
  })
89
90
  }
@@ -468,12 +469,13 @@ function setScheduleField(date, field, value) {
468
469
  // 而排程本身是**教学判断**(先补哪个点、排多密、今天要不要减量),
469
470
  // 那正是教练存在的理由,不该退回给训练员。
470
471
  //
471
- // 三种块(方针 §4.1.2)。**时长是常量,调用方不能传** ——
472
- // 传了就会冒出「排个 37 分钟的循环块」,而 40/10/10 的止损点全靠这个时长守着。
472
+ // 块类型保留已有默认时长,允许教练按任务明确整块时长。
473
473
  const SCHED_KINDS = {
474
474
  cycle: { min: 60, name: '题目循环' },
475
475
  review: { min: 25, name: '复习一题' },
476
476
  exam: { min: 90, name: '章节检测' },
477
+ diagnostic: { min: 15, name: '短诊断' },
478
+ vp: { min: 300, name: '完整 VP' },
477
479
  }
478
480
 
479
481
  // 网格范围。⚠️ **必须和 skilltree_template.html 里的 HOUR_FROM / HOUR_TO 逐字一致**:
@@ -625,13 +627,18 @@ function planDay(date, blocks, opts = {}) {
625
627
  return
626
628
  }
627
629
  const node = String(b?.node ?? '').trim()
628
- if (!byId.has(node)) {
629
- const near = suggest(node, [...byId.values()])
630
- errs.push(`${no}:地图里没有「${node}」这个知识点,别自己造。` +
631
- (near.length ? `相近的有:${near.join(' / ')}` : '先用 coach_next 看他该学什么。'))
630
+ const competency = String(b?.competency ?? '').trim()
631
+ const identity = trainingIdentity(node, competency)
632
+ if (!identity.ok) {
633
+ errs.push(`${no}:${identity.reject}`)
632
634
  return
633
635
  }
634
- out.push({ a, z: a + spec.min, kind, node, no })
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 })
635
642
  })
636
643
 
637
644
  // 排到网格外面的块,页面画不出来
@@ -672,6 +679,7 @@ function planDay(date, blocks, opts = {}) {
672
679
 
673
680
  const planned = sorted.map((b) => ({
674
681
  from: minHM(b.a), to: minHM(b.z), kind: b.kind, node: b.node,
682
+ ...(b.competency ? { competency: b.competency } : {}),
675
683
  }))
676
684
  const w = setScheduleField(d, 'planned', planned)
677
685
  if (!w.ok) return w
@@ -681,8 +689,9 @@ function planDay(date, blocks, opts = {}) {
681
689
  planned: sorted.map((b) => ({
682
690
  from: minHM(b.a), to: minHM(b.z), minutes: b.z - b.a,
683
691
  kind: b.kind, kindName: SCHED_KINDS[b.kind].name,
684
- node: b.node, nodeName: byId.get(b.node)?.name ?? b.node,
685
- domain: byId.get(b.node)?.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 } : {}),
686
695
  })),
687
696
  // 只报「这天一共排了多少分钟」这个**事实**(他排完之后自己看表用)。
688
697
  planMin,
@@ -757,11 +766,12 @@ function unplanDay(date, opts = {}) {
757
766
  if (!w.ok) return w
758
767
 
759
768
  const { byId } = loadMap()
760
- const nameOf = (b) => byId.get(String(b?.node ?? ''))?.name ?? String(b?.node ?? '')
769
+ const nameOf = (b) => trainingIdentity(String(b?.node ?? ''), String(b?.competency ?? '')).name ?? String(b?.node ?? '')
761
770
  const pack = (b) => ({
762
771
  from: String(b?.from ?? ''), to: String(b?.to ?? ''),
763
772
  kind: String(b?.kind ?? ''), kindName: SCHED_KINDS[b?.kind]?.name ?? String(b?.kind ?? ''),
764
773
  node: String(b?.node ?? ''), nodeName: nameOf(b),
774
+ ...(b?.competency ? { competency: String(b.competency) } : {}),
765
775
  })
766
776
  return {
767
777
  ok: true, reject: '', date: d,
@@ -848,7 +858,8 @@ function scheduleOverview(days = SCHED_WINDOW, fromDate = '') {
848
858
  kind: String(b?.kind ?? ''),
849
859
  kindName: SCHED_KINDS[b?.kind]?.name ?? String(b?.kind ?? ''),
850
860
  node: String(b?.node ?? ''),
851
- nodeName: byId.get(String(b?.node ?? ''))?.name ?? String(b?.node ?? ''),
861
+ nodeName: trainingIdentity(String(b?.node ?? ''), String(b?.competency ?? '')).name ?? String(b?.node ?? ''),
862
+ ...(b?.competency ? { competency: String(b.competency) } : {}),
852
863
  }
853
864
  })
854
865
  const planMin = planned.reduce((t, b) => t + b.minutes, 0)
@@ -877,12 +888,13 @@ function scheduleOverview(days = SCHED_WINDOW, fromDate = '') {
877
888
  const e = {
878
889
  node: nodeId,
879
890
  plannedMin: planned
880
- .filter((b) => String(b.node) === nodeId)
891
+ .filter((b) => a?.competency ? b.competency === a.competency : String(b.node) === nodeId && !b.competency)
881
892
  .reduce((t, b) => t + Number(b.minutes ?? 0), 0),
882
893
  actualMin: Number(a?.actualMin ?? 0),
883
894
  solved: a?.solved === true,
884
895
  independent: a?.independent === true,
885
896
  }
897
+ if (a?.competency) e.competency = String(a.competency)
886
898
  if (a?.note) e.note = String(a.note)
887
899
  return e
888
900
  }),
@@ -907,6 +919,13 @@ function resolveCursor(explicit) {
907
919
  return s || loadProgress().cursor
908
920
  }
909
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
+
910
929
  // 状态读取。**缺省即未学** —— 只有非未学的节点才会被写进文件。
911
930
  function nodeStatus(progress, id) {
912
931
  const s = progress?.nodes?.[id]?.status
@@ -2216,6 +2235,11 @@ function apply(ctx) {
2216
2235
  profile: { type: 'object', required: true, additionalProperties: false, properties: {
2217
2236
  id: { type: 'string', required: true }, title: { type: 'string', required: true }, scope: { type: 'string', required: true }, status: { type: 'string', required: true },
2218
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
+ } },
2219
2243
  } }, daysRemaining: { type: 'integer', required: true },
2220
2244
  course: { type: 'object', required: true, additionalProperties: false, properties: {
2221
2245
  exists: { type: 'boolean', required: true }, revision: { type: 'integer', required: true },
@@ -2243,7 +2267,7 @@ function apply(ctx) {
2243
2267
  focusExpired: Boolean(snapshot.focusExpired), missingFreshEvidence: snapshot.missingFreshEvidence ?? [],
2244
2268
  }
2245
2269
  return { ...v, course, text: v.ok ? [`【训练取舍依据】长期:${course.goal || '尚未建立'}\n本次:${v.contest} · ${v.deadline} · 剩余${v.daysRemaining}天`,
2246
- `样本:${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(';')}`,
2247
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}`),
2248
2272
  course.exists ? `课程阶段:${course.phaseTitle}(${course.phaseId})\n课程主线:${course.focusNodes.join('、') || '能力证据驱动'}\n比赛能力:${course.phaseCompetencies.join('、') || '地图知识点'}` : '课程阶段:尚未建立,用 coach_curriculum assess/read/create 建立长期路线。',
2249
2273
  course.pendingNodes.length ? `课程未完成动作:${course.pendingNodes.join('、')}` : '',
@@ -3017,15 +3041,15 @@ function apply(ctx) {
3017
3041
  // ① node 必须在地图里 → 挡住它编知识点
3018
3042
  // ② node 必须是游标的下一层 → 挡住它跳过前置乱指
3019
3043
  // ③ 前置必须都满足 → 挡住「学这个之前你得先学那个」这类空头指令
3020
- // ④ 时间盒不能短于 30 分钟 → 挡住排出没意义的表
3044
+ // ④ 时间盒必须是一天内的正整数分钟
3021
3045
  // 校验不过就**拒绝**,并把理由说清楚 —— 拒绝比给个错指令好。
3022
3046
  ctx.tools.register(defineTool({
3023
3047
  name: 'coach_assign',
3024
3048
  description:
3025
3049
  '布置**一个**训练动作。给训练员下指令时**必须**走这个工具,不要在正文里自由发挥 —— ' +
3026
- '它会把指令固定成「做什么 + 时间盒 + 交付物」,并校验你挑的知识点:' +
3027
- '必须存在于地图、必须是当前游标的下一层、前置必须都满足。校验不过会被拒绝并说明原因。' +
3028
- '它一次只收一个节点,所以**给不出候选列表** —— 这正是设计意图。' +
3050
+ '它保存「做什么 + 时间盒 + 交付物」,支持地图节点或已有比赛能力:' +
3051
+ '节点需符合游标或课程主线且前置满足;纯能力训练不要求游标。诊断和 VP 不强加做题收尾。' +
3052
+ '它一次只收一个训练目标;时长由教练按任务决定。' +
3029
3053
  '有长期课程时先读 coach_curriculum,带 curriculumRevision;主线必须属于当前阶段,' +
3030
3054
  '补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。' +
3031
3055
  '比赛目标与重点排序是决策参考,不会限制在排名前三;结合学生表现说明 why。' +
@@ -3035,23 +3059,21 @@ function apply(ctx) {
3035
3059
  type: 'string',
3036
3060
  description: '当前游标。不填就用 PROGRESS.yaml 里的(这是常态)。',
3037
3061
  },
3038
- node: { type: 'string', required: true, description: '地图知识点 id。无课程时从 coach_next 候选取;有课程时沿用当前主线,仍核查前置' },
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 是整项时长,不强加收尾或讲课。' },
3039
3065
  deliverable: { type: 'string', required: true, description: '做完交什么,如「把代码贴给我」' },
3040
3066
  minutes: {
3041
3067
  type: 'number',
3042
- description:
3043
- `**他自己动手做题的净时间**(分钟)—— 止损点就是它,不是整块长度。` +
3044
- `默认 ${NET_DEFAULT_MIN},最低 ${NET_FLOOR_MIN}。` +
3045
- `整块长度 = 净时间 + ${TAIL_MIN}(解决遗留 10 + 重写 10,固定不压缩):` +
3046
- `净 ${NET_DEFAULT_MIN} → 整块 ${CYCLE_TOTAL_MIN},和日程表上的 cycle 块对得上。`,
3068
+ description: '1–960 的整数分钟。地图 practice 是自己动手的净时间(默认40,另加20收尾);diagnostic/vp或纯能力动作是整项时长。',
3047
3069
  },
3048
3070
  teachMinutes: {
3049
3071
  type: 'number',
3050
3072
  description:
3051
- '**先讲一遍**的时间(分钟)。开一个他从没碰过的知识点时**必须给**,' +
3073
+ 'practice 开全新地图知识点时先讲的时间(分钟);diagnostic/vp 不要求讲解。' +
3052
3074
  '不给会被拒绝 —— 直接扔题让他自己卡,那是给学过的人的练法。' +
3053
3075
  '讲多久**你自己定**(不参考任何固定值),看你判断他需要多少。' +
3054
- `最低 ${TEACH_FLOOR_MIN} 分钟,没有上限。已经在学或学过的节点不用给。` +
3076
+ `给了讲解则最低 ${TEACH_FLOOR_MIN} 分钟,整项动作不得超过960分钟。已经在学或学过的节点不用给。` +
3055
3077
  '这是计划时长,不能证明已经讲过或学生理解了。',
3056
3078
  },
3057
3079
  why: { type: 'string', description: '为什么挑它(一句话,进台账,供训练员质疑时举证)' },
@@ -3069,6 +3091,7 @@ function apply(ctx) {
3069
3091
  cursor: { type: 'string', required: true },
3070
3092
  node: { type: 'string', required: true },
3071
3093
  nodeName: { type: 'string', required: true },
3094
+ competency: { type: 'string' },
3072
3095
  domain: { type: 'string', required: true },
3073
3096
  // 需求 #7:minutes 是**净时间**(他自己做多久);整块 = minutes + tailMinutes
3074
3097
  minutes: { type: 'integer', required: true },
@@ -3111,7 +3134,9 @@ function apply(ctx) {
3111
3134
  return [{
3112
3135
  type: 'text',
3113
3136
  text: `❌ 这条指令布置不了:${v.reject}\n\n` +
3114
- `**不要**改用候选列表交差 —— 先调 coach_next 重新挑一个就绪的,再调本工具。`,
3137
+ (v.competency
3138
+ ? '不要用候选列表代替指令;按拒绝原因核对能力、课程版本或时长,再调本工具。'
3139
+ : `**不要**改用候选列表交差 —— 先调 coach_next 重新挑一个就绪的,再调本工具。`),
3115
3140
  }]
3116
3141
  }
3117
3142
  const table = v.blocks
@@ -3122,7 +3147,7 @@ function apply(ctx) {
3122
3147
  text: [
3123
3148
  `【今天这一个动作】${v.nodeName} [${v.domain}]`,
3124
3149
  ``,
3125
- `做什么 ${v.nodeName}(地图节点 ${v.node})`,
3150
+ `做什么 ${v.nodeName}(${v.competency ? `比赛能力 ${v.competency}` : `地图节点 ${v.node}`})`,
3126
3151
  // 需求 #7:两个数都印出来 ——「同一个 40 两个意思」就是这条要治的病。
3127
3152
  `时间盒 自己做 ${v.minutes} + 收尾 ${v.tailMinutes} = 共 ${v.minutes + v.tailMinutes} 分钟` +
3128
3153
  (v.teachMinutes ? `(另有先讲 ${v.teachMinutes} 分钟,全场合计 ${v.totalMinutes})` : ''),
@@ -3152,12 +3177,15 @@ function apply(ctx) {
3152
3177
  const { byId, dependents, stamp } = loadMap()
3153
3178
  const cursor = resolveCursor(args?.cursor)
3154
3179
  const node = String(args?.node ?? '').trim()
3180
+ const competency = String(args?.competency ?? '').trim()
3181
+ const identity = trainingIdentity(node, competency)
3155
3182
  const deliverable = String(args?.deliverable ?? '').trim()
3183
+ const kind = String(args?.kind ?? 'practice').trim()
3184
+ const tail = kind === 'practice' && !competency ? TAIL_MIN : 0
3156
3185
  let why = String(args?.why ?? '').trim()
3157
3186
  // 需求 #7:这个数现在是**净时间**(他自己动手做多久),不是整块长度。
3158
3187
  // 整块 = 净 + TAIL_MIN(收尾固定 20),下面 blocks 和 totalMinutes 都照这个算。
3159
- const net = Number.isFinite(args?.minutes)
3160
- ? Math.trunc(args.minutes) : NET_DEFAULT_MIN
3188
+ const net = args?.minutes === undefined ? NET_DEFAULT_MIN : args.minutes
3161
3189
 
3162
3190
  // 校验失败一律走这里:回一个带原因的空壳,**不抛异常** ——
3163
3191
  // 抛异常会让对话断在那里,而拒绝是要让模型看见理由并改选的。
@@ -3165,7 +3193,7 @@ function apply(ctx) {
3165
3193
  accepted: false, reject,
3166
3194
  cursor, node, nodeName: byId.get(node)?.name ?? '',
3167
3195
  domain: byId.get(node)?.domain ?? '',
3168
- minutes: net, teachMinutes: 0, tailMinutes: TAIL_MIN, totalMinutes: 0, boxNote: '',
3196
+ minutes: Number.isInteger(net) ? net : 0, teachMinutes: 0, tailMinutes: tail, totalMinutes: 0, boxNote: '',
3169
3197
  deliverable, why, mapStamp: stamp, blocks: [], selfTarget: false,
3170
3198
  // 需求 #6:拒绝时也把这两个字段摆出来 —— 白名单式的 schema
3171
3199
  // (additionalProperties: false + required:true)要求每个出口同形,
@@ -3174,15 +3202,16 @@ function apply(ctx) {
3174
3202
  })
3175
3203
 
3176
3204
  // ① 游标认不认得。「没设」和「打错」分开提示,别让模型去猜。
3177
- if (!cursor) {
3205
+ if (!identity.ok) return deny(identity.reject)
3206
+ if (!competency && !cursor) {
3178
3207
  return deny('还没设游标(PROGRESS.yaml 里 cursor 是空的)。' +
3179
3208
  '先确认训练员现在在哪个知识点,用 coach_set_cursor 设上。')
3180
3209
  }
3181
- if (!byId.has(cursor)) {
3210
+ if (!competency && !byId.has(cursor)) {
3182
3211
  return deny(`地图里没有游标「${cursor}」。`)
3183
3212
  }
3184
3213
  // ② 节点必须真实存在 —— 挡住编造知识点
3185
- if (!byId.has(node)) {
3214
+ if (!competency && !byId.has(node)) {
3186
3215
  const near = suggest(node, [...byId.values()])
3187
3216
  return deny(
3188
3217
  `地图里没有「${node}」这个知识点,别自己造。` +
@@ -3190,7 +3219,7 @@ function apply(ctx) {
3190
3219
  }
3191
3220
  let route
3192
3221
  try {
3193
- route = curriculum.assignment({ node, revision: args?.curriculumRevision,
3222
+ route = curriculum.assignment({ node, competency, revision: args?.curriculumRevision,
3194
3223
  purpose: args?.purpose, reason: args?.routeReason })
3195
3224
  } catch (err) { return deny(`课程读取失败:${err.message};先核查,不凭印象布置。`) }
3196
3225
  if (!route.ok) return deny(route.reject)
@@ -3199,7 +3228,9 @@ function apply(ctx) {
3199
3228
  why = `课程「${route.binding.phaseTitle}」· ${route.binding.purpose}:${why}` +
3200
3229
  (route.binding.reason ? `;补充依据:${route.binding.reason}` : '')
3201
3230
  }
3202
- const targetRoute = strategy.route(node, { purpose: args?.purpose, courseBound: Boolean(route.binding) })
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) })
3203
3234
  if (!targetRoute.ok) return deny(targetRoute.reject)
3204
3235
  if (targetRoute.competencyIds.length) {
3205
3236
  why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '目标能力对应的训练动作'}`
@@ -3214,12 +3245,12 @@ function apply(ctx) {
3214
3245
  // 教练就一个动作都给不出来了 —— 它只能眼睁睁看着人卡住。
3215
3246
  //
3216
3247
  // 已经 verified 的不许再指回来:学完了就该往前走。
3217
- if (node === cursor) {
3248
+ if (!competency && node === cursor) {
3218
3249
  if (nodeStatus(loadProgress(), cursor) === 'verified' && route.binding?.purpose !== 'review') {
3219
3250
  return deny(`「${cursor}」已经验证过了 —— 学完了就往前走,` +
3220
3251
  '别在验证过的地方打转。用 coach_next 看下一层,或者往前沿退。')
3221
3252
  }
3222
- } else if (!(dependents.get(cursor) ?? []).includes(node) && !route.binding) {
3253
+ } else if (!competency && !(dependents.get(cursor) ?? []).includes(node) && !route.binding) {
3223
3254
  return deny(`「${node}」不是「${cursor}」的下一层。先调 coach_next 看它到底能开哪些。`)
3224
3255
  }
3225
3256
  // ④ 前置必须都满足 —— 挡住「你得先学那个」这类空头指令
@@ -3227,17 +3258,13 @@ function apply(ctx) {
3227
3258
  // 已学集合走**共用实现**:闭包(游标) ∪ 显式标为 学过/已验证 的节点。
3228
3259
  // 原先这里只展开游标自己,训练员标过的几十个节点全被当没看见。
3229
3260
  const assumed = assumedLearned(loadProgress(), byId, [cursor])
3230
- const missing = (byId.get(node).depends ?? []).filter((d) => !assumed.has(d))
3261
+ const missing = (byId.get(node)?.depends ?? []).filter((d) => !assumed.has(d))
3231
3262
  if (missing.length) {
3232
3263
  return deny(`「${node}」还缺前置:${missing.join('、')}。它现在还开不了。`)
3233
3264
  }
3234
3265
  // ⑤ 时间盒下限(需求 #7:比的是**净时间**,不是整块长度)
3235
- if (net < NET_FLOOR_MIN) {
3236
- return deny(
3237
- `净时间 ${net} 分钟太短,一个题目循环压不到这个长度。` +
3238
- '时间不够应该换更小的题,或者改用 review 块(25 分钟),' +
3239
- `而不是把循环压扁。(下限:自己做 ${NET_FLOOR_MIN} 分钟 = 整块 ${NET_FLOOR_MIN + TAIL_MIN} 分钟)`)
3240
- }
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 的整数分钟。')
3241
3268
  if (!deliverable) return deny('没写交付物。做完交什么必须说清楚,否则训练员不知道什么时候算完。')
3242
3269
 
3243
3270
  // ⑥ **开新知识点必须先讲**。
@@ -3260,11 +3287,13 @@ function apply(ctx) {
3260
3287
  // 而它存在的理由正是防「直接出题」。判据用 taughtEvidence,
3261
3288
  // 和 coach_test 开卷、coach_status 报「讲于哪」是同一个实现。
3262
3289
  const taughtBefore = taughtEvidence(loadProgress(), node) !== null
3263
- const isNewNode = nodeStatus(loadProgress(), node) === 'none' &&
3290
+ const isNewNode = !competency && nodeStatus(loadProgress(), node) === 'none' &&
3264
3291
  !(node === cursor && taughtBefore)
3265
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 分钟。')
3266
3295
 
3267
- if (isNewNode && teach <= 0) {
3296
+ if (kind === 'practice' && isNewNode && teach <= 0) {
3268
3297
  return deny(`「${byId.get(node)?.name ?? node}」是**全新知识点**(图上没人碰过),` +
3269
3298
  '而你没给讲解段。\n' +
3270
3299
  '开新知识点要**先拿一道例题讲一遍**再出题 —— 直接扔题让他自己卡,' +
@@ -3273,23 +3302,19 @@ function apply(ctx) {
3273
3302
  }
3274
3303
  if (teach > 0 && teach < TEACH_FLOOR_MIN) {
3275
3304
  return deny(`讲解 ${teach} 分钟太短 —— 讲一道例题至少 ${TEACH_FLOOR_MIN} 分钟,` +
3276
- '再短就只是念一遍结论,等于没讲。讲多久你定,**没有上限**。')
3305
+ '再短就只是念一遍结论,等于没讲。讲多久你定,整项时长需在一天内。')
3277
3306
  }
3278
3307
 
3279
- const n = byId.get(node)
3308
+ const n = { name: identity.name, domain: identity.domain }
3280
3309
  const solve = net // 需求 #7:净时间直接就是「自己做」那一段
3281
3310
 
3282
3311
  // 需求 #7 的两句提醒(只影响这段话,不挡布置):
3283
3312
  // · 压到默认以下 → 提醒它压的是**止损点**,不是块
3284
3313
  // · 净 ≠ 默认 → 提醒它这个整块长度**表上排不出来**(别让两个工具再打架)
3285
- const squeeze = net < NET_DEFAULT_MIN
3286
- ? `净 ${net} 分钟低于默认的净 ${NET_DEFAULT_MIN} —— 你压的是止损点,不是块;时间不够请换更小的题。`
3314
+ const shape = kind === 'practice' && net !== NET_DEFAULT_MIN
3315
+ ? `净 ${net} → 整块 ${net + tail} 分钟,排程时带 minutes=${net + tail + teach}。`
3287
3316
  : ''
3288
- const shape = net !== NET_DEFAULT_MIN
3289
- ? `净 ${net} → 整块 ${net + TAIL_MIN} 分钟,**表上排不出这个形状**` +
3290
- `(表上只有 cycle ${CYCLE_TOTAL_MIN} / review 25 / exam 90)。`
3291
- : ''
3292
- const boxNote = [squeeze, shape].filter(Boolean).join(' ')
3317
+ const boxNote = kind === 'practice' ? [shape].filter(Boolean).join(' ') : ''
3293
3318
 
3294
3319
  // ── 落盘:押上这个动作(需求 #6)─────────────────────
3295
3320
  // 布置的动作原先**只活在当次调用的返回里**:跨会话再开时教练不知道
@@ -3300,16 +3325,17 @@ function apply(ctx) {
3300
3325
  // 但要在输出里喊出来 —— 静默失败 = 下个会话重复布置。
3301
3326
  const progA = loadProgress()
3302
3327
  const pend = { ...(progA.pendingActions ?? {}) }
3303
- const replacedPending = Object.prototype.hasOwnProperty.call(pend, node)
3328
+ const replacedPending = Object.prototype.hasOwnProperty.call(pend, identity.key)
3304
3329
  const assignedAt = ymdToday()
3305
- pend[node] = {
3330
+ pend[identity.key] = {
3306
3331
  at: assignedAt, deliverable,
3307
3332
  // 存**拆开的**几个数,不存一个总数:总数在"整块 / 净时间"两种口径下
3308
3333
  // 含义不同,存总数等于把口径混进账本(那正是 #7 要治的病)。
3309
- solveMinutes: solve, tailMinutes: TAIL_MIN, teachMinutes: teach,
3310
- totalMinutes: net + teach + TAIL_MIN, why,
3334
+ solveMinutes: solve, tailMinutes: tail, teachMinutes: teach,
3335
+ totalMinutes: net + teach + tail, why, kind,
3336
+ ...(competency ? { competency } : {}),
3311
3337
  }
3312
- if (route.binding) pend[node].curriculum = route.binding
3338
+ if (route.binding) pend[identity.key].curriculum = route.binding
3313
3339
  // 布置讲解只保存计划;接触状态与理解证据由各自入口记录。
3314
3340
  progA.pendingActions = pend
3315
3341
  let pendingRecorded = true
@@ -3318,8 +3344,9 @@ function apply(ctx) {
3318
3344
  return {
3319
3345
  accepted: true, reject: '',
3320
3346
  cursor, node, nodeName: n.name ?? node, domain: n.domain ?? '',
3321
- minutes: net, teachMinutes: teach, tailMinutes: TAIL_MIN,
3322
- totalMinutes: net + teach + TAIL_MIN, boxNote,
3347
+ ...(competency ? { competency } : {}),
3348
+ minutes: net, teachMinutes: teach, tailMinutes: tail,
3349
+ totalMinutes: net + teach + tail, boxNote,
3323
3350
  deliverable, why, mapStamp: stamp, selfTarget: node === cursor,
3324
3351
  pendingRecorded, pendingReplaced: replacedPending,
3325
3352
  targetAligned: targetRoute.targetAligned, competencies: targetRoute.competencyIds,
@@ -3333,9 +3360,11 @@ function apply(ctx) {
3333
3360
  minutes: teach,
3334
3361
  }]
3335
3362
  : []),
3336
- { kind: 'solve', label: '自己做(限时,到点必须停)', minutes: solve },
3337
- { kind: 'resolve', label: '解决遗留(做出来了就复盘思路,没做出来就听讲)', minutes: 10 },
3338
- { kind: 'rewrite', label: '重写', minutes: 10 },
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
+ ] : []),
3339
3368
  ],
3340
3369
  }
3341
3370
  },
@@ -3413,6 +3442,7 @@ function apply(ctx) {
3413
3442
  nodeName: { type: 'string', required: true },
3414
3443
  date: { type: 'string', required: true },
3415
3444
  outcome: { type: 'string', required: true },
3445
+ independent: { type: 'boolean' },
3416
3446
  score: { type: 'integer', required: true },
3417
3447
  maxScore: { type: 'integer', required: true },
3418
3448
  minutes: { type: 'integer', required: true },
@@ -3497,6 +3527,8 @@ function apply(ctx) {
3497
3527
  node: { type: 'string', required: true },
3498
3528
  nodeName: { type: 'string', required: true },
3499
3529
  at: { type: 'string', required: true },
3530
+ competency: { type: 'string' },
3531
+ kind: { type: 'string' },
3500
3532
  // 押了几天。-1 = 日期不明(跟火候那边同一个约定,别让它算成 0)
3501
3533
  days: { type: 'integer', required: true },
3502
3534
  deliverable: { type: 'string', required: true },
@@ -3534,7 +3566,7 @@ function apply(ctx) {
3534
3566
  v.pendingActions.length
3535
3567
  ? '📌 **押着的动作**(布置了、还没落地):\n' +
3536
3568
  v.pendingActions.map((a) =>
3537
- ` · ${a.nodeName}${a.nodeName !== a.node ? `(${a.node})` : ''} ` +
3569
+ ` · ${a.nodeName}${a.node && a.nodeName !== a.node ? `(${a.node})` : ''} ` +
3538
3570
  `交付物 ${a.deliverable} ` +
3539
3571
  `时间盒 自己做 ${a.solveMinutes} + 收尾 ${a.tailMinutes} = ${a.solveMinutes + a.tailMinutes} 分钟` +
3540
3572
  (a.teachMinutes ? `,另加先讲 ${a.teachMinutes}(共 ${a.totalMinutes})` : '') +
@@ -3563,6 +3595,7 @@ function apply(ctx) {
3563
3595
  v.checkTotal
3564
3596
  ? `检测记录 ${v.checkTotal} 次,最近:` + v.recentChecks.slice(0, 4).map((c) =>
3565
3597
  `${c.date.slice(5)} ${c.nodeName} ${OUTCOME_TAG[c.outcome] ?? c.outcome}` +
3598
+ (c.outcome === 'passed' ? (c.independent === true ? '[独立]' : c.independent === false ? '[有帮助]' : '[独立性未知]') : '') +
3566
3599
  (c.outcome === 'overTime' ? `(${c.minutes}/${c.limit} 分钟)` : '') +
3567
3600
  (c.verifiedAt ? ` 验证于≈${c.verifiedAt}` : '') +
3568
3601
  (c.selfReported ? `[含 ${c.selfReported} 道自述题]` : '')).join(' · ')
@@ -3609,6 +3642,7 @@ function apply(ctx) {
3609
3642
  allChecks.push({
3610
3643
  node: id, nodeName: name,
3611
3644
  date: String(c?.date ?? ''), outcome: String(c?.outcome ?? ''),
3645
+ ...(typeof c?.independent === 'boolean' ? { independent: c.independent } : {}),
3612
3646
  score: Number(c?.score ?? 0), maxScore: Number(c?.maxScore ?? 0),
3613
3647
  minutes: Number(c?.minutes ?? 0), limit: Number(c?.limit ?? 0),
3614
3648
  verifiedAt: Number(c?.verifiedAt ?? 0),
@@ -3704,7 +3738,9 @@ function apply(ctx) {
3704
3738
  const then = parseYmd(at)
3705
3739
  const days = then ? Math.round((parseYmd(ymdToday()) - then) / 86400000) : -1
3706
3740
  return {
3707
- node: id, nodeName: byId.get(id)?.name ?? id, at, days,
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,
3708
3744
  deliverable: String(a?.deliverable ?? ''),
3709
3745
  solveMinutes: Number(a?.solveMinutes ?? 0),
3710
3746
  tailMinutes: Number(a?.tailMinutes ?? 0),
@@ -4956,25 +4992,19 @@ function apply(ctx) {
4956
4992
 
4957
4993
  // ── coach_grade:判卷 ────────────────────────────────────────
4958
4994
  //
4959
- // 检测闭环的收口。**「掌握」由检测决定,不由数据推断** —— 所以过了要写进
4960
- // verified:那是 verified 这一级的定义,不是顺手加的装饰。
4961
- //
4962
- // 三种结局**不能合并**:
4963
- // passed 分够 + 没超时 → 真过了,标 verified,可以推进
4964
- // overTime 分够 + 超时 → **不算过**。这是「不熟」的机械形态:
4965
- // 方法他有,就是慢。去向是加练,不是推进。
4966
- // failed 分不够 → 不过,交 coach_diagnose 判因
4995
+ // 卷面通过与独立验证分开:分数、用时只证明本次过卷;独立性未知不补成 true。
4996
+ // 历史 verified 不撤销,本次未独立验证也不刷新验证段位。
4967
4997
  //
4968
4998
  // 只依赖卷子本身(pending 里存了整份题目信息),不连数据库 ——
4969
4999
  // 判卷不该因为库被搬走或者锁了就失败。
4970
5000
  ctx.tools.register(defineTool({
4971
5001
  name: 'coach_grade',
4972
5002
  description:
4973
- '给正押着的那张检测卷判卷。他做完回来了,你收三样:每题 AC 没有、花了几分钟。\n' +
5003
+ '给正押着的那张检测卷判卷。收每题 AC 与否、实际分钟数和明确的独立性事实(未知就省略)。\n' +
4974
5004
  '判定:难易加权(易1/中2/难3,满分 6),**通过线 4 分且总用时不超过限时**。\n' +
4975
- '分够但超时 → 判「不熟」不算过(方法有,就是慢);分不够 → 不过。\n' +
4976
- '过了会自动把知识点标成「已验证」—— 那是 verified 的定义。\n' +
4977
- '判完给出下一步:过了就推进,不过就调 coach_diagnose 判因。',
5005
+ '分够但超时 → overTime;分不够 → failed。卷面通过且所有做出的题明确独立,才产生本次 verified。\n' +
5006
+ '接受过提示/题解/他人帮助,或独立性未知:只记过卷,保留原状态,不冒充独立掌握。\n' +
5007
+ '本次独立验证不证明隔日保持或迁移;下一步由教练结合课程和表现判断。',
4978
5008
  parameters: {
4979
5009
  results: {
4980
5010
  type: 'array',
@@ -4987,6 +5017,7 @@ function apply(ctx) {
4987
5017
  problemId: { type: 'string', required: true, description: '卷子里的题号,原样抄' },
4988
5018
  solved: { type: 'boolean', required: true, description: '这题最后 AC 了吗' },
4989
5019
  minutes: { type: 'number', required: true, description: '这题花了多少分钟(没做出来就填实际耗掉的)' },
5020
+ independent: { type: 'boolean', description: '这题是否未接受提示、题解或他人帮助而独立完成/尝试;未知就省略,不猜。' },
4990
5021
  },
4991
5022
  },
4992
5023
  },
@@ -5008,6 +5039,8 @@ function apply(ctx) {
5008
5039
  minutes: { type: 'integer', required: true },
5009
5040
  limit: { type: 'integer', required: true },
5010
5041
  status: { type: 'string', required: true },
5042
+ verified: { type: 'boolean', required: true, description: '本次卷面通过且所有做出题都有明确独立事实;不代表长期保持或迁移。' },
5043
+ independence: { type: 'string', required: true, description: '本次做出题的独立性:independent / assisted / unknown。' },
5011
5044
  verifiedAt: { type: 'integer', required: true },
5012
5045
  // 这张卷里有几道是「网上找的」(自述题)。>0 时这条 verified 的
5013
5046
  // 成色就要打折 —— 卷面上看得见,记录里也留痕。
@@ -5024,6 +5057,7 @@ function apply(ctx) {
5024
5057
  cfRating: { type: 'integer', required: true },
5025
5058
  solved: { type: 'boolean', required: true },
5026
5059
  minutes: { type: 'integer', required: true },
5060
+ independent: { type: 'boolean', description: '仅在本题明确报告独立性时返回。' },
5027
5061
  // 网上找的题的 cfRating 是自报估值 —— 不计入 verifiedAt(见上面的
5028
5062
  // solvedRatings 过滤)。来源得跟着结果走,不然判卷记录里分不出
5029
5063
  // 这道题是库里查的还是他自己找的。**schema 得声明它** ——
@@ -5038,7 +5072,7 @@ function apply(ctx) {
5038
5072
  render: (_args, v) => {
5039
5073
  if (!v.ok) return [{ type: 'text', text: `❌ 判不了:${v.reject}` }]
5040
5074
  const head = v.outcome === 'passed'
5041
- ? `✅ 过了 ${v.nodeName}`
5075
+ ? `✅ 卷面通过${v.verified ? ' · 本次独立验证' : ' · 未形成独立验证'} ${v.nodeName}`
5042
5076
  : v.outcome === 'overTime'
5043
5077
  ? `⏱ 分数够,但超时了 —— 这不算过 ${v.nodeName}`
5044
5078
  : `❌ 没过 ${v.nodeName}`
@@ -5050,29 +5084,29 @@ function apply(ctx) {
5050
5084
  // 直接影响这条 verified 值不值钱,而"过了"这两个字会盖过一切。
5051
5085
  ...(v.selfReported
5052
5086
  ? [`⚠️ 其中 ${v.selfReported} 道是**网上找的**:题号没进过题库、难度是估的、` +
5053
- '「做出来了」也只有他这句话。它不算进验证段位 —— 这条 verified 的成色比真题卷低,' +
5054
- '想坐实这个知识点,下次用题库里的题再测一次。']
5087
+ '做题结果是报告的事实,难度估值不计入验证段位;需要时用题库题再核对。']
5055
5088
  : []),
5056
5089
  '',
5057
5090
  ]
5058
5091
  for (const p of v.problems) {
5059
5092
  lines.push(` ${p.solved ? '✓' : '✗'} ${p.band} ${p.problemId} ${p.title} ` +
5060
- `${p.minutes} 分钟(权重 ${p.weight})`)
5093
+ `${p.minutes} 分钟(权重 ${p.weight};${p.independent === true ? '独立' : p.independent === false ? '有帮助' : '独立性未记录'})`)
5061
5094
  }
5062
5095
  lines.push('', '── 下一步 ──')
5063
5096
  if (v.outcome === 'passed') {
5064
- lines.push(` 这一节标成「已验证」了 —— 验证于 ≈${v.verifiedAt} 段位。` +
5065
- '(等他 rating 涨上去,这个段位的题也会变难,到时候该复检)')
5097
+ lines.push(v.verified
5098
+ ? ` 本次达到独立验证条件${v.verifiedAt ? `,题库难度上限≈${v.verifiedAt}` : ',没有可核对的题库难度段位'};不代表隔日仍会或能迁移。`
5099
+ : v.independence === 'assisted'
5100
+ ? ' 卷面过了,但做出题有帮助:保留原状态,不新增掌握证书;需要时再独立检测。'
5101
+ : ' 卷面过了,但独立性未记录:保留原状态,不新增掌握证书;不要把未知补成独立。')
5066
5102
  const course = curriculum.execute({ action: 'read' })
5067
5103
  lines.push(course.exists || !course.ok
5068
5104
  ? ' 先读 coach_curriculum 检查当前阶段验收;单点过卷不会自动推进阶段。继续当前主线,整体成果达标后再 advance。'
5069
- : ' 用 coach_assign 布置下一节 —— **推进,别回头**。')
5105
+ : ' 根据实际理解和独立表现,选择补练、复检、迁移或下一节。')
5070
5106
  } else if (v.outcome === 'overTime') {
5071
- lines.push(' 他不是不会,是慢。别推进,也别当「不会」去补课 —— ' +
5072
- '换个同难度的题限时加练,把速度压进限时里。')
5107
+ lines.push(' 本次超过限时。结合做题过程判断是建模、实现还是速度问题,再决定补讲或限时加练。')
5073
5108
  } else {
5074
- lines.push(' 让他把没做出来的那题代码贴过来,调 coach_diagnose 判因 —— ' +
5075
- '分清是「不会」还是「不认真」,再决定补课还是重写。')
5109
+ lines.push(' 结合没做出题的题面、代码和过程,用 coach_diagnose 判因,再决定补讲、练习或重写。')
5076
5110
  }
5077
5111
  return [{ type: 'text', text: lines.join('\n') }]
5078
5112
  },
@@ -5083,7 +5117,7 @@ function apply(ctx) {
5083
5117
  const deny = (reject) => ({
5084
5118
  ok: false, reject, node: '', nodeName: '', domain: '',
5085
5119
  outcome: '', score: 0, maxScore: 0, passScore: PASS_SCORE,
5086
- minutes: 0, limit: 0, status: '', verifiedAt: 0, selfReported: 0, problems: [], checks: 0,
5120
+ minutes: 0, limit: 0, status: '', verified: false, independence: 'unknown', verifiedAt: 0, selfReported: 0, problems: [], checks: 0,
5087
5121
  })
5088
5122
 
5089
5123
  const prog = loadProgress()
@@ -5107,6 +5141,11 @@ function apply(ctx) {
5107
5141
  const keyOf = (id) => normId('nowcoder', String(id ?? '').trim())
5108
5142
  const byIdGiven = new Map()
5109
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
+ }
5110
5149
  const key = keyOf(r?.problemId)
5111
5150
  if (byIdGiven.has(key)) return deny(`题号 ${r.problemId} 报了两遍。`)
5112
5151
  byIdGiven.set(key, r)
@@ -5124,6 +5163,7 @@ function apply(ctx) {
5124
5163
  }
5125
5164
  const solved = r.solved === true
5126
5165
  const spent = Math.max(0, Math.round(Number(r.minutes) || 0))
5166
+ if (!Number.isSafeInteger(minutes + spent)) return deny('总分钟数超出安全数值范围,不能记录。')
5127
5167
  if (solved) score += Number(q.weight) || 0
5128
5168
  minutes += spent
5129
5169
  graded.push({
@@ -5135,6 +5175,7 @@ function apply(ctx) {
5135
5175
  // 老卷子可能没有(那批 pending 是在加这个字段之前开的),兜底 0。
5136
5176
  cfRating: Math.round(Number(q.cfRating) || 0),
5137
5177
  solved, minutes: spent,
5178
+ ...(typeof r.independent === 'boolean' ? { independent: r.independent } : {}),
5138
5179
  })
5139
5180
  }
5140
5181
 
@@ -5144,9 +5185,13 @@ function apply(ctx) {
5144
5185
  // 超时和分数不够是**两种病**:前者是不熟,后者是不会。
5145
5186
  const outcome = score < passScore ? 'failed'
5146
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'
5147
5192
 
5148
5193
  const node = String(p.node ?? '')
5149
- const status = outcome === 'passed' ? 'verified'
5194
+ const status = verified ? 'verified'
5150
5195
  : nodeStatus(prog, node) === 'none' ? 'studying' : nodeStatus(prog, node)
5151
5196
 
5152
5197
  // 「验证于哪个段位」= 他拿下的题里 cfRating 最高的那个。
@@ -5161,7 +5206,7 @@ function apply(ctx) {
5161
5206
  const solvedRatings = graded
5162
5207
  .filter((g) => g.solved && g.source !== 'web')
5163
5208
  .map((g) => g.cfRating).filter((r) => r > 0)
5164
- const verifiedAt = outcome === 'passed' && solvedRatings.length
5209
+ const verifiedAt = verified && solvedRatings.length
5165
5210
  ? Math.max(...solvedRatings) : 0
5166
5211
 
5167
5212
  // 自述题计数。
@@ -5180,10 +5225,10 @@ function apply(ctx) {
5180
5225
  date: localStamp(new Date()).slice(0, 10),
5181
5226
  outcome, score, maxScore, passScore,
5182
5227
  minutes, limit, verifiedAt, selfReported, problems: graded,
5228
+ ...(independence === 'unknown' ? {} : { independent: independence === 'independent' }),
5183
5229
  })
5184
5230
  nodeRec.checks = checks
5185
- // 过 → verified(这是那一级的定义)。不过**不降级**已有的 learned/verified,
5186
- // 但若本来就没学过,标成「在学」—— 他显然正在这上面花时间。
5231
+ // 未达到独立验证条件不降级旧状态;未学节点只记录正在学习。
5187
5232
  const prevStatus = nodeStatus(prog, node)
5188
5233
  nodeRec.status = status
5189
5234
  // ⚠️ 修过:这条路**以前没写 `at`**。
@@ -5202,7 +5247,7 @@ function apply(ctx) {
5202
5247
  return {
5203
5248
  ok: true, reject: '', node, nodeName: byId.get(node)?.name ?? node,
5204
5249
  domain: byId.get(node)?.domain ?? '', outcome, score, maxScore, passScore,
5205
- minutes, limit, status, verifiedAt, selfReported,
5250
+ minutes, limit, status, verified, independence, verifiedAt, selfReported,
5206
5251
  problems: graded, checks: checks.length,
5207
5252
  }
5208
5253
  },
@@ -5354,6 +5399,7 @@ function apply(ctx) {
5354
5399
  type: 'string',
5355
5400
  description: '撤哪个节点的动作。不填 = 手上只有一条时撤那条;有多条会被拒绝并列出来。',
5356
5401
  },
5402
+ competency: { type: 'string', description: '撤哪个比赛能力动作,与 node 二选一;都不填时手上只有一条可直接撤。' },
5357
5403
  reason: { type: 'string', description: '为什么撤(一句话,回显用)' },
5358
5404
  },
5359
5405
  output: {
@@ -5368,6 +5414,7 @@ function apply(ctx) {
5368
5414
  at: { type: 'string', required: true },
5369
5415
  deliverable: { type: 'string', required: true },
5370
5416
  reason: { type: 'string', required: true },
5417
+ competency: { type: 'string' },
5371
5418
  remaining: { type: 'integer', required: true },
5372
5419
  },
5373
5420
  },
@@ -5393,6 +5440,13 @@ function apply(ctx) {
5393
5440
  const prog = loadProgress()
5394
5441
  const pend = { ...(prog.pendingActions ?? {}) }
5395
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
+ }
5396
5450
  if (!node) {
5397
5451
  const keys = Object.keys(pend)
5398
5452
  if (!keys.length) {
@@ -5409,6 +5463,7 @@ function apply(ctx) {
5409
5463
  '可能已经落地(coach_log 销的),或者被撤过了。')
5410
5464
  }
5411
5465
  const a = pend[node]
5466
+ competency = String(a?.competency ?? '')
5412
5467
  delete pend[node]
5413
5468
  prog.pendingActions = pend
5414
5469
  try {
@@ -5417,7 +5472,9 @@ function apply(ctx) {
5417
5472
  return deny(`写盘失败:${err.message} —— 动作还押着,别当它撤了。`)
5418
5473
  }
5419
5474
  return {
5420
- ok: true, reject: '', node, nodeName: byId.get(node)?.name ?? node,
5475
+ ok: true, reject: '', node: competency ? '' : node,
5476
+ nodeName: trainingIdentity(competency ? '' : node, competency).name ?? node,
5477
+ ...(competency ? { competency } : {}),
5421
5478
  at: String(a?.at ?? ''), deliverable: String(a?.deliverable ?? ''),
5422
5479
  reason, remaining: Object.keys(pend).length,
5423
5480
  }
@@ -5497,6 +5554,7 @@ function apply(ctx) {
5497
5554
  kindName: { type: 'string', required: true },
5498
5555
  node: { type: 'string', required: true },
5499
5556
  nodeName: { type: 'string', required: true },
5557
+ competency: { type: 'string' },
5500
5558
  },
5501
5559
  },
5502
5560
  },
@@ -5511,6 +5569,7 @@ function apply(ctx) {
5511
5569
  actualMin: { type: 'integer', required: true },
5512
5570
  solved: { type: 'boolean', required: true },
5513
5571
  independent: { type: 'boolean', required: true },
5572
+ competency: { type: 'string' },
5514
5573
  // 可选(coach_log 带 note 时才写)。不写 required —— 可选键
5515
5574
  // 在 dsh 那套里就是"不写 required",写 required:false 会抛。
5516
5575
  note: { type: 'string' },
@@ -5654,8 +5713,8 @@ function apply(ctx) {
5654
5713
  '**范围只认他说的那一天**:他说「排今天」就只排今天,说「排周三」就只排周三,' +
5655
5714
  '不要顺手把明天后天一起铺了 —— 那是替他做决定,而且是没问过他的决定。\n' +
5656
5715
  '三种块:cycle = 题目循环 60min(40 自己做 + 10 解决遗留 + 10 重写)、' +
5657
- 'review = 复习一题 25min、exam = 章节检测 90min。**时长不能改** —— ' +
5658
- '40 分钟那个止损点全靠它守着。\n' +
5716
+ 'review = 复习一题 25min、exam = 章节检测 90min;diagnostic 默认 15min、vp 默认 300min。' +
5717
+ '这些是默认值;按任务需要用 minutes 明确时长。\n' +
5659
5718
  '工具只拦**物理上做不到的**:压着他标的忙事 / 块叠在一起 / 知识点不在图上 / ' +
5660
5719
  '日子在窗口外。**排多排少是你的判断,工具不给容量上限**,排太满的后果' +
5661
5720
  '让表里的' +
@@ -5666,13 +5725,15 @@ function apply(ctx) {
5666
5725
  date: { type: 'string', required: true, description: '哪天,YYYY-MM-DD,得在今天 ~ 今天+6 之间' },
5667
5726
  blocks: {
5668
5727
  type: 'array', required: true,
5669
- description: '要排的块,按时间先后给。结束时间由 kind 决定,不用你算',
5728
+ description: '要排的块,按时间先后给。结束时间由 minutes 或 kind 默认时长决定',
5670
5729
  items: {
5671
5730
  type: 'object', additionalProperties: false,
5672
5731
  properties: {
5673
5732
  from: { type: 'string', required: true, description: '开始时间 "HH:MM",要对齐 5 分钟' },
5674
- kind: { type: 'string', required: true, enum: ['cycle', 'review', 'exam'] },
5675
- node: { type: 'string', required: true, description: '知识点 id,必须在地图里' },
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 的默认时长。' },
5676
5737
  },
5677
5738
  },
5678
5739
  },
@@ -5700,6 +5761,7 @@ function apply(ctx) {
5700
5761
  node: { type: 'string', required: true },
5701
5762
  nodeName: { type: 'string', required: true },
5702
5763
  domain: { type: 'string', required: true },
5764
+ competency: { type: 'string' },
5703
5765
  },
5704
5766
  },
5705
5767
  },
@@ -5729,7 +5791,7 @@ function apply(ctx) {
5729
5791
  (v.replaced ? `(覆盖掉原来那 ${v.replaced} 个块)` : '')]
5730
5792
  for (const b of v.planned) {
5731
5793
  L.push(` ★ ${b.from}–${b.to} ${b.kindName} ${b.nodeName}` +
5732
- (b.kind === 'cycle' ? ' (40 自己做 → 10 解决遗留 → 10 重写)' : ''))
5794
+ (b.kind === 'cycle' && b.minutes === CYCLE_TOTAL_MIN ? ' (40 自己做 → 10 解决遗留 → 10 重写)' : ''))
5733
5795
  }
5734
5796
  // 回一条**完整时间轴**,而不是「还剩多少分钟」。
5735
5797
  // 排完他得能一眼看出这块夹在哪两件事之间 —— 那个位置才决定来不来得及。
@@ -5751,6 +5813,8 @@ function apply(ctx) {
5751
5813
  async execute(args) {
5752
5814
  const blocks = (Array.isArray(args?.blocks) ? args.blocks : []).map((b) => ({
5753
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 } : {}),
5754
5818
  }))
5755
5819
  const r = planDay(args?.date, blocks, { replace: args?.replace === true })
5756
5820
 
@@ -5824,6 +5888,7 @@ function apply(ctx) {
5824
5888
  kindName: { type: 'string', required: true },
5825
5889
  node: { type: 'string', required: true },
5826
5890
  nodeName: { type: 'string', required: true },
5891
+ competency: { type: 'string' },
5827
5892
  },
5828
5893
  },
5829
5894
  },
@@ -5838,6 +5903,7 @@ function apply(ctx) {
5838
5903
  kindName: { type: 'string', required: true },
5839
5904
  node: { type: 'string', required: true },
5840
5905
  nodeName: { type: 'string', required: true },
5906
+ competency: { type: 'string' },
5841
5907
  },
5842
5908
  },
5843
5909
  },
@@ -5908,7 +5974,7 @@ function apply(ctx) {
5908
5974
  ctx.tools.register(defineTool({
5909
5975
  name: 'coach_log',
5910
5976
  description:
5911
- '记一条**实际用时**:他做完一个块之后,报「做了多久、是不是独立做出来的」。\n' +
5977
+ '记一条节点练习或比赛能力动作的**实际用时**:他做完一个块之后,报「做了多久、是不是独立做出来的」。\n' +
5912
5978
  '**每次他报告完成情况就调一次**(不管是做出来了还是没做出来、超时了还是提前),' +
5913
5979
  '这是系统学会"他真实速度是多少"的唯一来源 —— 不记,排程永远只能靠猜。\n' +
5914
5980
  '计划时长不用你填:那天表上排了多久,工具自己从 SCHEDULE.yaml 里算。\n' +
@@ -5917,7 +5983,8 @@ function apply(ctx) {
5917
5983
  '**同一天同一个知识点做了两道题,就调两次、各带自己的 problemId** —— ' +
5918
5984
  '别合成一条(合成之后「哪道独立做出来的」就没了,而那正是开不开检测卷的依据)。',
5919
5985
  parameters: {
5920
- node: { type: 'string', required: true, description: '知识点 id,必须在地图里' },
5986
+ node: { type: 'string', description: '知识点 id;与 competency 二选一。' },
5987
+ competency: { type: 'string', description: '已有比赛能力 id;记录能力诊断或完整 VP,与 node 二选一。' },
5921
5988
  // (需求 #2):题号。**可选** —— 不填就是老行为(一天一节点一条)。
5922
5989
  problemId: {
5923
5990
  type: 'string',
@@ -5949,6 +6016,7 @@ function apply(ctx) {
5949
6016
  date: { type: 'string', required: true },
5950
6017
  node: { type: 'string', required: true },
5951
6018
  nodeName: { type: 'string', required: true },
6019
+ competency: { type: 'string' },
5952
6020
  plannedMin: { type: 'integer', required: true },
5953
6021
  actualMin: { type: 'integer', required: true },
5954
6022
  deviation: { type: 'integer', required: true },
@@ -5992,7 +6060,7 @@ function apply(ctx) {
5992
6060
  ? `\n (这天这个点${v.problemId ? '这道题' : ''}原来记过一条,覆盖了)`
5993
6061
  : '') +
5994
6062
  (v.recorded
5995
- ? `\n 台账:这个知识点第 ${v.passCount} 次做完记录。`
6063
+ ? v.competency ? '\n 台账:比赛能力动作已记入实际记录;不证明长期掌握。' : `\n 台账:这个知识点第 ${v.passCount} 次做完记录。`
5996
6064
  : '\n ⚠️ 掌握证据那条没写上(磁盘/权限问题)—— 时间记录是好的。') +
5997
6065
  (v.actionCleared
5998
6066
  ? '\n 📌 这个节点上押着的动作**销了** —— 这条记录就是它的落地。'
@@ -6009,6 +6077,8 @@ function apply(ctx) {
6009
6077
  const { byId } = loadMap()
6010
6078
  const node = String(args?.node ?? '').trim()
6011
6079
  const d = String(args?.date ?? '').trim() || ymdToday()
6080
+ const competency = String(args?.competency ?? '').trim()
6081
+ const identity = trainingIdentity(node, competency)
6012
6082
  const actualMin = Math.round(Number(args?.actualMin))
6013
6083
  const solved = args?.solved === true
6014
6084
  const independent = args?.independent === true
@@ -6025,7 +6095,8 @@ function apply(ctx) {
6025
6095
  if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) {
6026
6096
  return deny(`日期要 YYYY-MM-DD 格式,收到「${d}」。`)
6027
6097
  }
6028
- if (!byId.has(node)) {
6098
+ if (!identity.ok) return deny(identity.reject)
6099
+ if (!competency && !byId.has(node)) {
6029
6100
  const near = suggest(node, [...byId.values()])
6030
6101
  return deny(`地图里没有「${node}」这个知识点,别自己造。` +
6031
6102
  (near.length ? `相近的有:${near.join(' / ')}` : '先调 coach_status 看他在哪。'))
@@ -6052,7 +6123,7 @@ function apply(ctx) {
6052
6123
  return a === null || z === null ? 0 : Math.max(0, z - a)
6053
6124
  }
6054
6125
  const plannedMin = (Array.isArray(day.planned) ? day.planned : [])
6055
- .filter((b) => String(b?.node ?? '') === node)
6126
+ .filter((b) => competency ? b?.competency === competency : String(b?.node ?? '') === node && !b?.competency)
6056
6127
  .reduce((t, b) => t + blockMin(b), 0)
6057
6128
 
6058
6129
  // 同一天同一个点:**没给题号时**只留一条 —— 这张表没有开始时间,
@@ -6061,15 +6132,20 @@ function apply(ctx) {
6061
6132
  // (09-21 的 ST 表:P2880 独立 AC、1548B 带提示 AC),压成一条等于
6062
6133
  // 把"他独立做出过这个点的题"从台账里抹掉。
6063
6134
  const idx = list.findIndex((r) =>
6064
- String(r?.node ?? '') === node && String(r?.problemId ?? '') === problemId)
6135
+ (competency ? r?.competency === competency : String(r?.node ?? '') === node && !r?.competency) && String(r?.problemId ?? '') === problemId)
6065
6136
  // 需求 #8:`plannedMin` 不再写进这条记录(见上面那段注释)
6066
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
+ }
6067
6143
  if (problemId) entry.problemId = problemId
6068
6144
  if (note) entry.note = note
6069
6145
  const replaced = idx >= 0
6070
6146
  if (replaced) list[idx] = entry
6071
6147
  else list.push(entry)
6072
- 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
6073
6149
 
6074
6150
  const w = setScheduleField(d, 'actual', list)
6075
6151
  if (!w.ok) return deny(w.reject)
@@ -6090,35 +6166,39 @@ function apply(ctx) {
6090
6166
  let passCount = 0
6091
6167
  try {
6092
6168
  const prog = loadProgress()
6093
- const rec = { ...(prog.nodes[node] ?? {}) }
6094
- const passes = Array.isArray(rec.passes) ? rec.passes.slice() : []
6095
- const e = { date: d, solved, independent, minutes: actualMin }
6096
- if (problemId) e.problemId = problemId
6097
- if (note) e.note = note
6098
- // 去重键和 SCHEDULE 那边**必须一致**(日期 + 节点 + 题号):
6099
- // 两边口径分家的话,`coach_status` 的条目数和 `coach_next` 的计划
6100
- // 会对不上同一件事 —— 这个项目反复在治的就是这个病。
6101
- const pi = passes.findIndex((p) => String(p?.date ?? '') === d &&
6102
- String(p?.problemId ?? '') === problemId)
6103
- if (pi >= 0) passes[pi] = e
6104
- else passes.push(e)
6105
- rec.passes = passes
6106
- prog.nodes[node] = rec
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
+ }
6107
6186
  // 押着的动作**落地即销**(需求 #6):这个节点上布置过、他这会儿记了
6108
6187
  // 一条做完记录 = 就是做了。撤销是 coach_unassign 的活(那条路不写 passes)——
6109
6188
  // 两个出口分开,是为了不让"做完了"和"不做了"在账上长成一个样。
6110
- if (prog.pendingActions && Object.prototype.hasOwnProperty.call(prog.pendingActions, node)) {
6111
- delete prog.pendingActions[node]
6189
+ if (prog.pendingActions && Object.prototype.hasOwnProperty.call(prog.pendingActions, identity.key)) {
6190
+ delete prog.pendingActions[identity.key]
6112
6191
  actionCleared = true
6113
6192
  }
6114
6193
  saveProgress(prog)
6115
6194
  recorded = true
6116
- passCount = passes.length
6117
- } catch { /* 落盘失败不影响时间那条已经写进去的记录 */ }
6195
+ if (competency) passCount = sameDayEntries
6196
+ } catch { actionCleared = false /* 日程已保存,进度未保存,动作仍在。 */ }
6118
6197
 
6119
6198
  return {
6120
6199
  ok: true, reject: '', date: d, node,
6121
- nodeName: byId.get(node)?.name ?? node,
6200
+ nodeName: identity.name,
6201
+ ...(competency ? { competency } : {}),
6122
6202
  plannedMin, actualMin, deviation: actualMin - plannedMin, solved, independent,
6123
6203
  replaced, problemId, sameDayEntries,
6124
6204
  totalLogged: list.length, recorded, actionCleared, passCount, updated: w.updated,