acmer-coach 2.0.0 → 2.2.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
@@ -23,7 +23,7 @@
23
23
  // · `file:` 依赖装出来的是**硬链接**,不是拷贝。原地写两边同变,但改名式
24
24
  // 写入(Edit 工具、VS Code 原子保存)会**断开链接**,断了两边分家,
25
25
  // 而 `dsh plugin add` 认不出分家、不重链也不报错 —— 所以改完一律跑 dev.sh。
26
- import { readFileSync, writeFileSync, renameSync, statSync } from 'node:fs'
26
+ import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs'
27
27
  import { join } from 'node:path'
28
28
  import { homedir } from 'node:os'
29
29
  import { parse, stringify } from 'yaml'
@@ -31,11 +31,12 @@ 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 { createVpClient } from './lib/vp-client.js'
34
35
 
35
36
  const name = 'acmer-coach'
36
37
  const inject = ['tools']
37
38
 
38
- const VERSION = '2.0.0'
39
+ const VERSION = '2.2.0'
39
40
 
40
41
  // 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
41
42
  // 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
@@ -71,11 +72,30 @@ const TEACH_FLOOR_MIN = 10
71
72
  const DATA_DIR_EXPLICIT = Boolean(process.env.COACH_DATA_DIR)
72
73
  const DATA_DIR = process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge')
73
74
  const MAP_PATH = () => join(DATA_DIR, 'MAP.yaml')
74
- const curriculum = createCurriculumStore({ dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule })
75
- const strategy = createStrategyStore({
76
- dataDir: DATA_DIR,
77
- competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
78
- })
75
+ let ACTIVE_DATA_DIR = DATA_DIR
76
+ let strategy
77
+ let curriculum
78
+ function createStores(dataDir) {
79
+ strategy = createStrategyStore({
80
+ dataDir, competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
81
+ })
82
+ curriculum = createCurriculumStore({
83
+ dataDir, loadMap, loadProgress, loadSchedule,
84
+ loadStrategyEvidence: () => strategy.evidence().evidence,
85
+ })
86
+ }
87
+ createStores(ACTIVE_DATA_DIR)
88
+
89
+ function switchCoachWorkspace(accountId = '') {
90
+ ACTIVE_DATA_DIR = accountId ? join(DATA_DIR, 'vp-accounts', String(accountId)) : DATA_DIR
91
+ mkdirSync(ACTIVE_DATA_DIR, { recursive: true })
92
+ createStores(ACTIVE_DATA_DIR)
93
+ return ACTIVE_DATA_DIR
94
+ }
95
+
96
+ const vpClient = createVpClient({ dataDir: DATA_DIR })
97
+ const savedVp = vpClient.localStatus()
98
+ if (savedVp.connected) switchCoachWorkspace(savedVp.activeAccountId)
79
99
 
80
100
  // 探针要的文件,各自代表一条后面的依赖:
81
101
  // MAP.yaml 地图(coach_next 读它算候选)
@@ -167,7 +187,7 @@ function loadMap() {
167
187
  //
168
188
  // 游标显式存,不靠"已验证的最大深度"推 —— 推出来的东西没法手改,
169
189
  // 而且他想复习某节时游标要能停在那儿。
170
- const PROGRESS_PATH = () => join(DATA_DIR, 'PROGRESS.yaml')
190
+ const PROGRESS_PATH = () => join(ACTIVE_DATA_DIR, 'PROGRESS.yaml')
171
191
 
172
192
  function emptyProgress() {
173
193
  return { version: 1, cursor: '', updated: '', nodes: {}, pending: null }
@@ -256,6 +276,16 @@ const PROGRESS_HEADER = `
256
276
  // 原子写:先写 .tmp 再 rename。半路崩了也不会把真相源撕成半个文件。
257
277
  // ⚠️ 千万不要在解析失败时静默返回空进度再写回 —— 那是**拿空数据覆盖真相源**。
258
278
  // loadProgress 遇到非 ENOENT 的错一律抛,就是要让这种情况炸出来。
279
+ function replaceFile(tmp, destination) {
280
+ try { renameSync(tmp, destination) } catch (err) {
281
+ if (!['EPERM', 'EEXIST'].includes(err.code)) throw err
282
+ // Windows sandbox may refuse rename over an existing file; replace from the complete temp copy.
283
+ try { unlinkSync(destination) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
284
+ copyFileSync(tmp, destination)
285
+ unlinkSync(tmp)
286
+ }
287
+ }
288
+
259
289
  function saveProgress(p) {
260
290
  p.updated = localStamp(new Date()).slice(0, 10)
261
291
  // 显式列出要落盘的字段,而不是 `stringify(p)` 一把梭 ——
@@ -276,7 +306,7 @@ function saveProgress(p) {
276
306
  }
277
307
  const tmp = `${PROGRESS_PATH()}.tmp`
278
308
  writeFileSync(tmp, PROGRESS_HEADER + stringify(out, { lineWidth: 0 }), 'utf8')
279
- renameSync(tmp, PROGRESS_PATH())
309
+ replaceFile(tmp, PROGRESS_PATH())
280
310
  return p
281
311
  }
282
312
 
@@ -296,7 +326,7 @@ function saveProgress(p) {
296
326
  //
297
327
  // `actual` 是**校准真实做题速度**的唯一来源 —— 计划 60 分钟实际 95 分钟,
298
328
  // 攒几周才知道他的速度到底是多少。这条现在一条数据都没有,所以排程只能保守。
299
- const SCHEDULE_PATH = () => join(DATA_DIR, 'SCHEDULE.yaml')
329
+ const SCHEDULE_PATH = () => join(ACTIVE_DATA_DIR, 'SCHEDULE.yaml')
300
330
 
301
331
  function emptySchedule() {
302
332
  return { version: 1, updated: '', days: {} }
@@ -370,7 +400,7 @@ function saveSchedule(s) {
370
400
  // days 的 key 本身就是日期,也裸着 —— PyYAML 会读成 Date,按字符串索引就 KeyError
371
401
  body = body.replace(/^(\s*)(\d{4}-\d{2}-\d{2}):(\s*)$/gm, '$1"$2":')
372
402
  writeFileSync(tmp, SCHEDULE_HEADER + body, 'utf8')
373
- renameSync(tmp, SCHEDULE_PATH())
403
+ replaceFile(tmp, SCHEDULE_PATH())
374
404
  return s
375
405
  }
376
406
 
@@ -1397,6 +1427,7 @@ const reachableOf = (entry, rating) =>
1397
1427
  !entry || !rating || Number(entry) <= rating + REACH_BUFFER
1398
1428
 
1399
1429
  const SKILLTREE_HTML = () => join(DATA_DIR, 'skilltree.html')
1430
+ const VP_HTML = () => join(import.meta.dirname, 'assets', 'knowledge', 'vp.html')
1400
1431
 
1401
1432
  // ── /coach 路由:把技能树挂在 dsh 自己的 web 服务上 ──────────────────
1402
1433
  //
@@ -1479,6 +1510,38 @@ function registerPages(ctx) {
1479
1510
  return
1480
1511
  }
1481
1512
 
1513
+ if (pathname === '/coach/vp.html') {
1514
+ try { return send(res, 200, 'text/html; charset=utf-8', readFileSync(VP_HTML())) }
1515
+ catch (err) { return send(res, 404, 'text/plain; charset=utf-8', `VP 页面缺失(${err?.code ?? err?.message})`) }
1516
+ }
1517
+
1518
+ if (pathname === '/coach/api/vp/status' && req.method === 'GET') {
1519
+ return sendJson(res, 200, { ok: true, ...vpClient.localStatus() })
1520
+ }
1521
+
1522
+ if (pathname === '/coach/api/vp/replay' && req.method === 'GET') {
1523
+ const id = new URL(req.url ?? '/', 'http://dsh.internal').searchParams.get('contestId')
1524
+ if (!id) return sendJson(res, 400, { ok: false, reject: '缺少 contestId' })
1525
+ try { return sendJson(res, 200, { ok: true, ...(await vpClient.replay({ contestId: id })) }) }
1526
+ catch (err) { return sendJson(res, 400, { ok: false, reject: String(err?.message || err) }) }
1527
+ }
1528
+
1529
+ if (pathname === '/coach/api/vp/connect' && req.method === 'POST') {
1530
+ let body = ''
1531
+ for await (const chunk of req) { body += chunk; if (body.length > 100_000) { req.destroy(); return } }
1532
+ let payload
1533
+ try { payload = JSON.parse(body || '{}') } catch { return sendJson(res, 400, { ok: false, reject: '请求体不是 JSON' }) }
1534
+ try {
1535
+ let result
1536
+ if (payload.op === 'start') result = await vpClient.start({ baseUrl: payload.baseUrl })
1537
+ else if (payload.op === 'finish') { result = await vpClient.finish({ code: payload.code, baseUrl: payload.baseUrl }); switchCoachWorkspace(result.activeAccountId) }
1538
+ else if (payload.op === 'disconnect') { result = vpClient.disconnect(); switchCoachWorkspace('') }
1539
+ else if (payload.op === 'sync') result = await vpClient.sync({})
1540
+ else return sendJson(res, 400, { ok: false, reject: 'op 只能是 start/finish/disconnect/sync' })
1541
+ return sendJson(res, 200, { ok: true, ...result })
1542
+ } catch (err) { return sendJson(res, 400, { ok: false, reject: String(err?.message || err) }) }
1543
+ }
1544
+
1482
1545
  if (pathname === '/coach/skilltree.html') {
1483
1546
  let html
1484
1547
  try {
@@ -1795,6 +1858,44 @@ function wikiPage(src) {
1795
1858
  return { ok: true, text: text.slice(0, 12000), truncated: text.length > 12000, chars: text.length }
1796
1859
  }
1797
1860
 
1861
+ const emptyVpAccount = () => ({ id: 0, handle: '', display_name: '' })
1862
+ const vpAccount = (value) => value && Number(value.id) > 0
1863
+ ? { id: Number(value.id), handle: String(value.handle || ''), display_name: String(value.display_name || value.handle || '') }
1864
+ : emptyVpAccount()
1865
+
1866
+ function vpEventsFromSync(payload, account) {
1867
+ const submissions = Array.isArray(payload?.submissions) ? payload.submissions : []
1868
+ const contests = Array.isArray(payload?.contests) ? payload.contests : []
1869
+ const byContest = new Map()
1870
+ for (const contest of contests) byContest.set(Number(contest.id), contest)
1871
+ const groups = new Map()
1872
+ for (const submission of submissions) {
1873
+ const contestId = Number(submission.contest_id)
1874
+ const problemId = String(submission.problem_id ?? '').trim()
1875
+ if (!contestId || !problemId) continue
1876
+ if (!groups.has(contestId)) groups.set(contestId, new Map())
1877
+ const problems = groups.get(contestId)
1878
+ if (!problems.has(problemId)) problems.set(problemId, [])
1879
+ problems.get(problemId).push(submission)
1880
+ }
1881
+ return [...groups.entries()].map(([contestId, problems]) => {
1882
+ const contest = byContest.get(contestId) || {}
1883
+ const start = String(contest.start_time || [...problems.values()][0]?.[0]?.submitted_at || '')
1884
+ const normalized = [...problems.entries()].map(([problemId, attempts]) => {
1885
+ const solved = attempts.find((item) => ['AC', 'OK', 'ACCEPTED', 'SOLVED'].includes(String(item.verdict || '').toUpperCase()))
1886
+ const at = solved?.submitted_at || attempts.at(-1)?.submitted_at
1887
+ const minutes = start && at ? Math.max(0, Math.round((Date.parse(at) - Date.parse(start)) / 60000)) : 0
1888
+ return { problemId, status: solved ? 'AC' : String(attempts.at(-1)?.verdict || 'UNKNOWN').toUpperCase(), attempts: attempts.length, solveMinutes: minutes, competencies: [] }
1889
+ })
1890
+ return {
1891
+ eventId: `vp:${account.id}:${contestId}`,
1892
+ contest: String(contest.title || contestId), contestId: String(contestId), date: start.slice(0, 10),
1893
+ durationMinutes: Number(contest.duration_min || 0), virtual: true, teamMode: 'team', problems: normalized,
1894
+ note: '由 VP 只读同步生成;具体能力标签在复盘时补充。',
1895
+ }
1896
+ }).filter((event) => /^\d{4}-\d{2}-\d{2}$/.test(event.date) && event.problems.length)
1897
+ }
1898
+
1798
1899
  function apply(ctx) {
1799
1900
  // ── 规则层:进 system prompt ──────────────────────────────────────
1800
1901
  //
@@ -1825,6 +1926,93 @@ function apply(ctx) {
1825
1926
  // 课程图回答“有哪些知识点”,策略层回答“为什么现在学这个”。两者分开,
1826
1927
  // 避免把 rating 或游标误当成区域赛目标。
1827
1928
  const strategyText = (v) => [{ type: 'text', text: v.ok ? v.text : `❌ ${v.reject}` }]
1929
+ ctx.tools.register(defineTool({
1930
+ name: 'coach_vp_connect',
1931
+ description: '连接 VP 账号。start 返回登录授权页,finish 交换一次性授权码;账号数据按 VP user id 分目录,disconnect 只删本地 token,不删缓存。',
1932
+ parameters: {
1933
+ action: { type: 'string', required: true, enum: ['start', 'finish', 'status', 'disconnect'] },
1934
+ baseUrl: { type: 'string', description: 'VP 地址,默认 https://xiaojing.run' },
1935
+ code: { type: 'string', description: 'VP 授权页显示的一次性 code,action=finish 时填写' },
1936
+ },
1937
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1938
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, action: { type: 'string', required: true },
1939
+ connected: { type: 'boolean', required: true }, activeAccountId: { type: 'integer', required: true },
1940
+ account: { type: 'object', required: true, additionalProperties: false, properties: { id: { type: 'integer', required: true }, handle: { type: 'string', required: true }, display_name: { type: 'string', required: true } } }, requestId: { type: 'string', required: true },
1941
+ authorizeUrl: { type: 'string', required: true }, expiresAt: { type: 'string', required: true }, text: { type: 'string', required: true },
1942
+ } }, render: (_a, v) => strategyText(v) },
1943
+ execute: async (args) => {
1944
+ const action = String(args?.action || 'status')
1945
+ const base = args?.baseUrl || 'https://xiaojing.run'
1946
+ try {
1947
+ let result
1948
+ if (action === 'start') result = await vpClient.start({ baseUrl: base })
1949
+ else if (action === 'finish') {
1950
+ result = await vpClient.finish({ code: args?.code, baseUrl: base })
1951
+ switchCoachWorkspace(result.activeAccountId)
1952
+ } else if (action === 'disconnect') {
1953
+ result = vpClient.disconnect()
1954
+ switchCoachWorkspace('')
1955
+ } else result = await vpClient.status()
1956
+ const account = vpAccount(result.account)
1957
+ return { ok: true, reject: '', action, connected: Boolean(result.connected ?? action === 'finish'), activeAccountId: Number(result.activeAccountId || account.id || 0), account,
1958
+ requestId: String(result.requestId || ''), authorizeUrl: String(result.authorizeUrl || ''), expiresAt: String(result.expiresAt || ''),
1959
+ text: action === 'start' ? `打开授权页登录 VP:${result.authorizeUrl}\n授权后把页面显示的 code 交给 coach_vp_connect finish。` :
1960
+ action === 'finish' ? `VP 已连接:${account.handle}(账号 ${account.id});本地工作区已切换。` :
1961
+ action === 'disconnect' ? 'VP 已断开;缓存保留,token 已删除。' : (result.connected ? `当前 VP:${account.handle}(账号 ${account.id})` : '当前没有已连接的 VP 账号。') }
1962
+ } catch (err) {
1963
+ return { ok: false, reject: String(err?.message || err), action, connected: false, activeAccountId: 0, account: emptyVpAccount(), requestId: '', authorizeUrl: '', expiresAt: '', text: '' }
1964
+ }
1965
+ },
1966
+ }))
1967
+
1968
+ ctx.tools.register(defineTool({
1969
+ name: 'coach_vp_sync',
1970
+ description: '从当前 VP 账号增量同步比赛、提交、事件和个人时间线。只读;游标和缓存都放在当前账号目录,切换账号不会读到另一人的数据。同步后自动生成带账号前缀的 VP 训练证据。',
1971
+ parameters: {
1972
+ limit: { type: 'integer', description: '单次最多同步多少条提交,默认 200' },
1973
+ includeProfile: { type: 'boolean', description: '是否同步个人 rating/比赛历史,默认 true' },
1974
+ },
1975
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1976
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, account: { type: 'object', required: true, additionalProperties: false, properties: { id: { type: 'integer', required: true }, handle: { type: 'string', required: true }, display_name: { type: 'string', required: true } } },
1977
+ imported: { type: 'integer', required: true }, contests: { type: 'array', required: true }, submissions: { type: 'array', required: true }, events: { type: 'array', required: true },
1978
+ nextCursor: { type: 'object', required: true, additionalProperties: true }, cachedPath: { type: 'string', required: true }, text: { type: 'string', required: true },
1979
+ } }, render: (_a, v) => strategyText(v) },
1980
+ execute: async (args) => {
1981
+ try {
1982
+ const payload = await vpClient.sync({ limit: args?.limit, includeProfile: args?.includeProfile !== false })
1983
+ const account = vpAccount(payload.account)
1984
+ switchCoachWorkspace(account.id)
1985
+ const importedEvents = vpEventsFromSync(payload, account)
1986
+ const imported = importedEvents.length ? strategy.vpImport(importedEvents) : { imported: 0 }
1987
+ return { ok: true, reject: '', account, imported: Number(imported.imported || 0), contests: payload.contests || [], submissions: payload.submissions || [], events: payload.events || [],
1988
+ nextCursor: payload.next_cursor || {}, cachedPath: String(payload.cachedPath || ''), text: `VP 同步完成:${account.handle};比赛 ${payload.contests?.length || 0} 场,提交 ${payload.submissions?.length || 0} 条,新增训练证据 ${imported.imported || 0} 场。` }
1989
+ } catch (err) {
1990
+ return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), imported: 0, contests: [], submissions: [], events: [], nextCursor: {}, cachedPath: '', text: '' }
1991
+ }
1992
+ },
1993
+ }))
1994
+
1995
+ ctx.tools.register(defineTool({
1996
+ name: 'coach_vp_replay',
1997
+ description: '读取当前 VP 账号指定比赛的细粒度回放。返回题目、队伍提交和秒级时间轴;代码不回传,页面可视化从这个数据绘制。',
1998
+ parameters: { contestId: { type: 'integer', required: true }, limit: { type: 'integer', description: '官方 runs 上限,默认 50000' } },
1999
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
2000
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, account: { type: 'object', required: true, additionalProperties: false, properties: { id: { type: 'integer', required: true }, handle: { type: 'string', required: true }, display_name: { type: 'string', required: true } } },
2001
+ contest: { type: 'object', required: true, additionalProperties: true }, problems: { type: 'array', required: true }, result: { type: 'object', required: true, additionalProperties: true },
2002
+ visualization: { type: 'object', required: true, additionalProperties: true }, cachedPath: { type: 'string', required: true }, text: { type: 'string', required: true },
2003
+ } }, render: (_a, v) => strategyText(v) },
2004
+ execute: async (args) => {
2005
+ try {
2006
+ const payload = await vpClient.replay({ contestId: args?.contestId, limit: args?.limit })
2007
+ const account = vpAccount(payload.account)
2008
+ return { ok: true, reject: '', account, contest: payload.contest || {}, problems: payload.problems || [], result: payload.result || {}, visualization: payload.visualization || {}, cachedPath: String(payload.cachedPath || ''),
2009
+ text: `回放已加载:${payload.contest?.title || args?.contestId};队伍事件 ${payload.visualization?.team_events?.length || 0} 个,官方 runs ${payload.visualization?.official_run_count || 0} 条。` }
2010
+ } catch (err) {
2011
+ return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), contest: {}, problems: [], result: {}, visualization: {}, cachedPath: '', text: '' }
2012
+ }
2013
+ },
2014
+ }))
2015
+
1828
2016
  ctx.tools.register(defineTool({
1829
2017
  name: 'coach_target',
1830
2018
  description: '设置或读取比赛目标。目标先于知识点:contest/date/result/teamMode/weeklyHours/priorities 都确定后,教练才有依据选训练重点。',
@@ -1865,11 +2053,11 @@ function apply(ctx) {
1865
2053
  },
1866
2054
  output: { schema: { type: 'object', additionalProperties: false, properties: {
1867
2055
  ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, imported: { type: 'integer', required: true },
1868
- total: { type: 'integer', required: true }, revision: { type: 'integer', required: true }, text: { type: 'string', required: true },
2056
+ total: { type: 'integer', required: true }, revision: { type: 'integer', required: true }, invalid: { type: 'array', required: true, items: { type: 'string' } }, text: { type: 'string', required: true },
1869
2057
  } }, render: (_a, v) => strategyText(v) },
1870
2058
  execute: (args) => {
1871
2059
  const v = strategy.vpImport(Array.isArray(args?.events) ? args.events : [])
1872
- return { ok: v.ok, reject: v.reject, imported: v.imported, total: v.total, revision: v.revision,
2060
+ return { ok: v.ok, reject: v.reject, imported: v.imported, total: v.total, revision: v.revision, invalid: v.invalid ?? [],
1873
2061
  text: v.ok ? `✓ VP 记录已保存:本次 ${v.imported} 场,累计 ${v.total} 场,revision ${v.revision}` : '' }
1874
2062
  },
1875
2063
  }))
@@ -1901,11 +2089,32 @@ function apply(ctx) {
1901
2089
  ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, contest: { type: 'string', required: true },
1902
2090
  deadline: { type: 'string', required: true }, focus: { type: 'array', required: true }, postponed: { type: 'array', required: true },
1903
2091
  evidenceEvents: { type: 'integer', required: true }, postmortems: { type: 'integer', required: true }, text: { type: 'string', required: true },
2092
+ course: { type: 'object', required: true, additionalProperties: false, properties: {
2093
+ exists: { type: 'boolean', required: true }, revision: { type: 'integer', required: true },
2094
+ phaseId: { type: 'string', required: true }, phaseTitle: { type: 'string', required: true },
2095
+ focusNodes: { type: 'array', required: true, items: { type: 'string' } }, pendingNodes: { type: 'array', required: true, items: { type: 'string' } },
2096
+ phaseCompetencies: { type: 'array', required: true, items: { type: 'string' } },
2097
+ focusExpired: { type: 'boolean', required: true }, missingFreshEvidence: { type: 'array', required: true, items: { type: 'string' } },
2098
+ } },
1904
2099
  } }, render: (_a, v) => strategyText(v) },
1905
2100
  execute: () => {
1906
2101
  const v = strategy.focus()
1907
- return { ...v, text: v.ok ? [`【当前训练重点】${v.contest} · 截止 ${v.deadline}`,
2102
+ let snapshot
2103
+ try { snapshot = curriculum.snapshot() } catch (err) {
2104
+ snapshot = { exists: false, revision: 0, phaseId: '', phaseTitle: '', focusNodes: [], pendingNodes: [], focusExpired: false, missingFreshEvidence: [] }
2105
+ v.ok = false
2106
+ v.reject = v.reject || `课程读取失败:${err.message}`
2107
+ }
2108
+ const course = {
2109
+ exists: Boolean(snapshot.exists), revision: Number(snapshot.revision || 0), phaseId: String(snapshot.phaseId || ''),
2110
+ phaseTitle: String(snapshot.phaseTitle || ''), focusNodes: snapshot.focusNodes ?? [], pendingNodes: snapshot.pendingNodes ?? [],
2111
+ phaseCompetencies: snapshot.phaseCompetencies ?? [],
2112
+ focusExpired: Boolean(snapshot.focusExpired), missingFreshEvidence: snapshot.missingFreshEvidence ?? [],
2113
+ }
2114
+ return { ...v, course, text: v.ok ? [`【当前训练重点】${v.contest} · 截止 ${v.deadline}`,
1908
2115
  ...v.focus.map((f, i) => `${i + 1}. ${f.title}(${f.id})\n 比赛价值:${f.contestUse}\n 为什么现在:${f.whyNow}\n 关联知识点:${f.nodes.join('、') || '需先定义'}\n 证据次数:${f.evidenceCount}`),
2116
+ course.exists ? `课程阶段:${course.phaseTitle}(${course.phaseId})\n课程主线:${course.focusNodes.join('、') || '能力证据驱动'}\n比赛能力:${course.phaseCompetencies.join('、') || '地图知识点'}` : '课程阶段:尚未建立,用 coach_curriculum assess/read/create 建立长期路线。',
2117
+ course.pendingNodes.length ? `课程未完成动作:${course.pendingNodes.join('、')}` : '',
1909
2118
  v.postponed.length ? `暂缓:${v.postponed.join('、')}` : '没有暂缓项。',
1910
2119
  ].join('\n') : '' }
1911
2120
  },
@@ -2681,7 +2890,9 @@ function apply(ctx) {
2681
2890
  '必须存在于地图、必须是当前游标的下一层、前置必须都满足。校验不过会被拒绝并说明原因。' +
2682
2891
  '它一次只收一个节点,所以**给不出候选列表** —— 这正是设计意图。' +
2683
2892
  '有长期课程时先读 coach_curriculum,带 curriculumRevision;主线必须属于当前阶段,' +
2684
- '补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。',
2893
+ '补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。' +
2894
+ '有比赛目标时,progress 动作还必须落在当前目标能力;跨重点训练只能走课程绑定,' +
2895
+ '补漏/复习必须说明 routeReason。',
2685
2896
  parameters: {
2686
2897
  cursor: {
2687
2898
  type: 'string',
@@ -2740,6 +2951,8 @@ function apply(ctx) {
2740
2951
  pendingRecorded: { type: 'boolean', required: true },
2741
2952
  // 这个节点上原来就押着一条(被这次覆盖)。
2742
2953
  pendingReplaced: { type: 'boolean', required: true },
2954
+ targetAligned: { type: 'boolean', required: true },
2955
+ competencies: { type: 'array', required: true, items: { type: 'string' } },
2743
2956
  blocks: {
2744
2957
  type: 'array',
2745
2958
  required: true,
@@ -2778,6 +2991,7 @@ function apply(ctx) {
2778
2991
  v.boxNote ? `⚠️ ${v.boxNote}` : '',
2779
2992
  `交付物 ${v.deliverable}`,
2780
2993
  v.why ? `为什么 ${v.why}` : '',
2994
+ v.competencies.length ? `目标对齐 ✓ ${v.competencies.join('、')}` : '',
2781
2995
  ``,
2782
2996
  table,
2783
2997
  v.teachMinutes
@@ -2818,7 +3032,7 @@ function apply(ctx) {
2818
3032
  // 需求 #6:拒绝时也把这两个字段摆出来 —— 白名单式的 schema
2819
3033
  // (additionalProperties: false + required:true)要求每个出口同形,
2820
3034
  // 缺一个 verify-shape 那一关就会红。
2821
- pendingRecorded: false, pendingReplaced: false,
3035
+ pendingRecorded: false, pendingReplaced: false, targetAligned: false, competencies: [],
2822
3036
  })
2823
3037
 
2824
3038
  // ① 游标认不认得。「没设」和「打错」分开提示,别让模型去猜。
@@ -2847,6 +3061,11 @@ function apply(ctx) {
2847
3061
  why = `课程「${route.binding.phaseTitle}」· ${route.binding.purpose}:${why}` +
2848
3062
  (route.binding.reason ? `;补充依据:${route.binding.reason}` : '')
2849
3063
  }
3064
+ const targetRoute = strategy.route(node, { purpose: args?.purpose, courseBound: Boolean(route.binding) })
3065
+ if (!targetRoute.ok) return deny(targetRoute.reject)
3066
+ if (targetRoute.competencyIds.length) {
3067
+ why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '当前重点对应的训练动作'}`
3068
+ }
2850
3069
 
2851
3070
  // ③ 未建课程时沿用直接后继;有课程时允许当前阶段跨分支,仍检查前置。
2852
3071
  //
@@ -2976,6 +3195,7 @@ function apply(ctx) {
2976
3195
  totalMinutes: net + teach + TAIL_MIN, boxNote,
2977
3196
  deliverable, why, mapStamp: stamp, selfTarget: node === cursor,
2978
3197
  pendingRecorded, pendingReplaced: replacedPending,
3198
+ targetAligned: targetRoute.targetAligned, competencies: targetRoute.competencyIds,
2979
3199
  blocks: [
2980
3200
  // 讲解段排在最前面,`kind: teach` —— 卷面上看得见,
2981
3201
  // 它才不会变成"讲完就忘了排"的那个动作
package/lib/client.js CHANGED
@@ -87,6 +87,7 @@ window.__ModuleLoader__.load({
87
87
  }
88
88
 
89
89
  const PAGE_URL = "/coach/skilltree.html";
90
+ const VP_PAGE_URL = "/coach/vp.html";
90
91
 
91
92
  // VP(CompetitionWhale)的品牌标:**拱**是露出水面的鲸背,**波浪**是水面,两笔。
92
93
  //
@@ -191,6 +192,37 @@ window.__ModuleLoader__.load({
191
192
  });
192
193
  }
193
194
 
195
+ function VpOverlay({ onClose }) {
196
+ React.useEffect(() => {
197
+ const onKey = (e) => { if (e.key === "Escape") onClose(); };
198
+ document.addEventListener("keydown", onKey);
199
+ return () => document.removeEventListener("keydown", onKey);
200
+ }, [onClose]);
201
+ return jsx("div", {
202
+ className: "dshcoach-mask",
203
+ onClick: (e) => { if (e.target === e.currentTarget) onClose(); },
204
+ children: jsxs("div", {
205
+ className: "dshcoach-panel",
206
+ children: [
207
+ jsxs("div", { className: "dshcoach-bar", children: [
208
+ jsxs("div", { className: "dshcoach-title", children: [jsx(WhaleMark, { size: 17 }), jsx("span", { children: "VP 回放" })] }),
209
+ jsx("button", { type: "button", className: "dshcoach-action", onClick: () => window.open(VP_PAGE_URL, "_blank", "noopener"), children: "新标签页打开" }),
210
+ jsx("button", { type: "button", className: "dshcoach-action", onClick: onClose, children: "✕ 关闭" }),
211
+ ] }),
212
+ jsx("iframe", { className: "dshcoach-frame", src: VP_PAGE_URL, title: "VP 回放" }),
213
+ ],
214
+ }),
215
+ });
216
+ }
217
+
218
+ function VpHeaderButton() {
219
+ const [open, setOpen] = React.useState(false);
220
+ return jsxs(React.Fragment, { children: [
221
+ jsxs("button", { type: "button", className: "dshcoach-btn", title: "连接 VP、同步比赛并查看秒级回放", onClick: () => setOpen(true), children: [jsx(WhaleMark, { size: 15 }), jsx("span", { children: "VP 回放" })] }),
222
+ open ? createPortal(jsx(VpOverlay, { onClose: () => setOpen(false) }), document.body) : null,
223
+ ] });
224
+ }
225
+
194
226
  // 服务名注入(不是模块名):等 slots 服务到位再注册。
195
227
  const inject = ["slots"];
196
228
 
@@ -202,6 +234,12 @@ window.__ModuleLoader__.load({
202
234
  order: 20,
203
235
  registrant: "acmer-coach",
204
236
  }, SkillTreeHeaderButton));
237
+ ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
238
+ name: "conversation.session.header.utilities",
239
+ id: "acmer-coach:vp",
240
+ order: 21,
241
+ registrant: "acmer-coach",
242
+ }, VpHeaderButton));
205
243
  }
206
244
 
207
245
  return { apply, inject };
@@ -4,7 +4,7 @@ const text = (value, field, phase) => {
4
4
  if (result.length < 10) throw new Error(`阶段 ${phase} 的 ${field} 需要至少 10 字的具体依据。`)
5
5
  return result
6
6
  }
7
- const coreKeys = ['id', 'title', 'outcome', 'nodes', 'exitCriteria', 'requiredVerified']
7
+ const coreKeys = ['id', 'title', 'outcome', 'nodes', 'competencies', 'exitCriteria', 'requiredVerified']
8
8
  export const corePhase = (phase) => Object.fromEntries(coreKeys.map((key) => [key, phase[key]]))
9
9
 
10
10
  export function prerequisitesOf(nodes, byId) {
@@ -37,6 +37,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
37
37
  const p = {
38
38
  id: clean(phase?.id), title: clean(phase?.title), outcome: clean(phase?.outcome),
39
39
  nodes: Array.isArray(phase?.nodes) ? phase.nodes.map(clean) : [],
40
+ competencies: Array.isArray(phase?.competencies) ? [...new Set(phase.competencies.map(clean).filter(Boolean))] : [],
40
41
  exitCriteria: clean(phase?.exitCriteria),
41
42
  requiredVerified: Array.isArray(phase?.requiredVerified) ? phase.requiredVerified.map(clean)
42
43
  : (kind === 'training' || kind === 'legacy' ? [...(phase?.nodes ?? [])].map(clean) : []),
@@ -44,7 +45,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
44
45
  if (!p.id || p.id.length > 80 || ids.has(p.id)) throw new Error('阶段 id 必须非空且唯一,最长 80 字符。')
45
46
  ids.add(p.id)
46
47
  if (!p.title || !p.outcome || !p.exitCriteria) throw new Error(`阶段 ${p.id} 缺标题、能力成果或验收条件。`)
47
- if (!p.nodes.length || p.nodes.length > 50 || new Set(p.nodes).size !== p.nodes.length) throw new Error(`阶段 ${p.id} 需要 1–50 个不重复的地图节点。`)
48
+ if ((!p.nodes.length && !p.competencies.length) || p.nodes.length > 50 || new Set(p.nodes).size !== p.nodes.length) throw new Error(`阶段 ${p.id} 至少需要一个地图节点或比赛能力,地图节点最多 50 个。`)
48
49
  for (const node of p.nodes) if (!byId.has(node)) throw new Error(`阶段 ${p.id} 的节点「${node}」不在地图里。`)
49
50
  if (new Set(p.requiredVerified).size !== p.requiredVerified.length || p.requiredVerified.some((node) => !p.nodes.includes(node))) throw new Error(`阶段 ${p.id} 的必验节点必须属于该阶段,且不能重复。`)
50
51
  const prerequisites = prerequisitesOf(p.nodes, byId)
@@ -85,7 +86,9 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
85
86
  for (const ref of phase.evidenceRefs) {
86
87
  const entry = records.get(ref)
87
88
  if (!entry) throw new Error(`阶段 ${phase.id} 的证据不存在或内容已变:${ref}。先 assess 重新取引用。`)
88
- if (!phase.nodes.includes(entry.node)) throw new Error(`阶段 ${phase.id} 引用了无关节点「${entry.node}」的记录。`)
89
+ const nodeMatch = entry.node && phase.nodes.includes(entry.node)
90
+ const competencyMatch = entry.competency && phase.competencies.includes(entry.competency)
91
+ if (!nodeMatch && !competencyMatch) throw new Error(`阶段 ${phase.id} 引用了无关的证据记录「${ref}」。`)
89
92
  }
90
93
  if (phase.kind !== 'training') continue
91
94
  const signal = phase.finding === 'gap' ? 'gap' : 'baseline'
@@ -94,6 +97,11 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
94
97
  return entry.node === node && entry.signal === signal
95
98
  }))
96
99
  if (uncovered.length) throw new Error(`阶段 ${phase.id} 的${signal === 'gap' ? '短板' : '进阶起点'}缺真实依据:${uncovered.join('、')}。先诊断;rating、状态或一段理由不能代替记录。`)
100
+ const missingCompetencies = phase.competencies.filter((competency) => !phase.evidenceRefs.some((ref) => {
101
+ const entry = records.get(ref)
102
+ return entry.competency === competency && entry.signal === signal
103
+ }))
104
+ if (missingCompetencies.length) throw new Error(`阶段 ${phase.id} 的${signal === 'gap' ? '短板' : '进阶起点'}缺真实能力依据:${missingCompetencies.join('、')}。先记录 VP 或复盘,不用 rating 代替。`)
97
105
  // 同阶段节点按依赖顺序列出;只有前阶段的必验训练节点能作为未来前置。
98
106
  for (const node of phase.nodes) {
99
107
  const missing = prerequisitesOf([node], byId).filter((dependency) => !available.has(dependency))
@@ -106,6 +114,9 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
106
114
  export function missingFreshEvidence(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = []) {
107
115
  const old = new Set(entryEvidenceRefs)
108
116
  const events = new Set(entryEvidenceEvents)
109
- return phase.nodes.filter((node) => !catalog.some((entry) => entry.node === node && !old.has(entry.id) &&
117
+ const missingNodes = phase.nodes.filter((node) => !catalog.some((entry) => entry.node === node && !old.has(entry.id) &&
118
+ !events.has(entry.eventId) && entry.date >= enteredDate && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
119
+ const missingCompetencies = phase.competencies.filter((competency) => !catalog.some((entry) => entry.competency === competency && !old.has(entry.id) &&
110
120
  !events.has(entry.eventId) && entry.date >= enteredDate && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
121
+ return [...missingNodes, ...missingCompetencies]
111
122
  }