acmer-coach 2.3.0 → 2.4.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/.github/workflows/publish.yml +2 -0
- package/README.md +35 -5
- package/assets/knowledge/COMPETENCIES.yaml +39 -4
- package/assets/knowledge/CONTEST_PROFILES.yaml +61 -0
- package/assets/knowledge/NODE_SCOPES.yaml +201 -0
- package/assets/rules/coach-rules.md +27 -3
- package/dev/catalog-check.mjs +74 -0
- package/dev/coaching-contract-check.mjs +93 -0
- package/dev/curriculum-check.mjs +193 -5
- package/dev/curriculum-evidence-check.mjs +9 -2
- package/dev/parity-check.mjs +9 -3
- package/dev/run-all.sh +5 -0
- package/dev/strategy-check.mjs +163 -4
- package/dev/vp-client-check.mjs +12 -2
- package/dev/vp-sync-check.mjs +122 -0
- package/dev/workspace-migration-check.mjs +106 -0
- package/dev.sh +8 -3
- package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md +48 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +17 -1
- package/docs//351/225/277/346/234/237/350/257/276/347/250/213/346/234/272/345/210/266.md +33 -6
- package/index.js +106 -34
- package/lib/curriculum-evidence.js +16 -2
- package/lib/curriculum-planning.js +75 -6
- package/lib/curriculum.js +44 -16
- package/lib/strategy.js +159 -51
- package/lib/vp-client.js +14 -2
- package/lib/workspace-migration.js +38 -0
- package/package.json +7 -1
- package/verify.mjs +1 -1
package/index.js
CHANGED
|
@@ -32,11 +32,12 @@ import { initDataDir, syncCodeforces, contactEvidence, importPool, importRecords
|
|
|
32
32
|
import { createCurriculumStore, curriculumTool } from './lib/curriculum.js'
|
|
33
33
|
import { createStrategyStore } from './lib/strategy.js'
|
|
34
34
|
import { createVpClient } from './lib/vp-client.js'
|
|
35
|
+
import { migratePersonalWorkspace as migrateWorkspacePersonalFiles } from './lib/workspace-migration.js'
|
|
35
36
|
|
|
36
37
|
const name = 'acmer-coach'
|
|
37
38
|
const inject = ['tools']
|
|
38
39
|
|
|
39
|
-
const VERSION = '2.
|
|
40
|
+
const VERSION = '2.4.0'
|
|
40
41
|
|
|
41
42
|
// 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
|
|
42
43
|
// 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
|
|
@@ -72,30 +73,38 @@ const TEACH_FLOOR_MIN = 10
|
|
|
72
73
|
const DATA_DIR_EXPLICIT = Boolean(process.env.COACH_DATA_DIR)
|
|
73
74
|
const DATA_DIR = process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge')
|
|
74
75
|
const MAP_PATH = () => join(DATA_DIR, 'MAP.yaml')
|
|
75
|
-
let
|
|
76
|
+
let ACTIVE_VP_DIR = DATA_DIR
|
|
76
77
|
let strategy
|
|
77
78
|
let curriculum
|
|
78
|
-
function createStores(
|
|
79
|
+
function createStores(vpDir) {
|
|
79
80
|
strategy = createStrategyStore({
|
|
80
|
-
dataDir
|
|
81
|
+
dataDir: DATA_DIR, vpDir,
|
|
82
|
+
competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
|
|
81
83
|
})
|
|
82
84
|
curriculum = createCurriculumStore({
|
|
83
|
-
dataDir, loadMap, loadProgress, loadSchedule,
|
|
85
|
+
dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
|
|
84
86
|
loadStrategyEvidence: () => strategy.evidence().evidence,
|
|
85
87
|
})
|
|
86
88
|
}
|
|
87
|
-
createStores(
|
|
89
|
+
createStores(ACTIVE_VP_DIR)
|
|
90
|
+
|
|
91
|
+
function migratePersonalWorkspace(accountId) {
|
|
92
|
+
return migrateWorkspacePersonalFiles(DATA_DIR, accountId)
|
|
93
|
+
}
|
|
88
94
|
|
|
89
95
|
function switchCoachWorkspace(accountId = '') {
|
|
90
|
-
|
|
91
|
-
mkdirSync(
|
|
92
|
-
createStores(
|
|
93
|
-
return
|
|
96
|
+
ACTIVE_VP_DIR = accountId ? join(DATA_DIR, 'vp-accounts', String(accountId)) : DATA_DIR
|
|
97
|
+
mkdirSync(ACTIVE_VP_DIR, { recursive: true })
|
|
98
|
+
createStores(ACTIVE_VP_DIR)
|
|
99
|
+
return ACTIVE_VP_DIR
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
const vpClient = createVpClient({ dataDir: DATA_DIR })
|
|
97
103
|
const savedVp = vpClient.localStatus()
|
|
98
|
-
if (savedVp.connected)
|
|
104
|
+
if (savedVp.connected) {
|
|
105
|
+
migratePersonalWorkspace(savedVp.activeAccountId)
|
|
106
|
+
switchCoachWorkspace(savedVp.activeAccountId)
|
|
107
|
+
}
|
|
99
108
|
|
|
100
109
|
// 探针要的文件,各自代表一条后面的依赖:
|
|
101
110
|
// MAP.yaml 地图(coach_next 读它算候选)
|
|
@@ -187,7 +196,7 @@ function loadMap() {
|
|
|
187
196
|
//
|
|
188
197
|
// 游标显式存,不靠"已验证的最大深度"推 —— 推出来的东西没法手改,
|
|
189
198
|
// 而且他想复习某节时游标要能停在那儿。
|
|
190
|
-
const PROGRESS_PATH = () => join(
|
|
199
|
+
const PROGRESS_PATH = () => join(DATA_DIR, 'PROGRESS.yaml')
|
|
191
200
|
|
|
192
201
|
function emptyProgress() {
|
|
193
202
|
return { version: 1, cursor: '', updated: '', nodes: {}, pending: null }
|
|
@@ -326,7 +335,7 @@ function saveProgress(p) {
|
|
|
326
335
|
//
|
|
327
336
|
// `actual` 是**校准真实做题速度**的唯一来源 —— 计划 60 分钟实际 95 分钟,
|
|
328
337
|
// 攒几周才知道他的速度到底是多少。这条现在一条数据都没有,所以排程只能保守。
|
|
329
|
-
const SCHEDULE_PATH = () => join(
|
|
338
|
+
const SCHEDULE_PATH = () => join(DATA_DIR, 'SCHEDULE.yaml')
|
|
330
339
|
|
|
331
340
|
function emptySchedule() {
|
|
332
341
|
return { version: 1, updated: '', days: {} }
|
|
@@ -1539,7 +1548,11 @@ function registerPages(ctx) {
|
|
|
1539
1548
|
try {
|
|
1540
1549
|
let result
|
|
1541
1550
|
if (payload.op === 'start') result = await vpClient.start({ baseUrl: payload.baseUrl })
|
|
1542
|
-
else if (payload.op === 'finish') {
|
|
1551
|
+
else if (payload.op === 'finish') {
|
|
1552
|
+
result = await vpClient.finish({ code: payload.code, baseUrl: payload.baseUrl })
|
|
1553
|
+
migratePersonalWorkspace(result.activeAccountId)
|
|
1554
|
+
switchCoachWorkspace(result.activeAccountId)
|
|
1555
|
+
}
|
|
1543
1556
|
else if (payload.op === 'disconnect') { result = vpClient.disconnect(); switchCoachWorkspace('') }
|
|
1544
1557
|
else if (payload.op === 'sync') result = await vpClient.sync({})
|
|
1545
1558
|
else return sendJson(res, 400, { ok: false, reject: 'op 只能是 start/finish/disconnect/sync' })
|
|
@@ -1869,6 +1882,15 @@ const vpAccount = (value) => value && Number(value.id) > 0
|
|
|
1869
1882
|
: emptyVpAccount()
|
|
1870
1883
|
|
|
1871
1884
|
function vpEventsFromSync(payload, account) {
|
|
1885
|
+
// The platform stores timezone-less timestamps as UTC, not the client machine's local time.
|
|
1886
|
+
const instant = value => {
|
|
1887
|
+
const text = String(value || '').trim()
|
|
1888
|
+
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(text) ? `${text.replace(' ', 'T')}Z` : text
|
|
1889
|
+
const day = normalized.slice(0, 10)
|
|
1890
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(Date.parse(`${day}T12:00:00Z`)) || new Date(`${day}T12:00:00Z`).toISOString().slice(0, 10) !== day) return ''
|
|
1891
|
+
const stamp = Date.parse(normalized)
|
|
1892
|
+
return Number.isFinite(stamp) ? new Date(stamp).toISOString() : ''
|
|
1893
|
+
}
|
|
1872
1894
|
const submissions = Array.isArray(payload?.submissions) ? payload.submissions : []
|
|
1873
1895
|
const contests = Array.isArray(payload?.contests) ? payload.contests : []
|
|
1874
1896
|
const byContest = new Map()
|
|
@@ -1886,15 +1908,18 @@ function vpEventsFromSync(payload, account) {
|
|
|
1886
1908
|
return [...groups.entries()].map(([contestId, problems]) => {
|
|
1887
1909
|
const contest = byContest.get(contestId) || {}
|
|
1888
1910
|
const start = String(contest.start_time || [...problems.values()][0]?.[0]?.submitted_at || '')
|
|
1911
|
+
const startedAt = instant(start)
|
|
1889
1912
|
const normalized = [...problems.entries()].map(([problemId, attempts]) => {
|
|
1913
|
+
attempts.sort((a, b) => String(a.submitted_at || '').localeCompare(String(b.submitted_at || '')) || Number(a.id) - Number(b.id))
|
|
1890
1914
|
const solved = attempts.find((item) => ['AC', 'OK', 'ACCEPTED', 'SOLVED'].includes(String(item.verdict || '').toUpperCase()))
|
|
1891
|
-
const at = solved?.submitted_at || attempts.at(-1)?.submitted_at
|
|
1892
|
-
const minutes =
|
|
1893
|
-
return { problemId, status: solved ? 'AC' : String(attempts.at(-1)?.verdict || 'UNKNOWN').toUpperCase(), attempts: attempts.length,
|
|
1915
|
+
const at = instant(solved?.submitted_at || attempts.at(-1)?.submitted_at)
|
|
1916
|
+
const minutes = startedAt && at ? Math.max(0, Math.round((Date.parse(at) - Date.parse(startedAt)) / 60000)) : 0
|
|
1917
|
+
return { problemId, status: solved ? 'AC' : String(attempts.at(-1)?.verdict || 'UNKNOWN').toUpperCase(), attempts: attempts.length,
|
|
1918
|
+
solveMinutes: minutes, competencies: [], ...(at && Number.isFinite(Date.parse(at)) ? { occurredAt: at } : {}) }
|
|
1894
1919
|
})
|
|
1895
1920
|
return {
|
|
1896
1921
|
eventId: `vp:${account.id}:${contestId}`,
|
|
1897
|
-
contest: String(contest.title || contestId), contestId: String(contestId), date: start.slice(0, 10),
|
|
1922
|
+
contest: String(contest.title || contestId), contestId: String(contestId), date: startedAt ? ymd(new Date(startedAt)) : start.slice(0, 10),
|
|
1898
1923
|
durationMinutes: Number(contest.duration_min || 0), virtual: true, teamMode: 'team', problems: normalized,
|
|
1899
1924
|
note: '由 VP 只读同步生成;具体能力标签在复盘时补充。',
|
|
1900
1925
|
}
|
|
@@ -1953,6 +1978,7 @@ function apply(ctx) {
|
|
|
1953
1978
|
if (action === 'start') result = await vpClient.start({ baseUrl: base })
|
|
1954
1979
|
else if (action === 'finish') {
|
|
1955
1980
|
result = await vpClient.finish({ code: args?.code, baseUrl: base })
|
|
1981
|
+
migratePersonalWorkspace(result.activeAccountId)
|
|
1956
1982
|
switchCoachWorkspace(result.activeAccountId)
|
|
1957
1983
|
} else if (action === 'disconnect') {
|
|
1958
1984
|
result = vpClient.disconnect()
|
|
@@ -1962,7 +1988,7 @@ function apply(ctx) {
|
|
|
1962
1988
|
return { ok: true, reject: '', action, connected: Boolean(result.connected ?? action === 'finish'), activeAccountId: Number(result.activeAccountId || account.id || 0), account,
|
|
1963
1989
|
requestId: String(result.requestId || ''), authorizeUrl: String(result.authorizeUrl || ''), expiresAt: String(result.expiresAt || ''),
|
|
1964
1990
|
text: action === 'start' ? `打开授权页登录 VP:${result.authorizeUrl}\n授权后把页面显示的 code 交给 coach_vp_connect finish。` :
|
|
1965
|
-
action === 'finish' ? `VP 已连接:${account.handle}(账号 ${account.id}
|
|
1991
|
+
action === 'finish' ? `VP 已连接:${account.handle}(账号 ${account.id});VP 缓存按账号隔离,个人课程和进度沿用。` :
|
|
1966
1992
|
action === 'disconnect' ? 'VP 已断开;缓存保留,token 已删除。' : (result.connected ? `当前 VP:${account.handle}(账号 ${account.id})` : '当前没有已连接的 VP 账号。') }
|
|
1967
1993
|
} catch (err) {
|
|
1968
1994
|
return { ok: false, reject: String(err?.message || err), action, connected: false, activeAccountId: 0, account: emptyVpAccount(), requestId: '', authorizeUrl: '', expiresAt: '', text: '' }
|
|
@@ -1972,7 +1998,7 @@ function apply(ctx) {
|
|
|
1972
1998
|
|
|
1973
1999
|
ctx.tools.register(defineTool({
|
|
1974
2000
|
name: 'coach_vp_sync',
|
|
1975
|
-
description: '从当前 VP
|
|
2001
|
+
description: '从当前 VP 账号增量读取一页比赛、提交、事件和个人时间线。hasMore 为 true 时继续调用;pageCounts 是本页拉取数,返回数组是累计缓存。VP 平台只读,游标和缓存按账号隔离。同步后写入或更新带账号前缀的 VP 训练记录,导入被拒绝时返回 ok:false。',
|
|
1976
2002
|
parameters: {
|
|
1977
2003
|
limit: { type: 'integer', description: '单次最多同步多少条提交,默认 200' },
|
|
1978
2004
|
includeProfile: { type: 'boolean', description: '是否同步个人 rating/比赛历史,默认 true' },
|
|
@@ -1980,7 +2006,13 @@ function apply(ctx) {
|
|
|
1980
2006
|
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
1981
2007
|
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 } } },
|
|
1982
2008
|
imported: { type: 'integer', required: true }, contests: { type: 'array', required: true }, submissions: { type: 'array', required: true }, events: { type: 'array', required: true },
|
|
1983
|
-
|
|
2009
|
+
hasMore: { type: 'boolean', required: true },
|
|
2010
|
+
pageCounts: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2011
|
+
contests: { type: 'integer', required: true }, submissions: { type: 'integer', required: true }, events: { type: 'integer', required: true },
|
|
2012
|
+
} },
|
|
2013
|
+
nextCursor: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2014
|
+
submission_id: { type: 'integer', required: true }, event_id: { type: 'integer', required: true },
|
|
2015
|
+
} }, cachedPath: { type: 'string', required: true }, text: { type: 'string', required: true },
|
|
1984
2016
|
} }, render: (_a, v) => strategyText(v) },
|
|
1985
2017
|
execute: async (args) => {
|
|
1986
2018
|
try {
|
|
@@ -1988,11 +2020,17 @@ function apply(ctx) {
|
|
|
1988
2020
|
const account = vpAccount(payload.account)
|
|
1989
2021
|
switchCoachWorkspace(account.id)
|
|
1990
2022
|
const importedEvents = vpEventsFromSync(payload, account)
|
|
1991
|
-
const imported = importedEvents.length ? strategy.vpImport(importedEvents) : { imported: 0 }
|
|
1992
|
-
|
|
1993
|
-
|
|
2023
|
+
const imported = importedEvents.length ? strategy.vpImport(importedEvents, { source: 'sync' }) : { ok: true, reject: '', imported: 0 }
|
|
2024
|
+
const hasMore = payload.hasMore === true
|
|
2025
|
+
const pageCounts = payload.pageCounts
|
|
2026
|
+
return { ok: imported.ok, reject: String(imported.reject || ''), account, imported: Number(imported.imported || 0), contests: payload.contests || [], submissions: payload.submissions || [], events: payload.events || [],
|
|
2027
|
+
hasMore, pageCounts, nextCursor: { submission_id: Number(payload.cursor?.submission_id || 0), event_id: Number(payload.cursor?.event_id || 0) }, cachedPath: String(payload.cachedPath || ''),
|
|
2028
|
+
text: imported.ok
|
|
2029
|
+
? `VP 本页同步:${account.handle};本页拉取比赛 ${pageCounts.contests} 场、提交 ${pageCounts.submissions} 条、事件 ${pageCounts.events} 条;缓存累计比赛 ${payload.contests?.length || 0} 场、提交 ${payload.submissions?.length || 0} 条、事件 ${payload.events?.length || 0} 条;写入或更新训练记录 ${imported.imported || 0} 场。${hasMore ? '仍有下一页,请继续调用 coach_vp_sync。' : '分页读取完成。'}`
|
|
2030
|
+
: `VP 缓存已同步,训练记录导入被拒绝:${imported.reject}` }
|
|
1994
2031
|
} catch (err) {
|
|
1995
|
-
return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), imported: 0, contests: [], submissions: [], events: [],
|
|
2032
|
+
return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), imported: 0, contests: [], submissions: [], events: [], hasMore: false,
|
|
2033
|
+
pageCounts: { contests: 0, submissions: 0, events: 0 }, nextCursor: { submission_id: 0, event_id: 0 }, cachedPath: '', text: '' }
|
|
1996
2034
|
}
|
|
1997
2035
|
},
|
|
1998
2036
|
}))
|
|
@@ -2042,15 +2080,16 @@ function apply(ctx) {
|
|
|
2042
2080
|
|
|
2043
2081
|
ctx.tools.register(defineTool({
|
|
2044
2082
|
name: 'coach_target',
|
|
2045
|
-
description: '
|
|
2083
|
+
description: '设置或读取比赛目标;profiles 查看带来源、样本范围和限制的比赛配置,profileId 可选用。样本不是赛场预测,不自动生成奖牌线或知识点清单。目标先于知识点。',
|
|
2046
2084
|
parameters: {
|
|
2047
|
-
action: { type: 'string', required: true, enum: ['read', 'set', 'clear'] },
|
|
2085
|
+
action: { type: 'string', required: true, enum: ['read', 'profiles', 'set', 'clear'] },
|
|
2048
2086
|
expectedRevision: { type: 'integer', description: 'read 返回的 revision;首次 set 用 0。' },
|
|
2049
2087
|
contest: { type: 'string', description: '比赛名称,例如西安区域赛' },
|
|
2050
2088
|
date: { type: 'string', description: '比赛日期 YYYY-MM-DD' },
|
|
2051
2089
|
result: { type: 'string', description: '目标结果,例如区域赛金牌' },
|
|
2052
2090
|
teamMode: { type: 'string', description: 'solo 或 team' },
|
|
2053
2091
|
weeklyHours: { type: 'number', description: '每周可用训练小时' },
|
|
2092
|
+
profileId: { type: 'string', description: 'profiles 返回的配置 id;空串清除。未选择时明确缺比赛样本依据。' },
|
|
2054
2093
|
constraints: { type: 'object', additionalProperties: true, description: '可选时间约束,例如 weekday/saturday/sunday' },
|
|
2055
2094
|
priorities: { type: 'array', description: '能力优先级 [{id, weight, reason}],weight 越大越优先', items: { type: 'object', additionalProperties: false, properties: {
|
|
2056
2095
|
id: { type: 'string', required: true }, weight: { type: 'number', required: true }, reason: { type: 'string' },
|
|
@@ -2062,11 +2101,13 @@ function apply(ctx) {
|
|
|
2062
2101
|
teamMode: { type: 'string', required: true }, weeklyHours: { type: 'number', required: true }, priorities: { type: 'array', required: true },
|
|
2063
2102
|
updated: { type: 'string', required: true }, version: { type: 'integer', required: true },
|
|
2064
2103
|
constraints: { type: 'object', required: true, additionalProperties: true }, text: { type: 'string', required: true },
|
|
2104
|
+
profileId: { type: 'string', required: true }, availableProfiles: { type: 'array', required: true },
|
|
2065
2105
|
} }, render: (_a, v) => strategyText(v) },
|
|
2066
2106
|
execute: (args) => {
|
|
2067
2107
|
const v = strategy.target(args?.action || 'read', args ?? {})
|
|
2068
2108
|
return { ...v, text: v.ok
|
|
2069
|
-
?
|
|
2109
|
+
? args?.action === 'profiles' ? ['【比赛样本配置】', ...v.availableProfiles.map(p => `${p.id} · ${p.title}(${p.status})\n范围:${p.scope}\n取舍:${p.selectionGuidance.join(';')}\n限制:${p.limitations.join(';')}\n来源:${p.sources.map(s => `${s.title} ${s.url}`).join(';')}`)].join('\n\n')
|
|
2110
|
+
: `【比赛目标 · revision ${v.revision}】${v.contest} / ${v.date}\n目标:${v.result}\n模式:${v.teamMode};每周 ${v.weeklyHours} 小时\n样本配置:${v.profileId || '未选,先 profiles 核查'}\n能力重点:${v.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`
|
|
2070
2111
|
: '' }
|
|
2071
2112
|
},
|
|
2072
2113
|
}))
|
|
@@ -2075,8 +2116,21 @@ function apply(ctx) {
|
|
|
2075
2116
|
name: 'coach_vp_import',
|
|
2076
2117
|
description: '导入 VP 或模拟赛记录。它保存比赛过程证据,让教练根据读题、耗时、提交和题目选择复盘,不再只看 CF rating。重复 eventId 会更新,不会重复堆积。',
|
|
2077
2118
|
parameters: {
|
|
2078
|
-
events: { type: 'array', required: true, items: { type: 'object', additionalProperties:
|
|
2079
|
-
description: '每场含 eventId/contest/date/problems
|
|
2119
|
+
events: { type: 'array', required: true, items: { type: 'object', additionalProperties: false,
|
|
2120
|
+
description: '每场含 eventId/contest/date/problems。AC 不证明独立性;失败不直接证明能力短板。测量由核对回放后的教练明确记录,同一场同一指标仅计一次。显式导入更新整场记录,请保留已有题目与注解。', properties: {
|
|
2121
|
+
eventId: { type: 'string', required: true }, contest: { type: 'string', required: true }, date: { type: 'string', required: true },
|
|
2122
|
+
durationMinutes: { type: 'integer' }, virtual: { type: 'boolean' }, teamMode: { type: 'string', enum: ['solo', 'team'] }, note: { type: 'string' },
|
|
2123
|
+
problems: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
2124
|
+
problemId: { type: 'string', required: true }, status: { type: 'string', required: true },
|
|
2125
|
+
readMinutes: { type: 'number' }, solveMinutes: { type: 'number', description: 'VP自动数据是从开赛到AC的经过时间,不是该题净用时。' },
|
|
2126
|
+
attempts: { type: 'integer' }, competencies: { type: 'array', items: { type: 'string' } }, note: { type: 'string' },
|
|
2127
|
+
independent: { type: 'boolean' }, occurredAt: { type: 'string' },
|
|
2128
|
+
} } },
|
|
2129
|
+
measurements: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
2130
|
+
competency: { type: 'string', required: true }, metric: { type: 'string', required: true }, value: { type: 'number', required: true },
|
|
2131
|
+
passed: { type: 'boolean', required: true, description: '是否对照明确条件验收通过;不能仅凭AC推断。' }, independent: { type: 'boolean' }, occurredAt: { type: 'string' },
|
|
2132
|
+
} } },
|
|
2133
|
+
} } },
|
|
2080
2134
|
},
|
|
2081
2135
|
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2082
2136
|
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, imported: { type: 'integer', required: true },
|
|
@@ -2091,12 +2145,17 @@ function apply(ctx) {
|
|
|
2091
2145
|
|
|
2092
2146
|
ctx.tools.register(defineTool({
|
|
2093
2147
|
name: 'coach_postmortem',
|
|
2094
|
-
description: '
|
|
2148
|
+
description: '记录 VP 复盘:findings 分 hypothesis(待确认)、confirmed(已核实短板)、resolved(已解决)。先 coach_curriculum assess 获取本场原始 VP 事实 id,结论必须引用真实 evidenceRefs。工具核对引用,不替代你核对代码/回放的因果判断;旧文字复盘只保留为观察。',
|
|
2095
2149
|
parameters: {
|
|
2096
2150
|
eventId: { type: 'string', required: true }, competencies: { type: 'array', items: { type: 'string' } },
|
|
2097
2151
|
missedReads: { type: 'array', items: { type: 'string' } }, decisionErrors: { type: 'array', items: { type: 'string' } },
|
|
2098
2152
|
teamIssues: { type: 'array', items: { type: 'string' } }, rootCauses: { type: 'array', items: { type: 'string' } },
|
|
2099
2153
|
nextActions: { type: 'array', items: { type: 'string' } }, note: { type: 'string' }, date: { type: 'string' },
|
|
2154
|
+
findings: { type: 'array', description: '每能力最多一项结论;确认前需对照代码或回放。不能把 WA 直接等同于不会。', items: { type: 'object', additionalProperties: false, properties: {
|
|
2155
|
+
competency: { type: 'string', required: true }, status: { type: 'string', required: true, enum: ['hypothesis', 'confirmed', 'resolved'] },
|
|
2156
|
+
cause: { type: 'string', required: true, description: '至少10字,说明观察与推理;不能只重复判题结果。' },
|
|
2157
|
+
evidenceRefs: { type: 'array', required: true, items: { type: 'string' }, description: '本场真实 VP 事实 id,内容变化后旧引用失效。' },
|
|
2158
|
+
} } },
|
|
2100
2159
|
},
|
|
2101
2160
|
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2102
2161
|
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, eventId: { type: 'string', required: true },
|
|
@@ -2110,17 +2169,23 @@ function apply(ctx) {
|
|
|
2110
2169
|
|
|
2111
2170
|
ctx.tools.register(defineTool({
|
|
2112
2171
|
name: 'coach_focus',
|
|
2113
|
-
description: '
|
|
2172
|
+
description: '汇总目标、比赛样本、当前课程与近30天 VP 事实。成功不算问题;只有引用事实的 confirmed 结论增加短板计数。返回独立能力起点、待确认观察、已确认问题、验收缺口与来源。排序是取舍参考,不是掌握度;没有目标时拒绝猜方向。',
|
|
2114
2173
|
parameters: {},
|
|
2115
2174
|
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2116
2175
|
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, contest: { type: 'string', required: true },
|
|
2117
2176
|
deadline: { type: 'string', required: true }, focus: { type: 'array', required: true }, postponed: { type: 'array', required: true },
|
|
2118
2177
|
evidenceEvents: { type: 'integer', required: true }, postmortems: { type: 'integer', required: true }, text: { type: 'string', required: true },
|
|
2178
|
+
profile: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2179
|
+
id: { type: 'string', required: true }, title: { type: 'string', required: true }, scope: { type: 'string', required: true }, status: { type: 'string', required: true },
|
|
2180
|
+
sources: { type: 'array', required: true }, competencyIds: { type: 'array', required: true }, selectionGuidance: { type: 'array', required: true }, limitations: { type: 'array', required: true },
|
|
2181
|
+
} }, daysRemaining: { type: 'integer', required: true },
|
|
2119
2182
|
course: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2120
2183
|
exists: { type: 'boolean', required: true }, revision: { type: 'integer', required: true },
|
|
2121
2184
|
phaseId: { type: 'string', required: true }, phaseTitle: { type: 'string', required: true },
|
|
2122
2185
|
focusNodes: { type: 'array', required: true, items: { type: 'string' } }, pendingNodes: { type: 'array', required: true, items: { type: 'string' } },
|
|
2123
2186
|
phaseCompetencies: { type: 'array', required: true, items: { type: 'string' } },
|
|
2187
|
+
goal: { type: 'string', required: true }, outcome: { type: 'string', required: true },
|
|
2188
|
+
exitCheckResults: { type: 'array', required: true },
|
|
2124
2189
|
focusExpired: { type: 'boolean', required: true }, missingFreshEvidence: { type: 'array', required: true, items: { type: 'string' } },
|
|
2125
2190
|
} },
|
|
2126
2191
|
} }, render: (_a, v) => strategyText(v) },
|
|
@@ -2136,13 +2201,17 @@ function apply(ctx) {
|
|
|
2136
2201
|
exists: Boolean(snapshot.exists), revision: Number(snapshot.revision || 0), phaseId: String(snapshot.phaseId || ''),
|
|
2137
2202
|
phaseTitle: String(snapshot.phaseTitle || ''), focusNodes: snapshot.focusNodes ?? [], pendingNodes: snapshot.pendingNodes ?? [],
|
|
2138
2203
|
phaseCompetencies: snapshot.phaseCompetencies ?? [],
|
|
2204
|
+
goal: String(snapshot.goal || ''), outcome: String(snapshot.outcome || ''), exitCheckResults: snapshot.exitCheckResults ?? [],
|
|
2139
2205
|
focusExpired: Boolean(snapshot.focusExpired), missingFreshEvidence: snapshot.missingFreshEvidence ?? [],
|
|
2140
2206
|
}
|
|
2141
|
-
return { ...v, course, text: v.ok ? [
|
|
2142
|
-
|
|
2207
|
+
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(';')}`,
|
|
2209
|
+
...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}`),
|
|
2143
2210
|
course.exists ? `课程阶段:${course.phaseTitle}(${course.phaseId})\n课程主线:${course.focusNodes.join('、') || '能力证据驱动'}\n比赛能力:${course.phaseCompetencies.join('、') || '地图知识点'}` : '课程阶段:尚未建立,用 coach_curriculum assess/read/create 建立长期路线。',
|
|
2144
2211
|
course.pendingNodes.length ? `课程未完成动作:${course.pendingNodes.join('、')}` : '',
|
|
2212
|
+
course.exitCheckResults.length ? `验收进展:${course.exitCheckResults.map(check => `${check.target}:${check.passed ? `已满足(${check.matchedEvents}次,跨${check.distinctDays}日)` : check.missing.join(',')}`).join(';')}` : '',
|
|
2145
2213
|
v.postponed.length ? `暂缓:${v.postponed.join('、')}` : '没有暂缓项。',
|
|
2214
|
+
'教练据此解释取舍并给一个动作;排序不是掌握度,也不是奖牌概率。',
|
|
2146
2215
|
].join('\n') : '' }
|
|
2147
2216
|
},
|
|
2148
2217
|
}))
|
|
@@ -2156,6 +2225,7 @@ function apply(ctx) {
|
|
|
2156
2225
|
id: { type: 'string', required: true }, title: { type: 'string', required: true }, contestUse: { type: 'string', required: true },
|
|
2157
2226
|
nodes: { type: 'array', required: true }, appliesTo: { type: 'array', required: true }, extensions: { type: 'array', required: true }, notFor: { type: 'array', required: true },
|
|
2158
2227
|
commonFailures: { type: 'array', required: true }, transferProblems: { type: 'array', required: true }, exitEvidence: { type: 'array', required: true },
|
|
2228
|
+
recognition: { type: 'array', required: true }, proofQuestions: { type: 'array', required: true }, sources: { type: 'array', required: true },
|
|
2159
2229
|
text: { type: 'string', required: true },
|
|
2160
2230
|
} }, render: (_a, v) => strategyText(v) },
|
|
2161
2231
|
execute: (args) => {
|
|
@@ -2163,6 +2233,8 @@ function apply(ctx) {
|
|
|
2163
2233
|
return { ...v, text: v.ok ? [`【范围卡片】${v.title}(${v.id})`, `比赛价值:${v.contestUse}`,
|
|
2164
2234
|
`适用:${v.appliesTo.join('、')}`, `延伸:${v.extensions.join('、')}`, `不要用于:${v.notFor.join('、')}`,
|
|
2165
2235
|
`常见错误:${v.commonFailures.join('、')}`, `迁移:${v.transferProblems.join('、')}`, `退出条件:${v.exitEvidence.join('、')}`,
|
|
2236
|
+
`识别信号:${v.recognition.join(';') || '先核对适用条件'}`, `解释与证明:${v.proofQuestions.join(';') || '先解释为何条件足够'}`,
|
|
2237
|
+
`来源:${v.sources.map(source => `${source.title} ${source.url}`).join(';') || '此卡尚无可核验来源'}`,
|
|
2166
2238
|
].join('\n') : '' }
|
|
2167
2239
|
},
|
|
2168
2240
|
}))
|
|
@@ -12,6 +12,10 @@ function date(value) {
|
|
|
12
12
|
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === d ? d : ''
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export const validOccurredAt = (value) => typeof value === 'string' &&
|
|
16
|
+
/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value) &&
|
|
17
|
+
Boolean(date(value.slice(0, 10))) && Number.isFinite(Date.parse(value))
|
|
18
|
+
|
|
15
19
|
// Sorted object keys make references insensitive to YAML/JavaScript field order.
|
|
16
20
|
// Arrays keep their order, and the caller separately includes an event's position.
|
|
17
21
|
function canonical(value, visiting = new Set()) {
|
|
@@ -38,6 +42,7 @@ function fingerprint(record) {
|
|
|
38
42
|
|
|
39
43
|
function checkValid(record) {
|
|
40
44
|
if (!object(record) || !date(record.date) || !['failed', 'overTime', 'passed'].includes(record.outcome)) return false
|
|
45
|
+
if (record.independent !== undefined && typeof record.independent !== 'boolean') return false
|
|
41
46
|
// These fields were introduced at different times. Missing legacy fields are
|
|
42
47
|
// compatible, but present malformed numbers cannot become trusted evidence.
|
|
43
48
|
for (const key of ['score', 'maxScore', 'passScore', 'minutes', 'limit', 'verifiedAt', 'selfReported']) {
|
|
@@ -65,7 +70,7 @@ const practiceKey = (day, node, record, minutes) => JSON.stringify([
|
|
|
65
70
|
|
|
66
71
|
/**
|
|
67
72
|
* Read-only catalog of usable observations in PROGRESS and SCHEDULE.
|
|
68
|
-
*
|
|
73
|
+
* Performance fields keep their explicit types. IDs contain an event locator and a
|
|
69
74
|
* canonical content hash, so references cannot silently resolve to edited data.
|
|
70
75
|
*/
|
|
71
76
|
export function collectEvidence(progress, schedule = { days: {} }) {
|
|
@@ -73,12 +78,21 @@ export function collectEvidence(progress, schedule = { days: {} }) {
|
|
|
73
78
|
const seenIds = new Set()
|
|
74
79
|
const progressPractice = new Set()
|
|
75
80
|
const add = (source, node, day, locator, eventId, signal, summary, record) => {
|
|
81
|
+
if (record.occurredAt !== undefined && !validOccurredAt(record.occurredAt)) return false
|
|
76
82
|
const hash = fingerprint(record)
|
|
77
83
|
if (!hash) return false
|
|
78
84
|
const id = `${source}:${encodeURIComponent(node)}:${locator}:${hash}`
|
|
79
85
|
if (!seenIds.has(id)) {
|
|
80
86
|
seenIds.add(id)
|
|
81
|
-
|
|
87
|
+
const facts = {}
|
|
88
|
+
if (typeof record.independent === 'boolean') facts.independent = record.independent
|
|
89
|
+
if (record.occurredAt !== undefined) facts.occurredAt = record.occurredAt
|
|
90
|
+
const minutes = source === 'schedule' ? record.actualMin : record.minutes
|
|
91
|
+
if (positive(minutes)) facts.minutes = minutes
|
|
92
|
+
if (typeof record.metric === 'string' && record.metric.trim() && Number.isFinite(record.value)) {
|
|
93
|
+
facts.metric = record.metric.trim(); facts.value = record.value
|
|
94
|
+
}
|
|
95
|
+
result.push({ id, eventId, source, node, date: day, signal, summary, fingerprint: hash, ...facts })
|
|
82
96
|
}
|
|
83
97
|
return true
|
|
84
98
|
}
|
|
@@ -1,12 +1,46 @@
|
|
|
1
|
+
import { validOccurredAt } from './curriculum-evidence.js'
|
|
2
|
+
|
|
1
3
|
const clean = (value) => String(value ?? '').trim()
|
|
2
4
|
const text = (value, field, phase) => {
|
|
3
5
|
const result = clean(value)
|
|
4
6
|
if (result.length < 10) throw new Error(`阶段 ${phase} 的 ${field} 需要至少 10 字的具体依据。`)
|
|
5
7
|
return result
|
|
6
8
|
}
|
|
7
|
-
const coreKeys = ['id', 'title', 'outcome', 'nodes', 'competencies', 'exitCriteria', 'requiredVerified']
|
|
9
|
+
const coreKeys = ['id', 'title', 'outcome', 'nodes', 'competencies', 'exitCriteria', 'exitChecks', 'requiredVerified']
|
|
8
10
|
export const corePhase = (phase) => Object.fromEntries(coreKeys.map((key) => [key, phase[key]]))
|
|
9
11
|
|
|
12
|
+
function normalizeExitChecks(input, phase) {
|
|
13
|
+
if (input === undefined) return []
|
|
14
|
+
if (!Array.isArray(input) || input.length > 100) throw new Error(`阶段 ${phase.id} 的 exitChecks 必须是最多 100 条的数组。`)
|
|
15
|
+
const targets = new Set()
|
|
16
|
+
return input.map((check) => {
|
|
17
|
+
const node = clean(check?.node), competency = clean(check?.competency)
|
|
18
|
+
if (Boolean(node) === Boolean(competency) || (node && !phase.nodes.includes(node)) || (competency && !phase.competencies.includes(competency))) throw new Error(`阶段 ${phase.id} 的 exitChecks 必须指定一个属于本阶段的 node 或 competency。`)
|
|
19
|
+
const target = `${node ? 'node' : 'competency'}:${node || competency}`
|
|
20
|
+
if (targets.has(target)) throw new Error(`阶段 ${phase.id} 的 exitChecks 目标不能重复:${node || competency}。`)
|
|
21
|
+
targets.add(target)
|
|
22
|
+
if (!Number.isInteger(check.minEvents) || check.minEvents < 1 || check.minEvents > 1000) throw new Error(`阶段 ${phase.id} 的 exitChecks.minEvents 应为 1–1000 的整数。`)
|
|
23
|
+
const result = { ...(node ? { node } : { competency }), minEvents: check.minEvents }
|
|
24
|
+
if (check.minDistinctDays !== undefined) {
|
|
25
|
+
if (!Number.isInteger(check.minDistinctDays) || check.minDistinctDays < 1 || check.minDistinctDays > check.minEvents) throw new Error(`阶段 ${phase.id} 的 minDistinctDays 应为 1–minEvents 的整数。`)
|
|
26
|
+
result.minDistinctDays = check.minDistinctDays
|
|
27
|
+
}
|
|
28
|
+
if (check.independent !== undefined) {
|
|
29
|
+
if (typeof check.independent !== 'boolean') throw new Error(`阶段 ${phase.id} 的 independent 必须是布尔值,不能从说明猜测。`)
|
|
30
|
+
result.independent = check.independent
|
|
31
|
+
}
|
|
32
|
+
if (check.maxMinutes !== undefined) {
|
|
33
|
+
if (!Number.isFinite(check.maxMinutes) || check.maxMinutes <= 0) throw new Error(`阶段 ${phase.id} 的 maxMinutes 必须为正数。`)
|
|
34
|
+
result.maxMinutes = check.maxMinutes
|
|
35
|
+
}
|
|
36
|
+
if (check.metric !== undefined || check.maxValue !== undefined) {
|
|
37
|
+
if (typeof check.metric !== 'string' || !check.metric.trim() || !Number.isFinite(check.maxValue)) throw new Error(`阶段 ${phase.id} 的 metric 与数值 maxValue 必须一起填写。`)
|
|
38
|
+
result.metric = check.metric.trim(); result.maxValue = check.maxValue
|
|
39
|
+
}
|
|
40
|
+
return result
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
10
44
|
export function prerequisitesOf(nodes, byId) {
|
|
11
45
|
const result = new Set()
|
|
12
46
|
const done = new Set()
|
|
@@ -29,7 +63,7 @@ export function prerequisitesOf(nodes, byId) {
|
|
|
29
63
|
return [...result].sort()
|
|
30
64
|
}
|
|
31
65
|
|
|
32
|
-
export function normalizePhases(input, byId, { legacy = false, completed = [] } = {}) {
|
|
66
|
+
export function normalizePhases(input, byId, { legacy = false, completed = [], allowMissingChecks = false } = {}) {
|
|
33
67
|
if (!Array.isArray(input) || input.length < 1 || input.length > 30) throw new Error('课程需要 1–30 个阶段。')
|
|
34
68
|
const ids = new Set()
|
|
35
69
|
return input.map((phase, index) => {
|
|
@@ -42,6 +76,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
|
|
|
42
76
|
requiredVerified: Array.isArray(phase?.requiredVerified) ? phase.requiredVerified.map(clean)
|
|
43
77
|
: (kind === 'training' || kind === 'legacy' ? [...(phase?.nodes ?? [])].map(clean) : []),
|
|
44
78
|
}
|
|
79
|
+
p.exitChecks = normalizeExitChecks(phase?.exitChecks, p)
|
|
45
80
|
if (!p.id || p.id.length > 80 || ids.has(p.id)) throw new Error('阶段 id 必须非空且唯一,最长 80 字符。')
|
|
46
81
|
ids.add(p.id)
|
|
47
82
|
if (!p.title || !p.outcome || !p.exitCriteria) throw new Error(`阶段 ${p.id} 缺标题、能力成果或验收条件。`)
|
|
@@ -52,7 +87,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
|
|
|
52
87
|
if (legacy) return { ...p, kind, finding: 'unknown', basis: '', goalContribution: '', priorityReason: '',
|
|
53
88
|
estimatedHours: 0, evidenceRefs: [], uncertainty: '', diagnosticMethod: '', prerequisites }
|
|
54
89
|
if (kind === 'legacy' && completed[index]?.kind === 'legacy' &&
|
|
55
|
-
JSON.stringify(corePhase(completed[index])) === JSON.stringify(p)) return { ...completed[index], prerequisites }
|
|
90
|
+
JSON.stringify(corePhase(completed[index])) === JSON.stringify(corePhase(p))) return { ...completed[index], prerequisites }
|
|
56
91
|
if (!['diagnostic', 'training', 'provisional'].includes(kind)) throw new Error(`阶段 ${p.id} 必须声明 kind=diagnostic/training/provisional;旧路线需补齐编排依据。`)
|
|
57
92
|
const finding = clean(phase.finding)
|
|
58
93
|
if (kind === 'training' ? !['gap', 'extension'].includes(finding) : finding !== 'unknown') throw new Error(`阶段 ${p.id}:训练需声明 gap 或 extension;诊断/待定只能声明 unknown。`)
|
|
@@ -60,6 +95,10 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
|
|
|
60
95
|
const estimatedHours = phase.estimatedHours
|
|
61
96
|
if (!Number.isFinite(estimatedHours) || estimatedHours < 0 || estimatedHours > 10000 || (kind !== 'provisional' && estimatedHours === 0)) throw new Error(`阶段 ${p.id} 必须给正数 estimatedHours;待定阶段可用 0 表示未估算。`)
|
|
62
97
|
if (kind === 'training' && p.nodes.some((node) => !p.requiredVerified.includes(node))) throw new Error(`训练阶段 ${p.id} 的节点必须全部列入 requiredVerified,不能用空验收绕过检测。`)
|
|
98
|
+
const preserved = completed[index] && JSON.stringify(corePhase(completed[index])) === JSON.stringify(corePhase(p))
|
|
99
|
+
if (kind === 'training' && !allowMissingChecks && !preserved &&
|
|
100
|
+
(p.nodes.some((node) => !p.exitChecks.some((check) => check.node === node)) ||
|
|
101
|
+
p.competencies.some((competency) => !p.exitChecks.some((check) => check.competency === competency)))) throw new Error(`训练阶段 ${p.id} 的 exitChecks 必须覆盖每个节点和能力;文字验收不能代替可检查条件。`)
|
|
63
102
|
if (kind !== 'training' && p.requiredVerified.length) throw new Error(`诊断/待定阶段 ${p.id} 不以既有 verified 验收,请使用新诊断记录。`)
|
|
64
103
|
return { ...p, kind, finding, estimatedHours, prerequisites,
|
|
65
104
|
basis: text(phase.basis, 'basis(观察或待确认问题)', p.id),
|
|
@@ -91,6 +130,8 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
|
|
|
91
130
|
if (!nodeMatch && !competencyMatch) throw new Error(`阶段 ${phase.id} 引用了无关的证据记录「${ref}」。`)
|
|
92
131
|
}
|
|
93
132
|
if (phase.kind !== 'training') continue
|
|
133
|
+
if (phase.nodes.some((node) => !phase.exitChecks.some((check) => check.node === node)) ||
|
|
134
|
+
phase.competencies.some((competency) => !phase.exitChecks.some((check) => check.competency === competency))) throw new Error(`阶段 ${phase.id} 尚缺结构化 exitChecks;旧路线请 revise 补齐,不会自动猜测验收次数。`)
|
|
94
135
|
const signal = phase.finding === 'gap' ? 'gap' : 'baseline'
|
|
95
136
|
const uncovered = phase.nodes.filter((node) => !phase.evidenceRefs.some((ref) => {
|
|
96
137
|
const entry = records.get(ref)
|
|
@@ -111,12 +152,40 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
|
|
|
111
152
|
}
|
|
112
153
|
}
|
|
113
154
|
|
|
114
|
-
|
|
155
|
+
const validEvidenceDate = (date, enteredDate, today) => typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) &&
|
|
156
|
+
Number.isFinite(Date.parse(`${date}T12:00:00Z`)) && new Date(`${date}T12:00:00Z`).toISOString().slice(0, 10) === date &&
|
|
157
|
+
date >= enteredDate && date <= today
|
|
158
|
+
const freshTiming = (entry, enteredDate, today, enteredAt, currentAt) => validEvidenceDate(entry.date, enteredDate, today) &&
|
|
159
|
+
(entry.occurredAt === undefined || (validOccurredAt(entry.occurredAt) &&
|
|
160
|
+
(!enteredAt || Date.parse(entry.occurredAt) >= Date.parse(enteredAt)) &&
|
|
161
|
+
(!currentAt || Date.parse(entry.occurredAt) <= Date.parse(currentAt))))
|
|
162
|
+
|
|
163
|
+
export function evaluateExitChecks(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = [], today = '9999-12-31', enteredAt = '', currentAt = '') {
|
|
164
|
+
const old = new Set(entryEvidenceRefs), oldEvents = new Set(entryEvidenceEvents)
|
|
165
|
+
return (phase.exitChecks ?? []).map((check) => {
|
|
166
|
+
const events = new Map()
|
|
167
|
+
for (const entry of catalog) {
|
|
168
|
+
if ((check.node ? entry.node !== check.node : entry.competency !== check.competency) || entry.signal !== 'baseline' ||
|
|
169
|
+
!entry.eventId || old.has(entry.id) || oldEvents.has(entry.eventId) || !freshTiming(entry, enteredDate, today, enteredAt, currentAt)) continue
|
|
170
|
+
if (check.independent !== undefined && entry.independent !== check.independent) continue
|
|
171
|
+
if (check.maxMinutes !== undefined && (!Number.isFinite(entry.minutes) || entry.minutes <= 0 || entry.minutes > check.maxMinutes)) continue
|
|
172
|
+
if (check.metric && (entry.metric !== check.metric || !Number.isFinite(entry.value) || entry.value > check.maxValue)) continue
|
|
173
|
+
events.set(entry.eventId, entry)
|
|
174
|
+
}
|
|
175
|
+
const matchedEvents = events.size, distinctDays = new Set([...events.values()].map((entry) => entry.date)).size
|
|
176
|
+
const missing = []
|
|
177
|
+
if (matchedEvents < check.minEvents) missing.push(`合格新事件 ${matchedEvents}/${check.minEvents}`)
|
|
178
|
+
if (distinctDays < (check.minDistinctDays ?? 1)) missing.push(`不同日期 ${distinctDays}/${check.minDistinctDays ?? 1}`)
|
|
179
|
+
return { ...check, target: check.node || check.competency, matchedEvents, distinctDays, passed: missing.length === 0, missing }
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function missingFreshEvidence(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = [], today = '9999-12-31', enteredAt = '', currentAt = '') {
|
|
115
184
|
const old = new Set(entryEvidenceRefs)
|
|
116
185
|
const events = new Set(entryEvidenceEvents)
|
|
117
186
|
const missingNodes = phase.nodes.filter((node) => !catalog.some((entry) => entry.node === node && !old.has(entry.id) &&
|
|
118
|
-
!events.has(entry.eventId) && entry
|
|
187
|
+
!events.has(entry.eventId) && freshTiming(entry, enteredDate, today, enteredAt, currentAt) && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
|
|
119
188
|
const missingCompetencies = phase.competencies.filter((competency) => !catalog.some((entry) => entry.competency === competency && !old.has(entry.id) &&
|
|
120
|
-
!events.has(entry.eventId) && entry
|
|
189
|
+
!events.has(entry.eventId) && freshTiming(entry, enteredDate, today, enteredAt, currentAt) && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
|
|
121
190
|
return [...missingNodes, ...missingCompetencies]
|
|
122
191
|
}
|